diff --git a/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts
index 3257edca885..929664e05bf 100644
--- a/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts
+++ b/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts
@@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
-import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
+import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
import * as DesktopConfig from "./DesktopConfig.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts";
diff --git a/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts
index 652072c1f5d..8953f3e9737 100644
--- a/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts
+++ b/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts
@@ -11,7 +11,7 @@ import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
-import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
+import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
interface CloudAuthTokenDocument {
diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
new file mode 100644
index 00000000000..5f4881c33e0
--- /dev/null
+++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
@@ -0,0 +1,96 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, describe, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+
+import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
+import * as DesktopConfig from "./DesktopConfig.ts";
+import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts";
+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
+
+const textDecoder = new TextDecoder();
+const textEncoder = new TextEncoder();
+
+function makeSafeStorageLayer(available: boolean) {
+ return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
+ isEncryptionAvailable: Effect.succeed(available),
+ encryptString: (value) => Effect.succeed(textEncoder.encode(`encrypted:${value}`)),
+ decryptString: (value) => {
+ const decoded = textDecoder.decode(value);
+ if (!decoded.startsWith("encrypted:")) {
+ return Effect.fail(
+ new ElectronSafeStorage.ElectronSafeStorageDecryptError({
+ cause: new Error("invalid encrypted catalog"),
+ }),
+ );
+ }
+ return Effect.succeed(decoded.slice("encrypted:".length));
+ },
+ } satisfies ElectronSafeStorage.ElectronSafeStorageShape);
+}
+
+function makeLayer(baseDir: string, encryptionAvailable = true) {
+ const environmentLayer = DesktopEnvironment.layer({
+ dirname: "/repo/apps/desktop/src",
+ homeDirectory: baseDir,
+ platform: "darwin",
+ processArch: "arm64",
+ appVersion: "1.2.3",
+ appPath: "/repo",
+ isPackaged: true,
+ resourcesPath: "/missing/resources",
+ runningUnderArm64Translation: false,
+ }).pipe(
+ Layer.provide(
+ Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
+ ),
+ );
+
+ return DesktopConnectionCatalogStore.layer.pipe(
+ Layer.provideMerge(environmentLayer),
+ Layer.provideMerge(makeSafeStorageLayer(encryptionAvailable)),
+ Layer.provideMerge(NodeServices.layer),
+ );
+}
+
+const withStore = (
+ effect: Effect.Effect,
+ encryptionAvailable = true,
+) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-desktop-connection-catalog-test-",
+ });
+ return yield* effect.pipe(Effect.provide(makeLayer(baseDir, encryptionAvailable)));
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
+
+describe("DesktopConnectionCatalogStore", () => {
+ it.effect("persists, reads, and clears an encrypted connection catalog", () =>
+ withStore(
+ Effect.gen(function* () {
+ const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
+ const catalog = '{"schemaVersion":1,"targets":[]}';
+
+ assert.isTrue(yield* store.set(catalog));
+ assert.deepStrictEqual(yield* store.get, Option.some(catalog));
+
+ yield* store.clear;
+ assert.deepStrictEqual(yield* store.get, Option.none());
+ }),
+ ),
+ );
+
+ it.effect("does not persist when secure storage is unavailable", () =>
+ withStore(
+ Effect.gen(function* () {
+ const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
+ assert.isFalse(yield* store.set("{}"));
+ assert.deepStrictEqual(yield* store.get, Option.none());
+ }),
+ false,
+ ),
+ );
+});
diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts
new file mode 100644
index 00000000000..19bec2f44cb
--- /dev/null
+++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts
@@ -0,0 +1,149 @@
+import { fromLenientJson } from "@t3tools/shared/schemaJson";
+import * as Context from "effect/Context";
+import * as Crypto from "effect/Crypto";
+import * as Data from "effect/Data";
+import * as Effect from "effect/Effect";
+import * as Encoding from "effect/Encoding";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
+
+const ConnectionCatalogDocument = Schema.Struct({
+ version: Schema.Literal(1),
+ encryptedCatalog: Schema.String,
+});
+type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type;
+
+const ConnectionCatalogDocumentJson = fromLenientJson(ConnectionCatalogDocument);
+const decodeConnectionCatalogDocumentJson = Schema.decodeEffect(ConnectionCatalogDocumentJson);
+const encodeConnectionCatalogDocumentJson = Schema.encodeEffect(ConnectionCatalogDocumentJson);
+
+export class DesktopConnectionCatalogStoreWriteError extends Data.TaggedError(
+ "DesktopConnectionCatalogStoreWriteError",
+)<{
+ readonly cause: PlatformError.PlatformError | Schema.SchemaError;
+}> {
+ override get message() {
+ return `Failed to write desktop connection catalog: ${this.cause.message}`;
+ }
+}
+
+export class DesktopConnectionCatalogStoreDecodeError extends Data.TaggedError(
+ "DesktopConnectionCatalogStoreDecodeError",
+)<{
+ readonly cause: Encoding.EncodingError;
+}> {
+ override get message() {
+ return "Failed to decode the desktop connection catalog.";
+ }
+}
+
+export interface DesktopConnectionCatalogStoreShape {
+ readonly get: Effect.Effect<
+ Option.Option,
+ | DesktopConnectionCatalogStoreDecodeError
+ | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
+ | ElectronSafeStorage.ElectronSafeStorageDecryptError
+ >;
+ readonly set: (
+ catalog: string,
+ ) => Effect.Effect<
+ boolean,
+ | DesktopConnectionCatalogStoreWriteError
+ | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
+ | ElectronSafeStorage.ElectronSafeStorageEncryptError
+ >;
+ readonly clear: Effect.Effect;
+}
+
+export class DesktopConnectionCatalogStore extends Context.Service<
+ DesktopConnectionCatalogStore,
+ DesktopConnectionCatalogStoreShape
+>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {}
+
+function decodeSecretBytes(
+ encoded: string,
+): Effect.Effect {
+ return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe(
+ Effect.mapError((cause) => new DesktopConnectionCatalogStoreDecodeError({ cause })),
+ );
+}
+
+const readDocument = (
+ fileSystem: FileSystem.FileSystem,
+ catalogPath: string,
+): Effect.Effect> =>
+ fileSystem.readFileString(catalogPath).pipe(
+ Effect.option,
+ Effect.flatMap(
+ Option.match({
+ onNone: () => Effect.succeed(Option.none()),
+ onSome: (raw) => decodeConnectionCatalogDocumentJson(raw).pipe(Effect.option),
+ }),
+ ),
+ );
+
+const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(function* (input: {
+ readonly fileSystem: FileSystem.FileSystem;
+ readonly path: Path.Path;
+ readonly catalogPath: string;
+ readonly document: ConnectionCatalogDocument;
+ readonly suffix: string;
+}): Effect.fn.Return {
+ const directory = input.path.dirname(input.catalogPath);
+ const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`;
+ const encoded = yield* encodeConnectionCatalogDocumentJson(input.document);
+ yield* input.fileSystem.makeDirectory(directory, { recursive: true });
+ yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
+ yield* input.fileSystem.rename(tempPath, input.catalogPath);
+});
+
+export const layer = Layer.effect(
+ DesktopConnectionCatalogStore,
+ Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
+ const crypto = yield* Crypto.Crypto;
+ const catalogPath = path.join(environment.stateDir, "connection-catalog.json");
+
+ return DesktopConnectionCatalogStore.of({
+ get: Effect.gen(function* () {
+ const document = yield* readDocument(fileSystem, catalogPath);
+ if (Option.isNone(document) || !(yield* safeStorage.isEncryptionAvailable)) {
+ return Option.none();
+ }
+ const bytes = yield* decodeSecretBytes(document.value.encryptedCatalog);
+ return Option.some(yield* safeStorage.decryptString(bytes));
+ }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")),
+ set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) {
+ if (!(yield* safeStorage.isEncryptionAvailable)) {
+ return false;
+ }
+ const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog));
+ const suffix = (yield* crypto.randomUUIDv4.pipe(
+ Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })),
+ )).replace(/-/g, "");
+ yield* writeDocument({
+ fileSystem,
+ path,
+ catalogPath,
+ document: { version: 1, encryptedCatalog },
+ suffix,
+ }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })));
+ return true;
+ }),
+ clear: fileSystem.remove(catalogPath, { force: true }).pipe(
+ Effect.catch(() => Effect.void),
+ Effect.withSpan("desktop.connectionCatalogStore.clear"),
+ ),
+ });
+ }),
+);
diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts
index cffe1572410..50706923fb3 100644
--- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts
+++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts
@@ -121,7 +121,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
input.readMagicDnsName ??
readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
- Effect.catch(() => Effect.succeed(null)),
+ Effect.orElseSucceed(() => null),
);
const dnsName =
input.statusJson === undefined
diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts
index c7b46265887..d30ddd682e6 100644
--- a/apps/desktop/src/electron/ElectronSafeStorage.ts
+++ b/apps/desktop/src/electron/ElectronSafeStorage.ts
@@ -1,54 +1,16 @@
-import * as Context from "effect/Context";
-import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Electron from "electron";
-export class ElectronSafeStorageAvailabilityError extends Data.TaggedError(
- "ElectronSafeStorageAvailabilityError",
-)<{
- readonly cause: unknown;
-}> {
- override get message() {
- return "Electron safe storage failed to check encryption availability.";
- }
-}
-
-export class ElectronSafeStorageEncryptError extends Data.TaggedError(
- "ElectronSafeStorageEncryptError",
-)<{
- readonly cause: unknown;
-}> {
- override get message() {
- return "Electron safe storage failed to encrypt a string.";
- }
-}
-
-export class ElectronSafeStorageDecryptError extends Data.TaggedError(
- "ElectronSafeStorageDecryptError",
-)<{
- readonly cause: unknown;
-}> {
- override get message() {
- return "Electron safe storage failed to decrypt a string.";
- }
-}
-
-export interface ElectronSafeStorageShape {
- readonly isEncryptionAvailable: Effect.Effect;
- readonly encryptString: (
- value: string,
- ) => Effect.Effect;
- readonly decryptString: (
- value: Uint8Array,
- ) => Effect.Effect;
-}
-
-export class ElectronSafeStorage extends Context.Service<
+import {
ElectronSafeStorage,
- ElectronSafeStorageShape
->()("@t3tools/desktop/electron/ElectronSafeStorage") {}
+ ElectronSafeStorageAvailabilityError,
+ ElectronSafeStorageDecryptError,
+ ElectronSafeStorageEncryptError,
+} from "./ElectronSafeStorageService.ts";
+
+export * from "./ElectronSafeStorageService.ts";
const make = ElectronSafeStorage.of({
isEncryptionAvailable: Effect.try({
diff --git a/apps/desktop/src/electron/ElectronSafeStorageService.ts b/apps/desktop/src/electron/ElectronSafeStorageService.ts
new file mode 100644
index 00000000000..5d2a0861d41
--- /dev/null
+++ b/apps/desktop/src/electron/ElectronSafeStorageService.ts
@@ -0,0 +1,48 @@
+import * as Context from "effect/Context";
+import * as Data from "effect/Data";
+import type * as Effect from "effect/Effect";
+
+export class ElectronSafeStorageAvailabilityError extends Data.TaggedError(
+ "ElectronSafeStorageAvailabilityError",
+)<{
+ readonly cause: unknown;
+}> {
+ override get message() {
+ return "Electron safe storage failed to check encryption availability.";
+ }
+}
+
+export class ElectronSafeStorageEncryptError extends Data.TaggedError(
+ "ElectronSafeStorageEncryptError",
+)<{
+ readonly cause: unknown;
+}> {
+ override get message() {
+ return "Electron safe storage failed to encrypt a string.";
+ }
+}
+
+export class ElectronSafeStorageDecryptError extends Data.TaggedError(
+ "ElectronSafeStorageDecryptError",
+)<{
+ readonly cause: unknown;
+}> {
+ override get message() {
+ return "Electron safe storage failed to decrypt a string.";
+ }
+}
+
+export interface ElectronSafeStorageShape {
+ readonly isEncryptionAvailable: Effect.Effect;
+ readonly encryptString: (
+ value: string,
+ ) => Effect.Effect;
+ readonly decryptString: (
+ value: Uint8Array,
+ ) => Effect.Effect;
+}
+
+export class ElectronSafeStorage extends Context.Service<
+ ElectronSafeStorage,
+ ElectronSafeStorageShape
+>()("@t3tools/desktop/electron/ElectronSafeStorageService/ElectronSafeStorage") {}
diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
index 40f84054878..167b1670d7a 100644
--- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts
+++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
@@ -9,6 +9,11 @@ import {
setCloudAuthToken,
} from "./methods/cloudAuth.ts";
import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
+import {
+ clearConnectionCatalog,
+ getConnectionCatalog,
+ setConnectionCatalog,
+} from "./methods/connectionCatalog.ts";
import {
getSavedEnvironmentRegistry,
getSavedEnvironmentSecret,
@@ -62,6 +67,9 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
yield* ipc.handle(getSavedEnvironmentSecret);
yield* ipc.handle(setSavedEnvironmentSecret);
yield* ipc.handle(removeSavedEnvironmentSecret);
+ yield* ipc.handle(getConnectionCatalog);
+ yield* ipc.handle(setConnectionCatalog);
+ yield* ipc.handle(clearConnectionCatalog);
yield* ipc.handle(discoverSshHosts);
yield* ipc.handle(ensureSshEnvironment);
diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts
index 1ded238c663..2120f769270 100644
--- a/apps/desktop/src/ipc/channels.ts
+++ b/apps/desktop/src/ipc/channels.ts
@@ -25,6 +25,9 @@ export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environ
export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
+export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog";
+export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog";
+export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog";
export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
diff --git a/apps/desktop/src/ipc/methods/cloudAuth.ts b/apps/desktop/src/ipc/methods/cloudAuth.ts
index a5a7aacff79..9f6a964ac05 100644
--- a/apps/desktop/src/ipc/methods/cloudAuth.ts
+++ b/apps/desktop/src/ipc/methods/cloudAuth.ts
@@ -59,7 +59,7 @@ function executeCloudAuthFetch(url: URL, input: typeof DesktopCloudAuthFetchInpu
const method = (input.method ?? "GET") as "GET" | "POST";
const headers = new Headers(input.headers);
const response = yield* HttpClientRequest.make(method)(url).pipe(
- HttpClientRequest.setHeaders(headers),
+ HttpClientRequest.setHeaders(Object.fromEntries(headers.entries())),
input.body === undefined
? identity
: HttpClientRequest.bodyText(input.body, headers.get("content-type") ?? undefined),
diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts
new file mode 100644
index 00000000000..c779c554ffd
--- /dev/null
+++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts
@@ -0,0 +1,37 @@
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+
+import * as DesktopConnectionCatalogStore from "../../app/DesktopConnectionCatalogStore.ts";
+import * as IpcChannels from "../channels.ts";
+import { makeIpcMethod } from "../DesktopIpc.ts";
+
+export const getConnectionCatalog = makeIpcMethod({
+ channel: IpcChannels.GET_CONNECTION_CATALOG_CHANNEL,
+ payload: Schema.Void,
+ result: Schema.NullOr(Schema.String),
+ handler: Effect.fn("desktop.ipc.connectionCatalog.get")(function* () {
+ const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
+ return Option.getOrNull(yield* store.get);
+ }),
+});
+
+export const setConnectionCatalog = makeIpcMethod({
+ channel: IpcChannels.SET_CONNECTION_CATALOG_CHANNEL,
+ payload: Schema.String,
+ result: Schema.Boolean,
+ handler: Effect.fn("desktop.ipc.connectionCatalog.set")(function* (catalog) {
+ const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
+ return yield* store.set(catalog);
+ }),
+});
+
+export const clearConnectionCatalog = makeIpcMethod({
+ channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL,
+ payload: Schema.Void,
+ result: Schema.Void,
+ handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () {
+ const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
+ yield* store.clear;
+ }),
+});
diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts
index 6eeaa3202d9..2f46b263b0f 100644
--- a/apps/desktop/src/ipc/methods/sshEnvironment.ts
+++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts
@@ -1,11 +1,11 @@
import {
bootstrapRemoteBearerSession,
- fetchRemoteEnvironmentDescriptor,
fetchRemoteSessionState,
issueRemoteWebSocketTicket,
RemoteEnvironmentAuthUndeclaredStatusError,
type RemoteEnvironmentAuthError,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/authorization";
+import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
import {
EnvironmentAuthInvalidError,
DesktopDiscoveredSshHostSchema,
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 9356eef441b..74ffcba1a16 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -28,6 +28,7 @@ import * as DesktopApp from "./app/DesktopApp.ts";
import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts";
import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts";
+import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
import * as DesktopAssets from "./app/DesktopAssets.ts";
import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
@@ -114,6 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll(
DesktopClientSettings.layer,
DesktopSavedEnvironments.layer,
DesktopCloudAuthTokenStore.layer,
+ DesktopConnectionCatalogStore.layer,
DesktopAssets.layer,
DesktopObservability.layer,
).pipe(Layer.provideMerge(desktopEnvironmentLayer));
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index 84f7580cb07..760575b75fa 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -47,6 +47,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }),
removeSavedEnvironmentSecret: (environmentId) =>
ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
+ getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL),
+ setConnectionCatalog: (catalog) =>
+ ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog),
+ clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL),
discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL),
ensureSshEnvironment: async (target, options) =>
unwrapEnsureSshEnvironmentResult(
diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts
index d1d37b96e11..69aeaded690 100644
--- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts
+++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts
@@ -9,7 +9,7 @@ import * as Schema from "effect/Schema";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
-import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
+import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts";
const textDecoder = new TextDecoder();
diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts
index 531b50ba73b..10938134044 100644
--- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts
+++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts
@@ -14,7 +14,7 @@ import * as Schema from "effect/Schema";
import * as Ref from "effect/Ref";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
-import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
+import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
type PersistedSavedEnvironmentDesktopSsh = NonNullable<
PersistedSavedEnvironmentRecord["desktopSsh"]
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 4b08a338e12..36826fc7366 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -74,6 +74,7 @@
"expo-haptics": "~56.0.3",
"expo-image-picker": "~56.0.14",
"expo-linking": "~56.0.12",
+ "expo-network": "~56.0.5",
"expo-notifications": "~56.0.14",
"expo-paste-input": "^0.1.15",
"expo-router": "~56.2.7",
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 136e141fdcf..de7d9c0432c 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -14,17 +14,14 @@ import { useResolveClassNames } from "uniwind";
import { LoadingScreen } from "../components/LoadingScreen";
-import {
- useRemoteEnvironmentBootstrap,
- useRemoteEnvironmentState,
-} from "../state/use-remote-environment-registry";
+import { useWorkspaceState } from "../state/workspace";
import { RegistryContext } from "@effect/atom-react";
import { appAtomRegistry } from "../state/atom-registry";
import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";
function AppNavigator() {
- const { isLoadingSavedConnection } = useRemoteEnvironmentState();
+ const { state } = useWorkspaceState();
const colorScheme = useColorScheme();
const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7";
const sheetStyle = useResolveClassNames("bg-sheet");
@@ -53,7 +50,7 @@ function AppNavigator() {
sheetAllowedDetents: [0.7],
};
- if (isLoadingSavedConnection) {
+ if (state.isLoadingConnections) {
return ;
}
@@ -97,8 +94,6 @@ export default function RootLayout() {
DMSans_500Medium,
DMSans_700Bold,
});
- useRemoteEnvironmentBootstrap();
-
return (
diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx
index f7a5fd37ac7..f34130c9e90 100644
--- a/apps/mobile/src/app/index.tsx
+++ b/apps/mobile/src/app/index.tsx
@@ -2,16 +2,19 @@ import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Text as RNText, View, useColorScheme } from "react-native";
+import { useProjects, useThreadShells } from "../state/entities";
+import { useWorkspaceState } from "../state/workspace";
import { buildThreadRoutePath } from "../lib/routes";
-import { useRemoteCatalog } from "../state/use-remote-catalog";
-import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry";
+import { useSavedRemoteConnections } from "../state/use-remote-environment-registry";
import { HomeScreen } from "../features/home/HomeScreen";
/* ─── Route screen ───────────────────────────────────────────────────── */
export default function HomeRouteScreen() {
- const { projects, state: catalogState, threads } = useRemoteCatalog();
- const { savedConnectionsById } = useRemoteEnvironmentState();
+ const projects = useProjects();
+ const threads = useThreadShells();
+ const { state: catalogState } = useWorkspaceState();
+ const { savedConnectionsById } = useSavedRemoteConnections();
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
@@ -30,9 +33,13 @@ export default function HomeRouteScreen() {
headerTitle: "",
headerSearchBarOptions: {
placeholder: "Search threads",
+ hideNavigationBar: false,
onChangeText: (event) => {
setSearchQuery(event.nativeEvent.text);
},
+ onCancelButtonPress: () => {
+ setSearchQuery("");
+ },
allowToolbarIntegration: true,
},
}}
@@ -102,6 +109,7 @@ export default function HomeRouteScreen() {
savedConnectionsById={savedConnectionsById}
searchQuery={searchQuery}
onAddConnection={() => router.push("/connections/new")}
+ onOpenEnvironments={() => router.push("/settings/environments")}
onSelectThread={(thread) => {
router.push(buildThreadRoutePath(thread));
}}
diff --git a/apps/mobile/src/app/new/add-project/repository.tsx b/apps/mobile/src/app/new/add-project/repository.tsx
index 2861dded1ad..7bf23a4955a 100644
--- a/apps/mobile/src/app/new/add-project/repository.tsx
+++ b/apps/mobile/src/app/new/add-project/repository.tsx
@@ -1,5 +1,5 @@
import { Stack, useLocalSearchParams } from "expo-router";
-import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime";
+import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";
diff --git a/apps/mobile/src/app/new/index.tsx b/apps/mobile/src/app/new/index.tsx
index 76102d842f4..2e632620370 100644
--- a/apps/mobile/src/app/new/index.tsx
+++ b/apps/mobile/src/app/new/index.tsx
@@ -8,16 +8,18 @@ import { useThemeColor } from "../../lib/useThemeColor";
import { AppText as Text } from "../../components/AppText";
import { ProjectFavicon } from "../../components/ProjectFavicon";
+import { useProjects, useThreadShells } from "../../state/entities";
+import type { WorkspaceState } from "../../state/workspaceModel";
+import { useWorkspaceState } from "../../state/workspace";
import { groupProjectsByRepository } from "../../lib/repositoryGroups";
-import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog";
-import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
+import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
-function deriveProjectEmptyState(catalogState: RemoteCatalogState): {
+function deriveProjectEmptyState(catalogState: WorkspaceState): {
readonly title: string;
readonly detail: string;
readonly loading: boolean;
} {
- if (catalogState.isLoadingSavedConnections) {
+ if (catalogState.isLoadingConnections) {
return {
title: "Loading environments",
detail: "Checking saved environments on this device.",
@@ -25,7 +27,7 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): {
};
}
- if (!catalogState.hasSavedConnections) {
+ if (!catalogState.hasConnections) {
return {
title: "No environments connected",
detail: "Add an environment before creating a task.",
@@ -33,7 +35,12 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): {
};
}
- if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) {
+ if (
+ (catalogState.connectionState === "available" ||
+ catalogState.connectionState === "offline" ||
+ catalogState.connectionState === "error") &&
+ !catalogState.hasLoadedShellSnapshot
+ ) {
return {
title: "Environment unavailable",
detail:
@@ -63,8 +70,10 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): {
}
export default function NewTaskRoute() {
- const { projects, state: catalogState, threads } = useRemoteCatalog();
- const { savedConnectionsById } = useRemoteEnvironmentState();
+ const projects = useProjects();
+ const threads = useThreadShells();
+ const { state: catalogState } = useWorkspaceState();
+ const { savedConnectionsById } = useSavedRemoteConnections();
const router = useRouter();
const insets = useSafeAreaInsets();
const chevronColor = useThemeColor("--color-chevron");
diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx
index 8a40720089b..ffe963cc5a7 100644
--- a/apps/mobile/src/app/settings/environments.tsx
+++ b/apps/mobile/src/app/settings/environments.tsx
@@ -1,32 +1,38 @@
import { useAuth } from "@clerk/expo";
import { Stack, useRouter } from "expo-router";
import { SymbolView } from "expo-symbols";
+import {
+ connectionStatusText,
+ type EnvironmentConnectionPhase,
+} from "@t3tools/client-runtime/connection";
import type { EnvironmentId } from "@t3tools/contracts";
-import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
-import * as Effect from "effect/Effect";
-import { useCallback, useMemo, useState } from "react";
-import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
+import { useCallback, useState } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ Switch,
+ type NativeSyntheticEvent,
+ type TextLayoutEventData,
+ View,
+} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
-import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment";
import {
- hasCloudPublicConfig,
- resolveRelayClerkTokenOptions,
-} from "../../features/cloud/publicConfig";
-import {
- useManagedRelayEnvironments,
- useManagedRelayEnvironmentStatus,
-} from "../../features/cloud/managedRelayState";
+ type RelayEnvironmentView,
+ useConnectionController,
+} from "../../features/connection/useConnectionController";
+import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
+import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation";
import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
+import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot";
+import { splitEnvironmentSections } from "../../features/connection/environmentSections";
import { cn } from "../../lib/cn";
-import { mobileRuntime } from "../../lib/runtime";
+import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { useThemeColor } from "../../lib/useThemeColor";
-import {
- connectSavedEnvironment,
- useRemoteConnections,
- useRemoteEnvironmentState,
-} from "../../state/use-remote-environment-registry";
+import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
+import { useRemoteConnections } from "../../state/use-remote-environment-registry";
export default function SettingsEnvironmentsRouteScreen() {
const {
@@ -37,7 +43,11 @@ export default function SettingsEnvironmentsRouteScreen() {
} = useRemoteConnections();
const router = useRouter();
const insets = useSafeAreaInsets();
- const hasEnvironments = connectedEnvironments.length > 0;
+ const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({
+ connectedEnvironments,
+ cloudEnvironments: null,
+ });
+ const hasLocalEnvironments = localEnvironments.length > 0;
const [expandedId, setExpandedId] = useState(null);
const accentColor = useThemeColor("--color-icon-muted");
@@ -69,9 +79,9 @@ export default function SettingsEnvironmentsRouteScreen() {
paddingTop: 16,
}}
>
- {hasEnvironments ? (
+ {hasLocalEnvironments ? (
- {connectedEnvironments.map((environment, index) => (
+ {localEnvironments.map((environment, index) => (
)}
- {hasCloudPublicConfig() ? : null}
+ {hasCloudPublicConfig() ? (
+
+ ) : null}
);
}
-function ConfiguredCloudEnvironmentRows() {
- const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
- const { savedConnectionsById } = useRemoteEnvironmentState();
- const cloudEnvironmentsState = useManagedRelayEnvironments();
- const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState(
- null,
- );
+function ConfiguredCloudEnvironmentRows(props: {
+ readonly connectedCloudEnvironments: ReadonlyArray;
+ readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
+}) {
+ const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
+ const controller = useConnectionController();
const iconColor = useThemeColor("--color-icon");
- const availableCloudEnvironments = useMemo(
- () =>
- (cloudEnvironmentsState.data ?? []).filter(
- (environment) => savedConnectionsById[environment.environmentId] === undefined,
- ),
- [cloudEnvironmentsState.data, savedConnectionsById],
- );
+ const availableCloudEnvironments = controller.availableRelayEnvironments;
+ const [expandedErrorId, setExpandedErrorId] = useState(null);
+ const hasCloudRows =
+ props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0;
const handleConnectCloudEnvironment = useCallback(
- async (environment: RelayClientEnvironmentRecord) => {
- setConnectingCloudEnvironmentId(environment.environmentId);
- try {
- const token = await getToken(resolveRelayClerkTokenOptions());
- if (!token) {
- throw new Error("Sign in to T3 Cloud before connecting.");
- }
- await mobileRuntime.runPromise(
- connectCloudEnvironment({
- clerkToken: token,
- environment,
- }).pipe(Effect.flatMap(connectSavedEnvironment)),
- );
- } catch (error) {
- Alert.alert(
- "Connect failed",
- error instanceof Error ? error.message : "Could not connect to this environment.",
- );
- } finally {
- setConnectingCloudEnvironmentId(null);
- }
+ (entry: RelayEnvironmentView) => {
+ void controller.connectRelayEnvironment(entry.environment);
},
- [getToken],
+ [controller],
);
+ const handleDisconnectCloudEnvironment = useCallback(
+ (environmentId: EnvironmentId) => {
+ void controller.removeEnvironment(environmentId);
+ },
+ [controller],
+ );
+
+ const handleToggleCloudError = useCallback((environmentId: string) => {
+ setExpandedErrorId((current) => (current === environmentId ? null : environmentId));
+ }, []);
+
if (!isSignedIn) return null;
return (
@@ -164,11 +167,13 @@ function ConfiguredCloudEnvironmentRows() {
T3 Cloud
{
+ void controller.refreshRelayEnvironments();
+ }}
className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
>
- {cloudEnvironmentsState.isPending ? (
+ {controller.relayDiscovery.isRefreshing ? (
) : (
@@ -176,33 +181,48 @@ function ConfiguredCloudEnvironmentRows() {
- {availableCloudEnvironments.length > 0 ? (
+ {hasCloudRows ? (
- {availableCloudEnvironments.map((environment, index) => (
- (
+ props.onReconnectEnvironment(environment.environmentId)}
+ onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}
+ errorExpanded={expandedErrorId === environment.environmentId}
+ onToggleError={() => handleToggleCloudError(environment.environmentId)}
+ />
+ ))}
+ {availableCloudEnvironments.map((environment, index) => (
+ 0 || index !== 0}
onConnect={() => handleConnectCloudEnvironment(environment)}
+ errorExpanded={expandedErrorId === environment.environment.environmentId}
+ onToggleError={() => handleToggleCloudError(environment.environment.environmentId)}
/>
))}
- ) : cloudEnvironmentsState.data === null ? (
+ ) : controller.relayDiscovery.isRefreshing ? (
Loading linked cloud environments.
- ) : cloudEnvironmentsState.error ? (
+ ) : controller.relayDiscovery.error ? (
Could not load T3 Cloud environments
- {cloudEnvironmentsState.error}
+ {controller.relayDiscovery.error}
+ {controller.relayDiscovery.errorTraceId ? (
+
+ ) : null}
) : (
@@ -215,23 +235,124 @@ function ConfiguredCloudEnvironmentRows() {
);
}
+function ConnectedCloudEnvironmentRow(props: {
+ readonly environment: ConnectedEnvironmentSummary;
+ readonly borderTop: boolean;
+ readonly errorExpanded: boolean;
+ readonly onConnect: () => void;
+ readonly onDisconnect: () => void;
+ readonly onToggleError: () => void;
+}) {
+ return (
+ {
+ if (enabled) {
+ props.onConnect();
+ return;
+ }
+ props.onDisconnect();
+ }}
+ onToggleError={props.onToggleError}
+ value={props.environment.connectionState !== "available"}
+ />
+ );
+}
+
function CloudEnvironmentRow(props: {
- readonly environment: RelayClientEnvironmentRecord;
+ readonly environment: RelayEnvironmentView;
readonly borderTop: boolean;
- readonly isConnecting: boolean;
+ readonly errorExpanded: boolean;
readonly onConnect: () => void;
+ readonly onToggleError: () => void;
}) {
- const mutedColor = useThemeColor("--color-icon-muted");
- const statusState = useManagedRelayEnvironmentStatus(props.environment);
- const status = statusState.data;
- const disabled = props.isConnecting;
- const statusText =
- status === null
- ? (statusState.error ?? (statusState.isPending ? "Checking status..." : "Status unavailable"))
- : status.status === "online"
- ? "Online"
- : (status.error ?? "Offline");
+ const presentation = availableCloudEnvironmentPresentation({
+ isStatusPending: props.environment.availability === "checking",
+ status: props.environment.status,
+ statusError: props.environment.error,
+ statusErrorTraceId: props.environment.traceId,
+ });
+ return (
+ {
+ if (enabled) {
+ props.onConnect();
+ }
+ }}
+ onToggleError={props.onToggleError}
+ statusText={presentation.statusText}
+ value={false}
+ />
+ );
+}
+
+function CloudEnvironmentRowShell(props: {
+ readonly borderTop: boolean;
+ readonly connectionError: string | null;
+ readonly connectionErrorTraceId: string | null;
+ readonly connectionState: EnvironmentConnectionPhase;
+ readonly disabled?: boolean;
+ readonly errorExpanded: boolean;
+ readonly label: string;
+ readonly onToggleError: () => void;
+ readonly onValueChange: (enabled: boolean) => void;
+ readonly statusText?: string;
+ readonly value: boolean;
+}) {
+ const activeTrack = String(useThemeColor("--color-switch-active"));
+ const track = String(useThemeColor("--color-secondary-border"));
+ const chevron = useThemeColor("--color-chevron");
+ const isRetrying =
+ props.connectionState === "connecting" || props.connectionState === "reconnecting";
+ const shouldPulse = isRetrying;
+ const statusText =
+ props.statusText ??
+ connectionStatusText({
+ phase: props.connectionState,
+ error: props.connectionError,
+ traceId: props.connectionErrorTraceId,
+ });
+ const statusClassName = props.connectionError
+ ? "text-rose-500 dark:text-rose-400"
+ : "text-foreground-muted";
+ const [errorMeasurement, setErrorMeasurement] = useState<{
+ readonly text: string;
+ readonly lineCount: number;
+ } | null>(null);
+ const errorTraceId = props.connectionErrorTraceId;
+ const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText;
+ const errorLineCount =
+ errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0;
+ const errorCanExpand = props.connectionError !== null && errorLineCount > 1;
+ const isErrorExpanded = errorCanExpand && props.errorExpanded;
+ const StatusContainer = errorCanExpand ? Pressable : View;
+ const onMeasuredErrorTextLayout = useCallback(
+ (event: NativeSyntheticEvent) => {
+ if (!props.connectionError) {
+ return;
+ }
+ const nextLineCount = event.nativeEvent.lines.length;
+ setErrorMeasurement((currentMeasurement) =>
+ currentMeasurement?.text === measuredErrorText &&
+ currentMeasurement.lineCount === nextLineCount
+ ? currentMeasurement
+ : { text: measuredErrorText, lineCount: nextLineCount },
+ );
+ },
+ [measuredErrorText, props.connectionError],
+ );
return (
-
-
-
-
- {props.environment.label}
-
-
- {props.environment.endpoint.httpBaseUrl}
-
-
- {statusText}
-
+
+
+
+ {props.label}
+
+
+ {props.connectionError ? (
+
+ {measuredErrorText}
+
+ ) : null}
+
+
+ {statusText}
+ {errorTraceId ? (
+ <>
+ {" Trace ID: "}
+ {
+ event.stopPropagation();
+ copyTextWithHaptic(errorTraceId);
+ }}
+ onPress={(event) => {
+ event.stopPropagation();
+ }}
+ style={{ textDecorationStyle: "dotted" }}
+ >
+ {errorTraceId}
+
+ >
+ ) : null}
+
+ {errorCanExpand ? (
+
+ ) : null}
+
-
-
- {props.isConnecting ? "Connecting" : "Connect"}
-
-
+
);
}
+
+function CopyTraceIdButton(props: { readonly traceId: string }) {
+ const iconColor = useThemeColor("--color-icon");
+
+ return (
+ {
+ copyTextWithHaptic(props.traceId);
+ }}
+ className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70"
+ >
+
+ Copy trace ID
+
+ );
+}
diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx
index b25de626867..595067ccae5 100644
--- a/apps/mobile/src/app/settings/index.tsx
+++ b/apps/mobile/src/app/settings/index.tsx
@@ -17,10 +17,10 @@ import {
hasCloudPublicConfig,
resolveRelayClerkTokenOptions,
} from "../../features/cloud/publicConfig";
-import { mobileRuntime } from "../../lib/runtime";
+import { runtime } from "../../lib/runtime";
import { loadPreferences } from "../../lib/storage";
import { useThemeColor } from "../../lib/useThemeColor";
-import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
+import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
@@ -31,7 +31,7 @@ export default function SettingsRouteScreen() {
function LocalSettingsRouteScreen() {
const insets = useSafeAreaInsets();
- const { savedConnectionsById } = useRemoteEnvironmentState();
+ const { savedConnectionsById } = useSavedRemoteConnections();
const environmentCount = Object.keys(savedConnectionsById).length;
return (
@@ -69,7 +69,7 @@ function ConfiguredSettingsRouteScreen() {
const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
const { user } = useUser();
const { isAvailable: isUserProfileModalAvailable, presentUserProfile } = useUserProfileModal();
- const { savedConnectionsById } = useRemoteEnvironmentState();
+ const { savedConnectionsById } = useSavedRemoteConnections();
const [notificationStatus, setNotificationStatus] = useState("checking");
const [liveActivityStatus, setLiveActivityStatus] = useState("checking");
@@ -115,7 +115,7 @@ function ConfiguredSettingsRouteScreen() {
const requestNotifications = useCallback(async () => {
try {
- const result = await mobileRuntime.runPromise(
+ const result = await runtime.runPromise(
requestAgentNotificationPermission.pipe(
Effect.tap((permission) =>
permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void,
@@ -185,7 +185,7 @@ function ConfiguredSettingsRouteScreen() {
return;
}
- await mobileRuntime.runPromise(
+ await runtime.runPromise(
setLiveActivityUpdatesEnabled({
enabled: true,
clerkToken: token,
@@ -235,7 +235,7 @@ function ConfiguredSettingsRouteScreen() {
void (async () => {
try {
const token = isSignedIn ? await getToken(resolveRelayClerkTokenOptions()) : null;
- await mobileRuntime.runPromise(
+ await runtime.runPromise(
setLiveActivityUpdatesEnabled({
enabled: false,
clerkToken: token,
@@ -387,15 +387,20 @@ function SettingsRow(props: {
style={{ opacity: props.disabled ? 0.45 : 1 }}
>
- {props.label}
- {props.value ? (
-
- {props.value}
-
- ) : null}
+
+ {props.label}
+
+
+ {props.value ? (
+
+ {props.value}
+
+ ) : null}
+
{
+ const signedIn = await presentAuth();
+ if (signedIn) {
+ router.replace("/settings");
+ }
+ }, [presentAuth, router]);
return (
<>
@@ -31,7 +40,7 @@ function ConfiguredSettingsWaitlistRouteScreen() {
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
- void presentAuth()} />
+ void handleSignIn()} />
>
);
diff --git a/apps/mobile/src/connection/catalog.ts b/apps/mobile/src/connection/catalog.ts
new file mode 100644
index 00000000000..971fa891106
--- /dev/null
+++ b/apps/mobile/src/connection/catalog.ts
@@ -0,0 +1,5 @@
+import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections";
+
+import { connectionAtomRuntime } from "./runtime";
+
+export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/connection/migration.test.ts b/apps/mobile/src/connection/migration.test.ts
new file mode 100644
index 00000000000..5cb17bd5bf7
--- /dev/null
+++ b/apps/mobile/src/connection/migration.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "@effect/vitest";
+import { EnvironmentId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+
+import { migrateLegacyConnectionCatalog } from "./migration";
+
+describe("migrateLegacyConnectionCatalog", () => {
+ it.effect("migrates bearer and relay-managed connections into the new catalog", () =>
+ Effect.gen(function* () {
+ const bearerEnvironmentId = EnvironmentId.make("bearer-environment");
+ const relayEnvironmentId = EnvironmentId.make("relay-environment");
+ const catalog = yield* migrateLegacyConnectionCatalog(
+ JSON.stringify({
+ connections: [
+ {
+ environmentId: bearerEnvironmentId,
+ environmentLabel: "Local Mac",
+ pairingUrl: "https://local.example.test/pair",
+ displayUrl: "https://local.example.test",
+ httpBaseUrl: "https://local.example.test",
+ wsBaseUrl: "wss://local.example.test",
+ bearerToken: "bearer-token",
+ authenticationMethod: "bearer",
+ },
+ {
+ environmentId: relayEnvironmentId,
+ environmentLabel: "Cloud Mac",
+ pairingUrl: "https://relay.example.test",
+ displayUrl: "https://relay.example.test",
+ httpBaseUrl: "https://relay.example.test",
+ wsBaseUrl: "wss://relay.example.test",
+ bearerToken: null,
+ authenticationMethod: "dpop",
+ relayManaged: true,
+ },
+ ],
+ }),
+ );
+
+ expect(catalog.targets).toHaveLength(2);
+ expect(
+ catalog.targets.find((target) => target.environmentId === bearerEnvironmentId)?._tag,
+ ).toBe("BearerConnectionTarget");
+ expect(
+ catalog.targets.find((target) => target.environmentId === relayEnvironmentId)?._tag,
+ ).toBe("RelayConnectionTarget");
+ expect(catalog.profiles).toHaveLength(1);
+ expect(catalog.credentials).toHaveLength(1);
+ expect(catalog.credentials[0]?.credential).toMatchObject({
+ _tag: "BearerConnectionCredential",
+ token: "bearer-token",
+ });
+ }),
+ );
+
+ it.effect("drops invalid legacy bearer entries without credentials", () =>
+ Effect.gen(function* () {
+ const catalog = yield* migrateLegacyConnectionCatalog(
+ JSON.stringify({
+ connections: [
+ {
+ environmentId: EnvironmentId.make("invalid-bearer"),
+ environmentLabel: "Invalid",
+ pairingUrl: "https://invalid.example.test/pair",
+ displayUrl: "https://invalid.example.test",
+ httpBaseUrl: "https://invalid.example.test",
+ wsBaseUrl: "wss://invalid.example.test",
+ bearerToken: null,
+ authenticationMethod: "bearer",
+ },
+ ],
+ }),
+ );
+
+ expect(catalog.targets).toEqual([]);
+ }),
+ );
+});
diff --git a/apps/mobile/src/connection/migration.ts b/apps/mobile/src/connection/migration.ts
new file mode 100644
index 00000000000..6f324c9ff15
--- /dev/null
+++ b/apps/mobile/src/connection/migration.ts
@@ -0,0 +1,110 @@
+import {
+ BearerConnectionCredential,
+ BearerConnectionProfile,
+ BearerConnectionRegistration,
+ RelayConnectionRegistration,
+ RelayConnectionTarget,
+ BearerConnectionTarget,
+} from "@t3tools/client-runtime/connection";
+import {
+ type ConnectionCatalogDocument,
+ EMPTY_CONNECTION_CATALOG_DOCUMENT,
+ registerConnectionInCatalog,
+} from "@t3tools/client-runtime/platform";
+import { EnvironmentId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+
+const LegacySavedRemoteConnection = Schema.Struct({
+ environmentId: EnvironmentId,
+ environmentLabel: Schema.String,
+ pairingUrl: Schema.String,
+ displayUrl: Schema.String,
+ httpBaseUrl: Schema.String,
+ wsBaseUrl: Schema.String,
+ bearerToken: Schema.NullOr(Schema.String),
+ authenticationMethod: Schema.optionalKey(Schema.Literals(["bearer", "dpop"])),
+ dpopAccessToken: Schema.optionalKey(Schema.String),
+ relayManaged: Schema.optionalKey(Schema.Literal(true)),
+});
+
+const LegacyConnectionDocument = Schema.Struct({
+ connections: Schema.optionalKey(Schema.Array(LegacySavedRemoteConnection)),
+});
+const decodeLegacyConnectionDocument = Schema.decodeUnknownEffect(LegacyConnectionDocument);
+
+export class LegacyConnectionMigrationError extends Schema.TaggedErrorClass()(
+ "LegacyConnectionMigrationError",
+ {
+ message: Schema.String,
+ },
+) {}
+
+function isRelayManaged(connection: typeof LegacySavedRemoteConnection.Type): boolean {
+ return connection.relayManaged === true || connection.authenticationMethod === "dpop";
+}
+
+function migrateConnection(
+ document: ConnectionCatalogDocument,
+ connection: typeof LegacySavedRemoteConnection.Type,
+): ConnectionCatalogDocument {
+ if (isRelayManaged(connection)) {
+ return registerConnectionInCatalog(
+ document,
+ new RelayConnectionRegistration({
+ target: new RelayConnectionTarget({
+ environmentId: connection.environmentId,
+ label: connection.environmentLabel,
+ }),
+ }),
+ );
+ }
+
+ if (connection.bearerToken === null || connection.bearerToken.trim() === "") {
+ return document;
+ }
+
+ const connectionId = `bearer:${connection.environmentId}`;
+ return registerConnectionInCatalog(
+ document,
+ new BearerConnectionRegistration({
+ target: new BearerConnectionTarget({
+ environmentId: connection.environmentId,
+ label: connection.environmentLabel,
+ connectionId,
+ }),
+ profile: new BearerConnectionProfile({
+ connectionId,
+ environmentId: connection.environmentId,
+ label: connection.environmentLabel,
+ httpBaseUrl: connection.httpBaseUrl,
+ wsBaseUrl: connection.wsBaseUrl,
+ }),
+ credential: new BearerConnectionCredential({
+ token: connection.bearerToken,
+ }),
+ }),
+ );
+}
+
+export const migrateLegacyConnectionCatalog = Effect.fn(
+ "mobile.connectionMigration.migrateCatalog",
+)(function* (raw: string) {
+ const parsed = yield* Effect.try({
+ try: () => JSON.parse(raw) as unknown,
+ catch: (cause) =>
+ new LegacyConnectionMigrationError({
+ message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`,
+ }),
+ });
+ const legacy = yield* decodeLegacyConnectionDocument(parsed).pipe(
+ Effect.mapError(
+ (cause) =>
+ new LegacyConnectionMigrationError({
+ message: `Could not decode the legacy mobile connection catalog: ${String(cause)}`,
+ }),
+ ),
+ );
+
+ return (legacy.connections ?? []).reduce(migrateConnection, EMPTY_CONNECTION_CATALOG_DOCUMENT);
+});
diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts
new file mode 100644
index 00000000000..a6b164c0208
--- /dev/null
+++ b/apps/mobile/src/connection/onboarding.ts
@@ -0,0 +1,13 @@
+import { ConnectionOnboarding } from "@t3tools/client-runtime/connection";
+import * as Effect from "effect/Effect";
+import { Atom } from "effect/unstable/reactivity";
+
+import { connectionAtomRuntime } from "./runtime";
+
+export const connectPairingUrl = connectionAtomRuntime
+ .fn()((pairingUrl) =>
+ ConnectionOnboarding.pipe(
+ Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })),
+ ),
+ )
+ .pipe(Atom.withLabel("mobile:connection:connect-pairing-url"));
diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts
new file mode 100644
index 00000000000..b9e3709894e
--- /dev/null
+++ b/apps/mobile/src/connection/platform.ts
@@ -0,0 +1,207 @@
+import {
+ ClientPresentation,
+ CloudSession,
+ EnvironmentOwnedDataCleanup,
+ PlatformConnectionSource,
+ RelayDeviceIdentity,
+ SshEnvironmentGateway,
+} from "@t3tools/client-runtime/platform";
+import {
+ ConnectionBlockedError,
+ ConnectionTransientError,
+ ConnectionWakeups,
+ Connectivity,
+} from "@t3tools/client-runtime/connection";
+import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
+import { AuthStandardClientScopes } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Queue from "effect/Queue";
+import * as Stream from "effect/Stream";
+import * as Network from "expo-network";
+import { AppState } from "react-native";
+
+import { authClientMetadata } from "../lib/authClientMetadata";
+import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage";
+import { appAtomRegistry } from "../state/atom-registry";
+import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
+import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
+import { connectionStorageLayer } from "./storage";
+
+function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" {
+ if (state.isConnected === false || state.isInternetReachable === false) {
+ return "offline";
+ }
+ if (state.isConnected === true) {
+ return "online";
+ }
+ return "unknown";
+}
+
+const connectivityLayer = Layer.succeed(
+ Connectivity,
+ Connectivity.of({
+ status: Effect.tryPromise({
+ try: () => Network.getNetworkStateAsync(),
+ catch: () => undefined,
+ }).pipe(
+ Effect.match({
+ onFailure: () => "unknown" as const,
+ onSuccess: networkStatus,
+ }),
+ ),
+ changes: Stream.callback((queue) =>
+ Effect.acquireRelease(
+ Effect.sync(() =>
+ Network.addNetworkStateListener((state) => {
+ Queue.offerUnsafe(queue, networkStatus(state));
+ }),
+ ),
+ (subscription) => Effect.sync(() => subscription.remove()),
+ ).pipe(Effect.asVoid),
+ ),
+ }),
+);
+
+const wakeupsLayer = Layer.succeed(
+ ConnectionWakeups,
+ ConnectionWakeups.of({
+ changes: Stream.merge(
+ Stream.callback<"application-active">((queue) =>
+ Effect.acquireRelease(
+ Effect.sync(() =>
+ AppState.addEventListener("change", (state) => {
+ if (state === "active") {
+ Queue.offerUnsafe(queue, "application-active");
+ }
+ }),
+ ),
+ (subscription) => Effect.sync(() => subscription.remove()),
+ ).pipe(Effect.asVoid),
+ ),
+ Stream.callback<"credentials-changed">((queue) =>
+ Effect.acquireRelease(
+ Effect.sync(() =>
+ appAtomRegistry.subscribe(managedRelaySessionAtom, () => {
+ Queue.offerUnsafe(queue, "credentials-changed");
+ }),
+ ),
+ (unsubscribe) => Effect.sync(unsubscribe),
+ ).pipe(Effect.asVoid),
+ ),
+ ),
+ }),
+);
+
+const capabilitiesLayer = Layer.succeedContext(
+ Context.make(
+ CloudSession,
+ CloudSession.of({
+ clerkToken: Effect.gen(function* () {
+ const session = appAtomRegistry.get(managedRelaySessionAtom);
+ if (session === null) {
+ return yield* new ConnectionBlockedError({
+ reason: "authentication",
+ message: "Sign in to T3 Cloud to connect this environment.",
+ });
+ }
+ const token = yield* session.readClerkToken().pipe(
+ Effect.mapError(
+ (error) =>
+ new ConnectionTransientError({
+ reason: "network",
+ message: error.message,
+ }),
+ ),
+ );
+ if (token === null) {
+ return yield* new ConnectionBlockedError({
+ reason: "authentication",
+ message: "The T3 Cloud session is unavailable.",
+ });
+ }
+ return token;
+ }),
+ }),
+ ).pipe(
+ Context.add(
+ RelayDeviceIdentity,
+ RelayDeviceIdentity.of({
+ deviceId: Effect.tryPromise({
+ try: () => loadOrCreateAgentAwarenessDeviceId(),
+ catch: (cause) =>
+ new ConnectionTransientError({
+ reason: "remote-unavailable",
+ message: `Could not load the mobile device identity: ${String(cause)}`,
+ }),
+ }).pipe(Effect.map(Option.some)),
+ }),
+ ),
+ Context.add(
+ ClientPresentation,
+ ClientPresentation.of({
+ metadata: authClientMetadata(),
+ scopes: AuthStandardClientScopes,
+ }),
+ ),
+ Context.add(
+ SshEnvironmentGateway,
+ SshEnvironmentGateway.of({
+ provision: () =>
+ Effect.fail(
+ new ConnectionBlockedError({
+ reason: "unsupported",
+ message: "SSH environments are only available in the desktop app.",
+ }),
+ ),
+ prepare: () =>
+ Effect.fail(
+ new ConnectionBlockedError({
+ reason: "unsupported",
+ message: "SSH environments are only available in the desktop app.",
+ }),
+ ),
+ disconnect: () => Effect.void,
+ }),
+ ),
+ ),
+);
+
+const platformConnectionSourceLayer = Layer.succeed(
+ PlatformConnectionSource,
+ PlatformConnectionSource.of({
+ registrations: Stream.empty,
+ }),
+);
+
+const environmentOwnedDataCleanupLayer = Layer.succeed(
+ EnvironmentOwnedDataCleanup,
+ EnvironmentOwnedDataCleanup.of({
+ clear: (environmentId) =>
+ Effect.all(
+ [
+ Effect.promise(() => clearThreadOutboxEnvironment(environmentId)),
+ Effect.promise(() => clearComposerDraftsEnvironment(environmentId)),
+ ],
+ { concurrency: "unbounded", discard: true },
+ ).pipe(
+ Effect.catch((cause) =>
+ Effect.logWarning("Could not clear mobile environment-owned data.", {
+ environmentId,
+ cause,
+ }),
+ ),
+ ),
+ }),
+);
+
+export const connectionPlatformLayer = Layer.mergeAll(
+ connectionStorageLayer,
+ connectivityLayer,
+ wakeupsLayer,
+ capabilitiesLayer,
+ platformConnectionSourceLayer,
+ environmentOwnedDataCleanupLayer,
+);
diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts
new file mode 100644
index 00000000000..862022ae4f7
--- /dev/null
+++ b/apps/mobile/src/connection/runtime.ts
@@ -0,0 +1,14 @@
+import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection";
+import * as Layer from "effect/Layer";
+import { Atom } from "effect/unstable/reactivity";
+
+import { runtimeLayer } from "../lib/runtime";
+import { connectionPlatformLayer } from "./platform";
+
+const connectionDependencies = Layer.mergeAll(runtimeLayer, connectionPlatformLayer);
+
+export const connectionLayer = clientConnectionLayer.pipe(
+ Layer.provideMerge(connectionDependencies),
+);
+
+export const connectionAtomRuntime = Atom.runtime(connectionLayer);
diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts
new file mode 100644
index 00000000000..03a0066fadd
--- /dev/null
+++ b/apps/mobile/src/connection/storage.ts
@@ -0,0 +1,506 @@
+import {
+ ConnectionCatalogDocument,
+ type ConnectionCatalogDocument as ConnectionCatalogDocumentType,
+ ConnectionPersistenceError,
+ ConnectionRegistrationStore,
+ ConnectionTargetStore,
+ EMPTY_CONNECTION_CATALOG_DOCUMENT,
+ EnvironmentCacheStore,
+ registerConnectionInCatalog,
+ removeConnectionFromCatalog,
+ removeCatalogValue,
+ replaceCatalogValue,
+} from "@t3tools/client-runtime/platform";
+import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization";
+import {
+ ConnectionCredentialStore,
+ ConnectionProfileStore,
+ ConnectionTransientError,
+} from "@t3tools/client-runtime/connection";
+import {
+ EnvironmentId,
+ OrchestrationThread,
+ OrchestrationShellSnapshot,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Ref from "effect/Ref";
+import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
+import * as SecureStore from "expo-secure-store";
+
+import { migrateLegacyConnectionCatalog } from "./migration";
+
+const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1";
+const LEGACY_CONNECTIONS_KEY = "t3code.connections";
+const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
+const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";
+const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
+const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
+const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots";
+
+const StoredShellSnapshot = Schema.Struct({
+ schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
+ environmentId: EnvironmentId,
+ snapshot: OrchestrationShellSnapshot,
+});
+
+const StoredThreadSnapshot = Schema.Struct({
+ schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ thread: OrchestrationThread,
+});
+
+const LegacyStoredShellSnapshot = Schema.Struct({
+ schemaVersion: Schema.Literal(1),
+ environmentId: EnvironmentId,
+ snapshotReceivedAt: Schema.String,
+ snapshot: OrchestrationShellSnapshot,
+});
+
+function catalogError(operation: string, cause: unknown) {
+ return new ConnectionTransientError({
+ reason: "remote-unavailable",
+ message: `Could not ${operation} the local connection catalog: ${String(cause)}`,
+ });
+}
+
+function shellPersistenceError(
+ operation:
+ | "load-shell"
+ | "save-shell"
+ | "load-thread"
+ | "save-thread"
+ | "remove-thread"
+ | "clear-environment",
+ cause: unknown,
+) {
+ return new ConnectionPersistenceError({
+ operation,
+ message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`,
+ });
+}
+
+function threadSnapshotFileName(threadId: ThreadId): string {
+ return `${encodeURIComponent(threadId)}.json`;
+}
+
+const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")(
+ function* (
+ environmentId: EnvironmentId,
+ operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment",
+ ) {
+ return yield* Effect.tryPromise({
+ try: async () => {
+ const { Directory, Paths } = await import("expo-file-system");
+ const directory = new Directory(
+ Paths.document,
+ THREAD_SNAPSHOT_CACHE_DIRECTORY,
+ encodeURIComponent(environmentId),
+ );
+ if (operation !== "clear-environment") {
+ directory.create({ idempotent: true, intermediates: true });
+ }
+ return directory;
+ },
+ catch: (cause) => shellPersistenceError(operation, cause),
+ });
+ },
+);
+
+const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* (
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ operation: "load-thread" | "save-thread" | "remove-thread",
+) {
+ const { File } = yield* Effect.promise(() => import("expo-file-system"));
+ return new File(
+ yield* threadSnapshotDirectory(environmentId, operation),
+ threadSnapshotFileName(threadId),
+ );
+});
+
+function targetPersistenceError(
+ operation: "list-targets" | "register-connection" | "remove-connection",
+ error: ConnectionTransientError,
+) {
+ return new ConnectionPersistenceError({
+ operation,
+ message: error.message,
+ });
+}
+
+const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) {
+ const parsed = yield* Effect.try({
+ try: () => JSON.parse(raw) as unknown,
+ catch: (cause) => catalogError("decode", cause),
+ });
+ return yield* Effect.fromResult(
+ Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed),
+ ).pipe(Effect.mapError((cause) => catalogError("decode", cause)));
+});
+
+const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* (
+ catalog: ConnectionCatalogDocumentType,
+) {
+ const encoded = yield* Effect.fromResult(
+ Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog),
+ ).pipe(Effect.mapError((cause) => catalogError("encode", cause)));
+ return JSON.stringify(encoded);
+});
+
+interface CatalogStore {
+ readonly read: Effect.Effect;
+ readonly update: (
+ transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType,
+ ) => Effect.Effect;
+}
+
+const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () {
+ const state = yield* Ref.make>(Option.none());
+ const lock = yield* Semaphore.make(1);
+
+ const loadUnlocked = Effect.fn("mobile.connectionStorage.loadCatalog")(function* () {
+ const cached = yield* Ref.get(state);
+ if (Option.isSome(cached)) {
+ return cached.value;
+ }
+ const raw = yield* Effect.tryPromise({
+ try: () => SecureStore.getItemAsync(CONNECTION_CATALOG_KEY),
+ catch: (cause) => catalogError("load", cause),
+ });
+ let catalog: ConnectionCatalogDocumentType;
+ if (raw !== null && raw.trim() !== "") {
+ catalog = yield* decodeCatalog(raw);
+ } else {
+ const legacyRaw = yield* Effect.tryPromise({
+ try: () => SecureStore.getItemAsync(LEGACY_CONNECTIONS_KEY),
+ catch: (cause) => catalogError("load legacy", cause),
+ });
+ catalog =
+ legacyRaw === null || legacyRaw.trim() === ""
+ ? EMPTY_CONNECTION_CATALOG_DOCUMENT
+ : yield* migrateLegacyConnectionCatalog(legacyRaw).pipe(
+ Effect.mapError((cause) => catalogError("migrate", cause)),
+ );
+ if (catalog.targets.length > 0) {
+ const encoded = yield* encodeCatalog(catalog);
+ yield* Effect.tryPromise({
+ try: () => SecureStore.setItemAsync(CONNECTION_CATALOG_KEY, encoded),
+ catch: (cause) => catalogError("save migrated", cause),
+ });
+ }
+ }
+ yield* Ref.set(state, Option.some(catalog));
+ return catalog;
+ });
+
+ const read = lock.withPermits(1)(loadUnlocked());
+ const update: CatalogStore["update"] = Effect.fn("mobile.connectionStorage.updateCatalog")(
+ function* (transform) {
+ yield* lock.withPermits(1)(
+ Effect.gen(function* () {
+ const next = transform(yield* loadUnlocked());
+ const encoded = yield* encodeCatalog(next);
+ yield* Effect.tryPromise({
+ try: () => SecureStore.setItemAsync(CONNECTION_CATALOG_KEY, encoded),
+ catch: (cause) => catalogError("save", cause),
+ });
+ yield* Ref.set(state, Option.some(next));
+ }),
+ );
+ },
+ );
+
+ return { read, update } satisfies CatalogStore;
+});
+
+function shellSnapshotFileName(environmentId: EnvironmentId): string {
+ return `${encodeURIComponent(environmentId)}.json`;
+}
+
+const shellSnapshotFileInDirectory = Effect.fn(
+ "mobile.connectionStorage.shellSnapshotFileInDirectory",
+)(function* (
+ environmentId: EnvironmentId,
+ operation: "load-shell" | "save-shell" | "clear-environment",
+ directoryName: string,
+) {
+ return yield* Effect.tryPromise({
+ try: async () => {
+ const { Directory, File, Paths } = await import("expo-file-system");
+ const directory = new Directory(Paths.document, directoryName);
+ directory.create({ idempotent: true, intermediates: true });
+ return new File(directory, shellSnapshotFileName(environmentId));
+ },
+ catch: (cause) => shellPersistenceError(operation, cause),
+ });
+});
+
+const shellSnapshotFile = (
+ environmentId: EnvironmentId,
+ operation: "load-shell" | "save-shell" | "clear-environment",
+) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY);
+
+const legacyShellSnapshotFile = (
+ environmentId: EnvironmentId,
+ operation: "load-shell" | "clear-environment",
+) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY);
+
+export const connectionStorageLayer = Layer.effectContext(
+ Effect.gen(function* () {
+ const catalog = yield* makeCatalogStore();
+
+ const targetStore = ConnectionTargetStore.of({
+ list: catalog.read.pipe(
+ Effect.map((document) => document.targets),
+ Effect.mapError((error) => targetPersistenceError("list-targets", error)),
+ ),
+ });
+ const registrationStore = ConnectionRegistrationStore.of({
+ register: (registration) =>
+ catalog
+ .update((document) => registerConnectionInCatalog(document, registration))
+ .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))),
+ remove: (target) =>
+ catalog
+ .update((document) => removeConnectionFromCatalog(document, target))
+ .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))),
+ });
+ const profileStore = ConnectionProfileStore.of({
+ get: (connectionId) =>
+ catalog.read.pipe(
+ Effect.map((document) =>
+ Option.fromUndefinedOr(
+ document.profiles.find((candidate) => candidate.connectionId === connectionId),
+ ),
+ ),
+ ),
+ put: (profile) =>
+ catalog.update((document) => ({
+ ...document,
+ profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile),
+ })),
+ remove: (connectionId) =>
+ catalog.update((document) => ({
+ ...document,
+ profiles: removeCatalogValue(
+ document.profiles,
+ (value) => value.connectionId,
+ connectionId,
+ ),
+ })),
+ });
+ const credentialStore = ConnectionCredentialStore.of({
+ get: (connectionId) =>
+ catalog.read.pipe(
+ Effect.map((document) =>
+ Option.fromUndefinedOr(
+ document.credentials.find((entry) => entry.connectionId === connectionId)?.credential,
+ ),
+ ),
+ ),
+ put: (connectionId, credential) =>
+ catalog.update((document) => ({
+ ...document,
+ credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, {
+ connectionId,
+ credential,
+ }),
+ })),
+ remove: (connectionId) =>
+ catalog.update((document) => ({
+ ...document,
+ credentials: removeCatalogValue(
+ document.credentials,
+ (value) => value.connectionId,
+ connectionId,
+ ),
+ })),
+ });
+ const remoteTokenStore = RemoteDpopAccessTokenStore.of({
+ get: (environmentId) =>
+ catalog.read.pipe(
+ Effect.map((document) =>
+ Option.fromUndefinedOr(
+ document.remoteDpopTokens.find((token) => token.environmentId === environmentId),
+ ),
+ ),
+ ),
+ put: (token) =>
+ catalog.update((document) => ({
+ ...document,
+ remoteDpopTokens: replaceCatalogValue(
+ document.remoteDpopTokens,
+ (value) => value.environmentId,
+ token,
+ ),
+ })),
+ remove: (environmentId) =>
+ catalog.update((document) => ({
+ ...document,
+ remoteDpopTokens: removeCatalogValue(
+ document.remoteDpopTokens,
+ (value) => value.environmentId,
+ environmentId,
+ ),
+ })),
+ });
+ const cacheStore = EnvironmentCacheStore.of({
+ loadShell: (environmentId) =>
+ Effect.gen(function* () {
+ const file = yield* shellSnapshotFile(environmentId, "load-shell");
+ if (file.exists) {
+ const raw = yield* Effect.tryPromise({
+ try: () => file.text(),
+ catch: (cause) => shellPersistenceError("load-shell", cause),
+ });
+ const parsed = yield* Effect.try({
+ try: () => JSON.parse(raw) as unknown,
+ catch: (cause) => shellPersistenceError("load-shell", cause),
+ });
+ const stored = yield* Effect.fromResult(
+ Schema.decodeUnknownResult(StoredShellSnapshot)(parsed),
+ ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause)));
+ return stored.environmentId === environmentId
+ ? Option.some(stored.snapshot)
+ : Option.none();
+ }
+
+ const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell");
+ if (!legacyFile.exists) {
+ return Option.none();
+ }
+ const legacyRaw = yield* Effect.tryPromise({
+ try: () => legacyFile.text(),
+ catch: (cause) => shellPersistenceError("load-shell", cause),
+ });
+ const legacyParsed = yield* Effect.try({
+ try: () => JSON.parse(legacyRaw) as unknown,
+ catch: (cause) => shellPersistenceError("load-shell", cause),
+ });
+ const legacyStored = yield* Effect.fromResult(
+ Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed),
+ ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause)));
+ return legacyStored.environmentId === environmentId
+ ? Option.some(legacyStored.snapshot)
+ : Option.none();
+ }),
+ saveShell: (environmentId, snapshot) =>
+ Effect.gen(function* () {
+ const file = yield* shellSnapshotFile(environmentId, "save-shell");
+ const stored = {
+ schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
+ environmentId,
+ snapshot,
+ } as const;
+ const encoded = yield* Effect.fromResult(
+ Schema.encodeUnknownResult(StoredShellSnapshot)(stored),
+ ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause)));
+ yield* Effect.try({
+ try: () => {
+ if (!file.exists) {
+ file.create({ intermediates: true, overwrite: true });
+ }
+ file.write(JSON.stringify(encoded));
+ },
+ catch: (cause) => shellPersistenceError("save-shell", cause),
+ });
+ }),
+ loadThread: (environmentId, threadId) =>
+ Effect.gen(function* () {
+ const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread");
+ if (!file.exists) {
+ return Option.none();
+ }
+ const raw = yield* Effect.tryPromise({
+ try: () => file.text(),
+ catch: (cause) => shellPersistenceError("load-thread", cause),
+ });
+ const parsed = yield* Effect.try({
+ try: () => JSON.parse(raw) as unknown,
+ catch: (cause) => shellPersistenceError("load-thread", cause),
+ });
+ const stored = yield* Effect.fromResult(
+ Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed),
+ ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause)));
+ return stored.environmentId === environmentId && stored.threadId === threadId
+ ? Option.some(stored.thread)
+ : Option.none();
+ }),
+ saveThread: (environmentId, thread) =>
+ Effect.gen(function* () {
+ const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread");
+ const encoded = yield* Effect.fromResult(
+ Schema.encodeUnknownResult(StoredThreadSnapshot)({
+ schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
+ environmentId,
+ threadId: thread.id,
+ thread,
+ }),
+ ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause)));
+ yield* Effect.try({
+ try: () => {
+ if (!file.exists) {
+ file.create({ intermediates: true, overwrite: true });
+ }
+ file.write(JSON.stringify(encoded));
+ },
+ catch: (cause) => shellPersistenceError("save-thread", cause),
+ });
+ }),
+ removeThread: (environmentId, threadId) =>
+ Effect.gen(function* () {
+ const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread");
+ if (file.exists) {
+ file.delete();
+ }
+ }).pipe(
+ Effect.mapError((cause) =>
+ cause._tag === "ConnectionPersistenceError"
+ ? cause
+ : shellPersistenceError("remove-thread", cause),
+ ),
+ ),
+ clear: (environmentId) =>
+ Effect.gen(function* () {
+ const file = yield* shellSnapshotFile(environmentId, "clear-environment");
+ if (file.exists) {
+ yield* Effect.try({
+ try: () => file.delete(),
+ catch: (cause) => shellPersistenceError("clear-environment", cause),
+ });
+ }
+ const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment");
+ if (legacyFile.exists) {
+ yield* Effect.try({
+ try: () => legacyFile.delete(),
+ catch: (cause) => shellPersistenceError("clear-environment", cause),
+ });
+ }
+ const threadDirectory = yield* threadSnapshotDirectory(
+ environmentId,
+ "clear-environment",
+ );
+ if (threadDirectory.exists) {
+ yield* Effect.try({
+ try: () => threadDirectory.delete(),
+ catch: (cause) => shellPersistenceError("clear-environment", cause),
+ });
+ }
+ }),
+ });
+
+ return Context.make(ConnectionTargetStore, targetStore).pipe(
+ Context.add(ConnectionRegistrationStore, registrationStore),
+ Context.add(ConnectionProfileStore, profileStore),
+ Context.add(ConnectionCredentialStore, credentialStore),
+ Context.add(RemoteDpopAccessTokenStore, remoteTokenStore),
+ Context.add(EnvironmentCacheStore, cacheStore),
+ );
+ }),
+);
diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts
index f06868ed7d9..ec50e4ae9ce 100644
--- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts
+++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts
@@ -2,7 +2,8 @@ import { beforeEach, vi } from "vite-plus/test";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import type { EnvironmentId } from "@t3tools/contracts";
-import { ManagedRelayClient } from "@t3tools/client-runtime";
+import { ManagedRelayClient } from "@t3tools/client-runtime/relay";
+import * as Layer from "effect/Layer";
import { HttpClient } from "effect/unstable/http";
import type { SavedRemoteConnection } from "../../lib/connection";
@@ -33,90 +34,85 @@ const connection: SavedRemoteConnection = {
bearerToken: "local-bearer",
};
-const runWithHttpClient = (
- effect: Effect.Effect,
-): Promise =>
- Effect.runPromise(
- effect.pipe(
- Effect.provideService(ManagedRelayClient, null as never),
- Effect.provideService(
- HttpClient.HttpClient,
- HttpClient.make(() => Effect.die("unexpected HTTP request")),
- ),
- ),
- );
+const testLayer = Layer.mergeAll(
+ Layer.succeed(ManagedRelayClient, null as never),
+ Layer.succeed(
+ HttpClient.HttpClient,
+ HttpClient.make(() => Effect.die("unexpected HTTP request")),
+ ),
+);
describe("liveActivityPreferences", () => {
beforeEach(() => {
vi.clearAllMocks();
});
- it("pushes disabled Live Activity preferences to relay registrations", async () => {
- await runWithHttpClient(
- setLiveActivityUpdatesEnabled({
+ it.effect("pushes disabled Live Activity preferences to relay registrations", () =>
+ Effect.gen(function* () {
+ yield* setLiveActivityUpdatesEnabled({
enabled: false,
clerkToken: "clerk-token",
connections: [connection],
- }),
- );
-
- expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });
- expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
- expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
- clerkToken: "clerk-token",
- connection,
- });
- });
+ });
- it("pushes enabled Live Activity preferences to relay registrations", async () => {
- await runWithHttpClient(
- setLiveActivityUpdatesEnabled({
+ expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });
+ expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
+ expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
+ clerkToken: "clerk-token",
+ connection,
+ });
+ }).pipe(Effect.provide(testLayer)),
+ );
+
+ it.effect("pushes enabled Live Activity preferences to relay registrations", () =>
+ Effect.gen(function* () {
+ yield* setLiveActivityUpdatesEnabled({
enabled: true,
clerkToken: "clerk-token",
connections: [connection],
- }),
- );
-
- expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true });
- expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
- expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
- clerkToken: "clerk-token",
- connection,
- });
- });
+ });
- it("keeps local preferences refreshable when signed out", async () => {
- await runWithHttpClient(
- setLiveActivityUpdatesEnabled({
+ expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true });
+ expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
+ expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
+ clerkToken: "clerk-token",
+ connection,
+ });
+ }).pipe(Effect.provide(testLayer)),
+ );
+
+ it.effect("keeps local preferences refreshable when signed out", () =>
+ Effect.gen(function* () {
+ yield* setLiveActivityUpdatesEnabled({
enabled: false,
clerkToken: null,
connections: [connection],
- }),
- );
+ });
- expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });
- expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
- expect(linkEnvironmentToCloud).not.toHaveBeenCalled();
- });
+ expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });
+ expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
+ expect(linkEnvironmentToCloud).not.toHaveBeenCalled();
+ }).pipe(Effect.provide(testLayer)),
+ );
- it("does not try to re-link managed relay connections without bearer credentials", async () => {
+ it.effect("does not try to re-link managed relay connections without bearer credentials", () => {
const managedConnection: SavedRemoteConnection = {
...connection,
bearerToken: null,
};
- await runWithHttpClient(
- setLiveActivityUpdatesEnabled({
+ return Effect.gen(function* () {
+ yield* setLiveActivityUpdatesEnabled({
enabled: true,
clerkToken: "clerk-token",
connections: [connection, managedConnection],
- }),
- );
-
- expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1);
- expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
- clerkToken: "clerk-token",
- connection,
- });
+ });
+
+ expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1);
+ expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
+ clerkToken: "clerk-token",
+ connection,
+ });
+ }).pipe(Effect.provide(testLayer));
});
});
diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts
index 7bf29483f1d..a522129d40d 100644
--- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts
+++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts
@@ -1,6 +1,6 @@
import * as Effect from "effect/Effect";
import { HttpClient } from "effect/unstable/http";
-import { ManagedRelayClient } from "@t3tools/client-runtime";
+import { ManagedRelayClient } from "@t3tools/client-runtime/relay";
import type { SavedRemoteConnection } from "../../lib/connection";
import { savePreferencesPatch } from "../../lib/storage";
diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts
index 44ef38df0ef..a4e6fc3d6db 100644
--- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts
+++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts
@@ -1,6 +1,6 @@
import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
-import type { MobilePreferences } from "../../lib/storage";
+import type { Preferences } from "../../lib/storage";
export function makeRelayDeviceRegistrationRequest(input: {
readonly deviceId: string;
@@ -10,7 +10,7 @@ export function makeRelayDeviceRegistrationRequest(input: {
readonly pushToken?: string;
readonly pushToStartToken?: string;
readonly notificationsEnabled: boolean;
- readonly preferences: MobilePreferences;
+ readonly preferences: Preferences;
}): RelayDeviceRegistrationRequest {
const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false;
return {
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
index 346680df8c0..ebb506e1d67 100644
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
@@ -4,17 +4,19 @@ import * as NodeCrypto from "node:crypto";
import { beforeEach, vi } from "vite-plus/test";
import { describe, expect, it } from "@effect/vitest";
+import * as Cause from "effect/Cause";
import Constants from "expo-constants";
import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import { FetchHttpClient } from "effect/unstable/http";
-import type { ManagedRelayClient } from "@t3tools/client-runtime";
+import { type ManagedRelayClient } from "@t3tools/client-runtime/relay";
import type { EnvironmentId } from "@t3tools/contracts";
import { verifyDpopProof } from "@t3tools/shared/dpop";
import type { SavedRemoteConnection } from "../../lib/connection";
-import { mobileCryptoLayer } from "../cloud/dpop";
-import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer";
+import { cryptoLayer } from "../cloud/dpop";
+import { managedRelayClientLayer } from "../cloud/managedRelayLayer";
import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
import {
__resetAgentAwarenessRemoteRegistrationForTest,
@@ -33,6 +35,13 @@ const secureStore = vi.hoisted(() => new Map());
const widgetMocks = vi.hoisted(() => ({
getInstances: vi.fn(() => []),
}));
+const backgroundRuntime = vi.hoisted(() => ({
+ pending: [] as Array<{
+ readonly operation: unknown;
+ readonly resolve: (value: unknown) => void;
+ readonly reject: (error: unknown) => void;
+ }>,
+}));
vi.mock("expo-constants", () => ({
default: {
@@ -95,17 +104,11 @@ vi.mock("react-native", () => ({
}));
vi.mock("../../lib/runtime", () => ({
- mobileRuntime: {
- runPromise: (operation: Effect.Effect) =>
- Effect.runPromise(
- operation.pipe(
- Effect.provide(
- mobileManagedRelayClientLayer("https://relay.example.test").pipe(
- Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)),
- ),
- ),
- ),
- ),
+ runtime: {
+ runPromise: (operation: unknown) =>
+ new Promise((resolve, reject) => {
+ backgroundRuntime.pending.push({ operation, resolve, reject });
+ }),
},
}));
@@ -138,34 +141,38 @@ function savedConnection(): SavedRemoteConnection {
};
}
-const runRegistrationEffect = (effect: Effect.Effect): Promise =>
- Effect.runPromise(
- effect.pipe(
- Effect.provide(
- mobileManagedRelayClientLayer("https://relay.example.test").pipe(
- Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)),
- ),
- ),
- ),
- );
-
-async function waitForFetchCalls(
- fetchMock: ReturnType,
- count: number,
-): Promise {
- for (let attempt = 0; attempt < 20; attempt += 1) {
- if (fetchMock.mock.calls.length >= count) {
- return;
+const relayTestLayer = managedRelayClientLayer("https://relay.example.test").pipe(
+ Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)),
+);
+
+const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundOperations")(
+ function* () {
+ for (;;) {
+ yield* Effect.promise(() => Promise.resolve());
+ const pending = backgroundRuntime.pending.shift();
+ if (!pending) {
+ return;
+ }
+ const exit = yield* Effect.exit(
+ pending.operation as Effect.Effect,
+ );
+ yield* Effect.sync(() => {
+ if (Exit.isSuccess(exit)) {
+ pending.resolve(exit.value);
+ } else {
+ pending.reject(Cause.squash(exit.cause));
+ }
+ });
}
- await new Promise((resolve) => setTimeout(resolve, 0));
- }
-}
+ },
+);
describe("makeRelayDeviceRegistrationRequest", () => {
beforeEach(() => {
vi.unstubAllGlobals();
vi.stubGlobal("__DEV__", false);
secureStore.clear();
+ backgroundRuntime.pending.length = 0;
Constants.expoConfig!.extra = {};
__resetAgentAwarenessRemoteRegistrationForTest();
widgetMocks.getInstances.mockReset();
@@ -243,7 +250,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull();
});
- it("registers at most one listener while a Live Activity push token is pending", async () => {
+ it.effect("registers at most one listener while a Live Activity push token is pending", () => {
registerAgentAwarenessConnection(savedConnection());
const addPushTokenListener = vi.fn();
const activity = {
@@ -251,56 +258,64 @@ describe("makeRelayDeviceRegistrationRequest", () => {
addPushTokenListener,
};
- await expect(
- runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })),
- ).resolves.toBe(false);
- await expect(
- runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })),
- ).resolves.toBe(false);
+ return Effect.gen(function* () {
+ expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false);
+ expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false);
- expect(activity.getPushToken).toHaveBeenCalledTimes(2);
- expect(addPushTokenListener).toHaveBeenCalledTimes(1);
+ expect(activity.getPushToken).toHaveBeenCalledTimes(2);
+ expect(addPushTokenListener).toHaveBeenCalledTimes(1);
+ }).pipe(Effect.provide(relayTestLayer));
});
- it("reports Live Activity token registration as skipped when relay auth is unavailable", async () => {
- registerAgentAwarenessConnection(savedConnection());
- const activity = {
- getPushToken: vi.fn(() => Promise.resolve("activity-token")),
- addPushTokenListener: vi.fn(),
- };
+ it.effect(
+ "reports Live Activity token registration as skipped when relay auth is unavailable",
+ () => {
+ registerAgentAwarenessConnection(savedConnection());
+ const activity = {
+ getPushToken: vi.fn(() => Promise.resolve("activity-token")),
+ addPushTokenListener: vi.fn(),
+ };
- await expect(
- runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })),
- ).resolves.toBe(false);
- });
+ return Effect.gen(function* () {
+ expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false);
+ }).pipe(Effect.provide(relayTestLayer));
+ },
+ );
- it("registers APNS-started Live Activities for relay updates without mutating them locally", async () => {
- const activity = {
- getPushToken: vi.fn(() => Promise.resolve("activity-token")),
- addPushTokenListener: vi.fn(),
- start: vi.fn(),
- update: vi.fn(),
- end: vi.fn(),
- };
- widgetMocks.getInstances.mockReturnValue([activity] as never);
- setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+ it.effect(
+ "registers APNS-started Live Activities for relay updates without mutating them locally",
+ () => {
+ const activity = {
+ getPushToken: vi.fn(() => Promise.resolve("activity-token")),
+ addPushTokenListener: vi.fn(),
+ start: vi.fn(),
+ update: vi.fn(),
+ end: vi.fn(),
+ };
+ widgetMocks.getInstances.mockReturnValue([activity] as never);
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
- await runRegistrationEffect(refreshActiveLiveActivityRemoteRegistration());
+ return Effect.gen(function* () {
+ yield* refreshActiveLiveActivityRemoteRegistration();
- expect(activity.getPushToken).toHaveBeenCalled();
- expect(activity.start).not.toHaveBeenCalled();
- expect(activity.update).not.toHaveBeenCalled();
- expect(activity.end).not.toHaveBeenCalled();
- });
+ expect(activity.getPushToken).toHaveBeenCalled();
+ expect(activity.start).not.toHaveBeenCalled();
+ expect(activity.update).not.toHaveBeenCalled();
+ expect(activity.end).not.toHaveBeenCalled();
+ }).pipe(Effect.provide(relayTestLayer));
+ },
+ );
- it("refreshes APNs registration for connected environments after settings changes", async () => {
+ it.effect("refreshes APNs registration for connected environments after settings changes", () => {
registerAgentAwarenessConnection(savedConnection());
- await new Promise((resolve) => setTimeout(resolve, 0));
- vi.mocked(Notifications.getDevicePushTokenAsync).mockClear();
+ return Effect.gen(function* () {
+ yield* runBackgroundOperations();
+ vi.mocked(Notifications.getDevicePushTokenAsync).mockClear();
- await runRegistrationEffect(refreshAgentAwarenessRegistration());
+ yield* refreshAgentAwarenessRegistration();
- expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
+ expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
+ }).pipe(Effect.provide(relayTestLayer));
});
it.effect("registers the APNs device when cloud auth becomes available", () => {
@@ -330,7 +345,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
return Effect.gen(function* () {
- yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2));
+ yield* runBackgroundOperations();
expect(fetchMock).toHaveBeenCalledTimes(2);
const [request, init] = fetchMock.mock.calls[1] as unknown as [
@@ -357,7 +372,41 @@ describe("makeRelayDeviceRegistrationRequest", () => {
nowEpochSeconds: proofIat(dpop),
}),
).toMatchObject({ ok: true });
+ }).pipe(Effect.provide(relayTestLayer));
+ });
+
+ it.effect("coalesces simultaneous sign-in and environment connection registrations", () => {
+ const fetchMock = vi.fn((request: RequestInfo | URL) => {
+ const url = request instanceof Request ? request.url : String(request);
+ return Promise.resolve(
+ Response.json(
+ url.endsWith("/v1/client/dpop-token")
+ ? {
+ access_token: "relay-dpop-token",
+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
+ token_type: "DPoP",
+ expires_in: 300,
+ scope: "mobile:registration",
+ }
+ : { ok: true },
+ ),
+ );
});
+ vi.stubGlobal("fetch", fetchMock);
+ Constants.expoConfig!.extra = {
+ relay: {
+ url: "https://relay.example.test/",
+ },
+ };
+
+ vi.mocked(Notifications.getPermissionsAsync).mockClear();
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+ registerAgentAwarenessConnection(savedConnection());
+
+ return Effect.gen(function* () {
+ yield* runBackgroundOperations();
+ expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1);
+ }).pipe(Effect.provide(relayTestLayer));
});
it("only registers again when the authenticated identity changes", () => {
@@ -367,7 +416,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true);
});
- it("registers rotated APNs tokens without rereading the native token", async () => {
+ it.effect("registers rotated APNs tokens without rereading the native token", () => {
const fetchMock = vi.fn((request: RequestInfo | URL) => {
const url = request instanceof Request ? request.url : String(request);
return Promise.resolve(
@@ -398,9 +447,10 @@ describe("makeRelayDeviceRegistrationRequest", () => {
expect(tokenListener).toBeDefined();
tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never);
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
+ return Effect.gen(function* () {
+ yield* runBackgroundOperations();
+ expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
+ }).pipe(Effect.provide(relayTestLayer));
});
it.effect(
@@ -432,13 +482,13 @@ describe("makeRelayDeviceRegistrationRequest", () => {
registerAgentAwarenessConnection(savedConnection());
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
return Effect.gen(function* () {
- yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2));
+ yield* runBackgroundOperations();
fetchMock.mockClear();
unregisterAgentAwarenessConnection(savedConnection().environmentId);
expect(fetchMock).not.toHaveBeenCalled();
- });
+ }).pipe(Effect.provide(relayTestLayer));
},
);
});
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
index 3e49ec1e257..be91420bef5 100644
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -8,10 +8,11 @@ import {
type RelayDeviceRegistrationRequest,
type RelayLiveActivityRegistrationRequest,
} from "@t3tools/contracts/relay";
-import { ManagedRelayClient } from "@t3tools/client-runtime";
+import { findErrorTraceId } from "@t3tools/client-runtime/errors";
+import { ManagedRelayClient } from "@t3tools/client-runtime/relay";
import type { SavedRemoteConnection } from "../../lib/connection";
-import { mobileRuntime } from "../../lib/runtime";
+import { runtime } from "../../lib/runtime";
import {
loadAgentAwarenessDeviceId,
loadOrCreateAgentAwarenessDeviceId,
@@ -29,6 +30,20 @@ let pushTokenSubscription: { remove: () => void } | null = null;
let activeLiveActivityRegistrationRetry: ReturnType | null = null;
let relayTokenProvider: (() => Promise) | null = null;
let relayTokenProviderIdentity: string | null = null;
+let deviceRegistrationGeneration = 0;
+let activeDeviceRegistration: {
+ readonly input: DeviceRegistrationInput;
+ readonly operation: Promise;
+} | null = null;
+let pendingDeviceRegistration: {
+ readonly input: DeviceRegistrationInput;
+ readonly context: string;
+} | null = null;
+
+interface DeviceRegistrationInput {
+ readonly pushToStartToken?: string;
+ readonly observedPushToken?: string;
+}
export function normalizeAgentAwarenessRelayBaseUrl(
value: string | null | undefined,
@@ -68,6 +83,11 @@ export function setAgentAwarenessRelayTokenProvider(
const isExistingIdentity =
provider !== null &&
!shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity);
+ if (!isExistingIdentity) {
+ deviceRegistrationGeneration++;
+ activeDeviceRegistration = null;
+ pendingDeviceRegistration = null;
+ }
relayTokenProvider = provider;
relayTokenProviderIdentity = provider ? (identity ?? null) : null;
if (!provider) {
@@ -90,7 +110,7 @@ export function setAgentAwarenessRelayTokenProvider(
if (isExistingIdentity) {
return;
}
- runRegistrationInBackground(registerDevice(), "device registration after cloud sign-in failed");
+ enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
}
function iosMajorVersion(): number {
@@ -149,20 +169,41 @@ const relayToken = Effect.gen(function* () {
function registerDeviceWithRelay(
body: RelayDeviceRegistrationRequest,
+ expectedGeneration: number,
): Effect.Effect {
return Effect.gen(function* () {
+ if (expectedGeneration !== deviceRegistrationGeneration) {
+ logRegistrationDebug("device registration cancelled before relay request", {
+ expectedGeneration,
+ currentGeneration: deviceRegistrationGeneration,
+ });
+ return;
+ }
if (!readRelayConfig()) return;
const token = yield* relayToken;
+ if (expectedGeneration !== deviceRegistrationGeneration) {
+ logRegistrationDebug("device registration cancelled after auth lookup", {
+ expectedGeneration,
+ currentGeneration: deviceRegistrationGeneration,
+ });
+ return;
+ }
if (!token) {
logRegistrationDebug("relay device registration skipped; user is not signed in");
return;
}
const client = yield* ManagedRelayClient;
+ logRegistrationDebug("relay device registration request started", {
+ expectedGeneration,
+ });
yield* client.registerDevice({
clerkToken: token,
payload: body,
});
+ logRegistrationDebug("relay device registration request completed", {
+ expectedGeneration,
+ });
});
}
@@ -213,10 +254,11 @@ function logRegistrationError(context: string, error: unknown): void {
if (!__DEV__) {
return;
}
- console.warn(
- `[agent-awareness] ${context}`,
- error instanceof Error ? error.message : String(error),
- );
+ console.warn(`[agent-awareness] ${context}`, {
+ message: error instanceof Error ? error.message : String(error),
+ traceId: findErrorTraceId(error),
+ error,
+ });
}
function logRegistrationDebug(context: string, details?: unknown): void {
@@ -230,20 +272,99 @@ function runRegistrationInBackground(
operation: Effect.Effect,
context: string,
): void {
- void mobileRuntime.runPromise(operation).catch((error: unknown) => {
+ void runtime.runPromise(operation).catch((error: unknown) => {
logRegistrationError(context, error);
});
}
-function registerDevice(input?: {
- readonly pushToStartToken?: string;
- readonly observedPushToken?: string;
-}): Effect.Effect {
+function mergeDeviceRegistrationInput(
+ current: DeviceRegistrationInput,
+ next: DeviceRegistrationInput,
+): DeviceRegistrationInput {
+ return {
+ ...((next.pushToStartToken ?? current.pushToStartToken)
+ ? { pushToStartToken: next.pushToStartToken ?? current.pushToStartToken }
+ : {}),
+ ...((next.observedPushToken ?? current.observedPushToken)
+ ? { observedPushToken: next.observedPushToken ?? current.observedPushToken }
+ : {}),
+ };
+}
+
+function registrationAddsInformation(
+ current: DeviceRegistrationInput,
+ next: DeviceRegistrationInput,
+): boolean {
+ return (
+ (next.pushToStartToken !== undefined && next.pushToStartToken !== current.pushToStartToken) ||
+ (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken)
+ );
+}
+
+function startPendingDeviceRegistration(): void {
+ if (activeDeviceRegistration || !pendingDeviceRegistration) {
+ return;
+ }
+
+ const next = pendingDeviceRegistration;
+ pendingDeviceRegistration = null;
+ const generation = deviceRegistrationGeneration;
+ logRegistrationDebug("device registration started", {
+ generation,
+ hasObservedPushToken: next.input.observedPushToken !== undefined,
+ hasPushToStartToken: next.input.pushToStartToken !== undefined,
+ });
+ const operation = runtime
+ .runPromise(registerDevice(next.input, generation))
+ .catch((error: unknown) => {
+ logRegistrationError(next.context, error);
+ })
+ .finally(() => {
+ logRegistrationDebug("device registration finished", { generation });
+ if (activeDeviceRegistration?.operation === operation) {
+ activeDeviceRegistration = null;
+ }
+ startPendingDeviceRegistration();
+ });
+ activeDeviceRegistration = { input: next.input, operation };
+}
+
+function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: string): void {
+ if (
+ activeDeviceRegistration &&
+ !registrationAddsInformation(activeDeviceRegistration.input, input)
+ ) {
+ logRegistrationDebug("device registration coalesced with active request", {
+ generation: deviceRegistrationGeneration,
+ });
+ return;
+ }
+
+ logRegistrationDebug("device registration enqueued", {
+ generation: deviceRegistrationGeneration,
+ hasActiveRegistration: activeDeviceRegistration !== null,
+ hasPendingRegistration: pendingDeviceRegistration !== null,
+ });
+ pendingDeviceRegistration = pendingDeviceRegistration
+ ? {
+ input: mergeDeviceRegistrationInput(pendingDeviceRegistration.input, input),
+ context,
+ }
+ : { input, context };
+ startPendingDeviceRegistration();
+}
+
+function registerDevice(
+ input: DeviceRegistrationInput = {},
+ expectedGeneration = deviceRegistrationGeneration,
+): Effect.Effect {
return Effect.gen(function* () {
if (!canRegisterRemoteLiveActivities()) {
+ logRegistrationDebug("device registration skipped; platform does not support it");
return;
}
+ logRegistrationDebug("device registration loading local state", { expectedGeneration });
const [deviceId, preferences] = yield* Effect.all([
Effect.tryPromise({
try: () => loadOrCreateAgentAwarenessDeviceId(),
@@ -255,6 +376,10 @@ function registerDevice(input?: {
}),
]);
const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);
+ logRegistrationDebug("device registration local state ready", {
+ expectedGeneration,
+ notificationsEnabled: pushTokenRegistration.notificationsEnabled,
+ });
yield* registerDeviceWithRelay(
makeRelayDeviceRegistrationRequest({
deviceId,
@@ -266,6 +391,7 @@ function registerDevice(input?: {
notificationsEnabled: pushTokenRegistration.notificationsEnabled,
preferences,
}),
+ expectedGeneration,
);
});
}
@@ -277,10 +403,7 @@ function registerDeviceForCurrentUser(
}
function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void {
- runRegistrationInBackground(
- registerDeviceForCurrentUser(pushToStartToken),
- "push-to-start token registration failed",
- );
+ enqueueDeviceRegistration({ pushToStartToken }, "push-to-start token registration failed");
}
function ensurePushToStartListener(): void {
@@ -303,8 +426,8 @@ function ensurePushTokenListener(): void {
pushTokenSubscription = Notifications.addPushTokenListener((token) => {
if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) {
- runRegistrationInBackground(
- registerDevice({ observedPushToken: token.data.trim() }),
+ enqueueDeviceRegistration(
+ { observedPushToken: token.data.trim() },
"native APNs token rotation registration failed",
);
}
@@ -319,7 +442,7 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti
environmentConnections.set(connection.environmentId, connection);
ensurePushToStartListener();
ensurePushTokenListener();
- runRegistrationInBackground(registerDevice(), "device registration failed");
+ enqueueDeviceRegistration({}, "device registration failed");
runRegistrationInBackground(
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after environment connection failed",
@@ -372,6 +495,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
}
relayTokenProvider = null;
relayTokenProviderIdentity = null;
+ deviceRegistrationGeneration++;
+ activeDeviceRegistration = null;
+ pendingDeviceRegistration = null;
}
export function unregisterAgentAwarenessDeviceForCurrentUser(
diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
index 5fc3b96fdc8..bf400639b19 100644
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
@@ -1,9 +1,15 @@
import { ClerkProvider, useAuth } from "@clerk/expo";
import { tokenCache } from "@clerk/expo/token-cache";
-import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";
+import {
+ createManagedRelaySession,
+ ManagedRelayClient,
+ setManagedRelaySession,
+} from "@t3tools/client-runtime/relay";
+import * as Effect from "effect/Effect";
import { type ReactNode, useEffect, useRef } from "react";
-import { mobileRuntime } from "../../lib/runtime";
+import { useEnvironmentConnectionActions } from "../../state/environments";
+import { runtime } from "../../lib/runtime";
import { appAtomRegistry } from "../../state/atom-registry";
import {
setAgentAwarenessRelayTokenProvider,
@@ -11,47 +17,100 @@ import {
} from "../agent-awareness/remoteRegistration";
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
+function resetManagedRelayTokenCache(): Promise {
+ return runtime.runPromise(
+ ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
+ );
+}
+
function CloudAuthBridge(props: { readonly children: ReactNode }) {
const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
+ const { removeRelayEnvironments } = useEnvironmentConnectionActions();
const previousTokenProviderRef = useRef<{
readonly userId: string;
readonly provider: () => Promise;
} | null>(null);
+ const observedAccountRef = useRef(undefined);
+ const accountTransitionRef = useRef(Promise.resolve());
useEffect(() => {
+ let cancelled = false;
if (!isLoaded) {
return;
}
+
+ const previousObservedAccount = observedAccountRef.current;
+ const nextAccount = isSignedIn && userId ? userId : null;
+ observedAccountRef.current = nextAccount;
+
+ const queueAccountCleanup = (
+ previous: {
+ readonly userId: string;
+ readonly provider: () => Promise;
+ } | null,
+ ) => {
+ accountTransitionRef.current = accountTransitionRef.current.then(async () => {
+ const cleanup = [
+ resetManagedRelayTokenCache(),
+ removeRelayEnvironments(),
+ ...(previous
+ ? [runtime.runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider))]
+ : []),
+ ];
+ const results = await Promise.allSettled(cleanup);
+ for (const result of results) {
+ if (result.status === "rejected") {
+ console.warn("[t3-cloud] cloud account cleanup failed", result.reason);
+ }
+ }
+ });
+ return accountTransitionRef.current;
+ };
+
if (!isSignedIn || !userId) {
const previous = previousTokenProviderRef.current;
previousTokenProviderRef.current = null;
- if (previous) {
- void mobileRuntime
- .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider))
- .catch(() => undefined);
- }
setAgentAwarenessRelayTokenProvider(null);
setManagedRelaySession(appAtomRegistry, null);
+ if (previousObservedAccount !== null) {
+ void queueAccountCleanup(previous);
+ }
return;
}
const previous = previousTokenProviderRef.current;
- if (previous && previous.userId !== userId) {
- void mobileRuntime
- .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider))
- .catch(() => undefined);
- }
const tokenProvider = () => getToken(resolveRelayClerkTokenOptions());
- previousTokenProviderRef.current = { userId, provider: tokenProvider };
- setAgentAwarenessRelayTokenProvider(tokenProvider, userId);
- setManagedRelaySession(
- appAtomRegistry,
- createManagedRelaySession({
- accountId: userId,
- readClerkToken: tokenProvider,
- }),
- );
- }, [getToken, isLoaded, isSignedIn, userId]);
+ const activateSession = () => {
+ if (cancelled) {
+ return;
+ }
+ previousTokenProviderRef.current = { userId, provider: tokenProvider };
+ setAgentAwarenessRelayTokenProvider(tokenProvider, userId);
+ setManagedRelaySession(
+ appAtomRegistry,
+ createManagedRelaySession({
+ accountId: userId,
+ readClerkToken: tokenProvider,
+ }),
+ );
+ };
+ if (
+ previousObservedAccount !== undefined &&
+ previousObservedAccount !== null &&
+ previousObservedAccount !== userId
+ ) {
+ previousTokenProviderRef.current = null;
+ setAgentAwarenessRelayTokenProvider(null);
+ setManagedRelaySession(appAtomRegistry, null);
+ void queueAccountCleanup(previous).then(activateSession);
+ } else {
+ void accountTransitionRef.current.then(activateSession);
+ }
+
+ return () => {
+ cancelled = true;
+ };
+ }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]);
useEffect(
() => () => {
diff --git a/apps/mobile/src/features/cloud/cloudDebugLog.ts b/apps/mobile/src/features/cloud/cloudDebugLog.ts
new file mode 100644
index 00000000000..840a3db5568
--- /dev/null
+++ b/apps/mobile/src/features/cloud/cloudDebugLog.ts
@@ -0,0 +1,18 @@
+export function isCloudDebugEnabled(): boolean {
+ return (
+ (typeof __DEV__ !== "undefined" && __DEV__) ||
+ (typeof globalThis !== "undefined" &&
+ (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true)
+ );
+}
+
+export function cloudDebugLog(event: string, data?: Record): void {
+ if (!isCloudDebugEnabled()) {
+ return;
+ }
+ if (data) {
+ console.log(`[t3-cloud] ${event}`, data);
+ } else {
+ console.log(`[t3-cloud] ${event}`);
+ }
+}
diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts
new file mode 100644
index 00000000000..8143eda09a1
--- /dev/null
+++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts
@@ -0,0 +1,86 @@
+import { EnvironmentId } from "@t3tools/contracts";
+import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
+import { describe, expect, it } from "vite-plus/test";
+
+import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation";
+
+function relayStatus(
+ status: RelayEnvironmentStatusResponse["status"],
+ error?: string,
+): RelayEnvironmentStatusResponse {
+ return {
+ environmentId: EnvironmentId.make("environment-cloud"),
+ endpoint: {
+ httpBaseUrl: "https://cloud.example.test/",
+ wsBaseUrl: "wss://cloud.example.test/ws",
+ providerKind: "cloudflare_tunnel",
+ },
+ status,
+ checkedAt: "2026-06-05T16:49:11.000Z",
+ ...(error ? { error } : {}),
+ };
+}
+
+describe("available cloud environment presentation", () => {
+ it("presents an online unsaved environment as available, not connected", () => {
+ expect(
+ availableCloudEnvironmentPresentation({
+ isStatusPending: false,
+ status: relayStatus("online"),
+ statusError: null,
+ statusErrorTraceId: null,
+ }),
+ ).toEqual({
+ connectionError: null,
+ connectionErrorTraceId: null,
+ connectionState: "available",
+ statusText: "Available · Relay online",
+ });
+ });
+
+ it("keeps relay status checks distinct from connection attempts", () => {
+ expect(
+ availableCloudEnvironmentPresentation({
+ isStatusPending: true,
+ status: null,
+ statusError: null,
+ statusErrorTraceId: null,
+ }),
+ ).toEqual({
+ connectionError: null,
+ connectionErrorTraceId: null,
+ connectionState: "available",
+ statusText: "Available · Checking relay status...",
+ });
+ });
+
+ it("surfaces an offline relay as an error", () => {
+ expect(
+ availableCloudEnvironmentPresentation({
+ isStatusPending: false,
+ status: relayStatus("offline", "Tunnel is unavailable."),
+ statusError: null,
+ statusErrorTraceId: null,
+ }),
+ ).toEqual({
+ connectionError: "Tunnel is unavailable.",
+ connectionErrorTraceId: null,
+ connectionState: "error",
+ statusText: "Tunnel is unavailable.",
+ });
+ });
+
+ it("preserves trace metadata for relay request failures", () => {
+ expect(
+ availableCloudEnvironmentPresentation({
+ isStatusPending: false,
+ status: null,
+ statusError: "Could not get relay environment status.",
+ statusErrorTraceId: "trace-status",
+ }),
+ ).toMatchObject({
+ connectionError: "Could not get relay environment status.",
+ connectionErrorTraceId: "trace-status",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts
new file mode 100644
index 00000000000..0346a1be9d7
--- /dev/null
+++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts
@@ -0,0 +1,53 @@
+import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
+import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+
+export interface AvailableCloudEnvironmentPresentation {
+ readonly connectionError: string | null;
+ readonly connectionErrorTraceId: string | null;
+ readonly connectionState: EnvironmentConnectionPhase;
+ readonly statusText: string;
+}
+
+export function availableCloudEnvironmentPresentation(input: {
+ readonly isStatusPending: boolean;
+ readonly status: RelayEnvironmentStatusResponse | null;
+ readonly statusError: string | null;
+ readonly statusErrorTraceId: string | null;
+}): AvailableCloudEnvironmentPresentation {
+ if (input.status?.status === "online") {
+ return {
+ connectionError: null,
+ connectionErrorTraceId: null,
+ connectionState: "available",
+ statusText: "Available · Relay online",
+ };
+ }
+
+ if (input.status?.status === "offline") {
+ const connectionError = input.status.error ?? "Relay is offline.";
+ return {
+ connectionError,
+ connectionErrorTraceId: null,
+ connectionState: "error",
+ statusText: connectionError,
+ };
+ }
+
+ if (input.statusError) {
+ return {
+ connectionError: input.statusError,
+ connectionErrorTraceId: input.statusErrorTraceId,
+ connectionState: "error",
+ statusText: input.statusError,
+ };
+ }
+
+ return {
+ connectionError: null,
+ connectionErrorTraceId: null,
+ connectionState: "available",
+ statusText: input.isStatusPending
+ ? "Available · Checking relay status..."
+ : "Available · Relay status unknown",
+ };
+}
diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts
index 8eda21b96ce..8945d148ee9 100644
--- a/apps/mobile/src/features/cloud/dpop.test.ts
+++ b/apps/mobile/src/features/cloud/dpop.test.ts
@@ -12,7 +12,7 @@ import {
createDpopProof,
generateDpopProofKeyPair,
loadOrCreateDpopProofKeyPair,
- mobileCryptoLayer,
+ cryptoLayer,
} from "./dpop";
vi.mock("expo-crypto", () => ({
@@ -75,7 +75,7 @@ describe("mobile DPoP", () => {
expect(Buffer.from(digest).toString("hex")).toBe(
NodeCrypto.createHash("sha256").update("typed-array").digest("hex"),
);
- }).pipe(Effect.provide(mobileCryptoLayer)),
+ }).pipe(Effect.provide(cryptoLayer)),
);
it.effect("persists and reuses the installation proof key", () =>
@@ -86,7 +86,7 @@ describe("mobile DPoP", () => {
expect(second.thumbprint).toBe(first.thumbprint);
expect(second.privateJwk).toEqual(first.privateJwk);
- }).pipe(Effect.provide(mobileCryptoLayer)),
+ }).pipe(Effect.provide(cryptoLayer)),
);
it.effect("rejects malformed persisted proof keys", () =>
@@ -96,7 +96,7 @@ describe("mobile DPoP", () => {
const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);
expect(error.message).toBe("Stored DPoP proof key is invalid.");
- }).pipe(Effect.provide(mobileCryptoLayer)),
+ }).pipe(Effect.provide(cryptoLayer)),
);
it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () =>
@@ -135,7 +135,7 @@ describe("mobile DPoP", () => {
nowEpochSeconds: proofIat(bootstrap.proof),
}),
).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint });
- }).pipe(Effect.provide(mobileCryptoLayer)),
+ }).pipe(Effect.provide(cryptoLayer)),
);
it.effect("signs DPoP proofs with RFC 9449 htu normalization", () =>
@@ -161,6 +161,6 @@ describe("mobile DPoP", () => {
nowEpochSeconds: proofIat(proof.proof),
}),
).toMatchObject({ ok: true });
- }).pipe(Effect.provide(mobileCryptoLayer)),
+ }).pipe(Effect.provide(cryptoLayer)),
);
});
diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts
index 0a3d7c2a5a7..0bd4b7ff1bd 100644
--- a/apps/mobile/src/features/cloud/dpop.ts
+++ b/apps/mobile/src/features/cloud/dpop.ts
@@ -70,7 +70,7 @@ function toExpoDigestAlgorithm(
}
}
-export const mobileCryptoLayer = Layer.succeed(
+export const cryptoLayer = Layer.succeed(
Crypto.Crypto,
Crypto.make({
randomBytes: ExpoCrypto.getRandomBytes,
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
index 16a7a745398..a227e937b23 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
@@ -8,8 +8,8 @@ import {
managedRelayClientLayer,
ManagedRelayClient,
ManagedRelayDpopSigner,
- remoteHttpClientLayer,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/relay";
+import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
import { HttpClient } from "effect/unstable/http";
import {
@@ -658,6 +658,7 @@ describe("mobile cloud link environment client", () => {
_tag: "CloudEnvironmentLinkError",
message:
"https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).",
+ traceId: "trace-test",
});
expect(fetchMock).toHaveBeenCalledTimes(3);
}),
@@ -1003,6 +1004,7 @@ describe("mobile cloud link environment client", () => {
_tag: "CloudEnvironmentLinkError",
message:
"https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.",
+ traceId: "trace-connect",
});
}),
);
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts
index f72b35d1756..04838a283fa 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.ts
@@ -16,22 +16,23 @@ import {
type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType,
RelayEnvironmentConnectScope,
RelayEnvironmentStatusScope,
- RelayProtectedError,
type RelayDpopAccessTokenScope,
type RelayProtectedError as RelayProtectedErrorType,
type RelayClientEnvironmentRecord,
type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
type RelayManagedEndpointProviderKind,
} from "@t3tools/contracts/relay";
+import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
+import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
+import { findErrorTraceId } from "@t3tools/client-runtime/errors";
import {
- exchangeRemoteDpopAccessToken,
- fetchRemoteEnvironmentDescriptor,
- makeEnvironmentHttpApiClient,
ManagedRelayClient,
+ type ManagedRelayClientError,
ManagedRelayDpopSigner,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/relay";
+import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
-import { mobileAuthClientMetadata } from "../../lib/authClientMetadata";
+import { authClientMetadata } from "../../lib/authClientMetadata";
import type { SavedRemoteConnection } from "../../lib/connection";
import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage";
import { resolveCloudPublicConfig } from "./publicConfig";
@@ -56,6 +57,7 @@ function readRelayUrl(): string | null {
export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
readonly message: string;
readonly cause?: unknown;
+ readonly traceId?: string;
}> {}
export interface CloudEnvironmentRecordWithStatus {
@@ -64,7 +66,6 @@ export interface CloudEnvironmentRecordWithStatus {
readonly statusError: string | null;
}
-const isRelayProtectedError = Schema.is(RelayProtectedError);
const isEnvironmentCloudApiError = Schema.is(
Schema.Union([
EnvironmentHttpBadRequestError,
@@ -82,11 +83,13 @@ const MANAGED_ENDPOINT_PROVIDER_KIND =
function cloudEnvironmentLinkError(message: string) {
return (cause: unknown) => {
const environmentError = findEnvironmentCloudApiError(cause);
+ const traceId = findErrorTraceId(cause);
return new CloudEnvironmentLinkError({
message: environmentError
? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
: withDevCause(message, cause),
cause,
+ ...(traceId === null ? {} : { traceId }),
});
};
}
@@ -148,31 +151,22 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
case "RelayAgentActivityPublishProofInvalidError":
return `Relay rejected the agent activity publish proof (${error.reason}).`;
case "RelayInternalError":
- return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`;
+ return `Relay encountered an internal error (${error.reason}).`;
}
}
function decodedRelayClientError(message: string) {
- return (cause: unknown) => {
- const relayError = findRelayProtectedError(cause);
+ return (cause: ManagedRelayClientError) => {
+ const relayError = cause.relayError;
const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
return new CloudEnvironmentLinkError({
message: detail ? `${message}: ${detail}` : message,
cause,
+ ...(cause.traceId ? { traceId: cause.traceId } : {}),
});
};
}
-function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null {
- if (isRelayProtectedError(cause)) {
- return cause;
- }
- if (typeof cause !== "object" || cause === null) {
- return null;
- }
- return "cause" in cause ? findRelayProtectedError(cause.cause) : null;
-}
-
function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {
if (isEnvironmentCloudApiError(cause)) {
return cause;
@@ -462,23 +456,26 @@ export function listCloudEnvironmentsWithStatus(input: {
});
}
-function connectRelayManagedEnvironment(input: {
- readonly clerkToken: string;
- readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
- readonly expectedEnvironment?: RelayClientEnvironmentRecord;
-}): Effect.Effect<
- SavedRemoteConnection,
- CloudEnvironmentLinkError,
- HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner
-> {
- return Effect.gen(function* () {
- const relayUrl = yield* requireRelayUrl();
- const relayClient = yield* ManagedRelayClient;
-
- const deviceId = yield* Effect.tryPromise({
+const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")(
+ function* () {
+ return yield* Effect.tryPromise({
try: () => loadOrCreateAgentAwarenessDeviceId(),
catch: cloudEnvironmentLinkError("Could not load the mobile device id."),
});
+ },
+);
+
+const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")(
+ function* (input: {
+ readonly clerkToken: string;
+ readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
+ readonly expectedEnvironment?: RelayClientEnvironmentRecord;
+ }) {
+ yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId });
+ const relayUrl = yield* requireRelayUrl();
+ const relayClient = yield* ManagedRelayClient;
+
+ const deviceId = yield* loadAgentAwarenessDeviceId();
const connect = yield* relayClient
.connectEnvironment({
clerkToken: input.clerkToken,
@@ -528,7 +525,7 @@ function connectRelayManagedEnvironment(input: {
httpBaseUrl: connect.endpoint.httpBaseUrl,
credential: connect.credential,
dpopProof: bootstrapDpop,
- clientMetadata: mobileAuthClientMetadata(),
+ clientMetadata: authClientMetadata(),
}).pipe(
Effect.mapError(
cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."),
@@ -548,9 +545,9 @@ function connectRelayManagedEnvironment(input: {
authenticationMethod: "dpop",
dpopAccessToken: bootstrap.access_token,
relayManaged: true,
- };
- });
-}
+ } satisfies SavedRemoteConnection;
+ },
+);
export function connectCloudEnvironment(input: {
readonly clerkToken: string;
diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts
index 0de43d049c5..6678d13047e 100644
--- a/apps/mobile/src/features/cloud/managedRelayLayer.ts
+++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts
@@ -1,42 +1,46 @@
import {
- managedRelayClientLayer,
+ managedRelayClientLayer as makeManagedRelayClientLayer,
ManagedRelayDpopSigner,
ManagedRelayDpopSignerError,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/relay";
import { RelayMobileClientId } from "@t3tools/contracts/relay";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";
+import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";
-const mobileRelayDpopSignerLayer = Layer.effect(
+const relayDpopSignerLayer = Layer.effect(
ManagedRelayDpopSigner,
Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
+ const loadProofKey = yield* Effect.cached(
+ loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)),
+ );
return ManagedRelayDpopSigner.of({
- thumbprint: Effect.suspend(() =>
- loadOrCreateDpopProofKeyPair().pipe(
- Effect.provideService(Crypto.Crypto, crypto),
- Effect.map((proofKey) => proofKey.thumbprint),
- Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
- ),
+ thumbprint: loadProofKey.pipe(
+ Effect.map((proofKey) => proofKey.thumbprint),
+ Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
+ Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"),
),
- createProof: (input) =>
- Effect.gen(function* () {
- const proofKey = yield* loadOrCreateDpopProofKeyPair().pipe(
- Effect.provideService(Crypto.Crypto, crypto),
- );
+ createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(
+ function* (input) {
+ const proofKey = yield* loadProofKey;
return yield* createDpopProof({ ...input, proofKey }).pipe(
Effect.provideService(Crypto.Crypto, crypto),
Effect.map((proof) => proof.proof),
);
- }).pipe(Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause }))),
+ },
+ Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
+ ),
});
}),
);
-export const mobileManagedRelayClientLayer = (relayUrl: string) =>
- managedRelayClientLayer({ relayUrl, clientId: RelayMobileClientId }).pipe(
- Layer.provideMerge(mobileRelayDpopSignerLayer),
- );
+export const managedRelayClientLayer = (relayUrl: string) =>
+ makeManagedRelayClientLayer({
+ relayUrl,
+ clientId: RelayMobileClientId,
+ accessTokenStore: managedRelayAccessTokenStore,
+ }).pipe(Layer.provideMerge(relayDpopSignerLayer));
diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts
index 3394a519fd6..eec1e3410e6 100644
--- a/apps/mobile/src/features/cloud/managedRelayState.ts
+++ b/apps/mobile/src/features/cloud/managedRelayState.ts
@@ -3,20 +3,24 @@ import {
createManagedRelayQueryManager,
managedRelaySessionAtom,
readManagedRelaySnapshotState,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/relay";
import type {
RelayClientEnvironmentRecord,
RelayEnvironmentStatusResponse,
} from "@t3tools/contracts/relay";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
-import { mobileRuntimeContextLayer } from "../../lib/runtime";
+import { runtimeContextLayer } from "../../lib/runtime";
import { appAtomRegistry } from "../../state/atom-registry";
+import { cloudDebugLog } from "./cloudDebugLog";
-const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer);
+const managedRelayAtomRuntime = Atom.runtime(runtimeContextLayer);
-export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);
+export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime, {
+ onQueryEvent: (event) =>
+ cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }),
+});
const EMPTY_ENVIRONMENTS_ATOM = Atom.make(
AsyncResult.success>([]),
@@ -33,6 +37,15 @@ export function useManagedRelayEnvironments() {
? managedRelayQueryManager.environmentsAtom(accountId)
: EMPTY_ENVIRONMENTS_ATOM;
const result = useAtomValue(atom);
+ const snapshot = readManagedRelaySnapshotState(result);
+ useEffect(() => {
+ if (snapshot.error) {
+ console.error("[t3-cloud] Relay environment listing failed", {
+ message: snapshot.error,
+ traceId: snapshot.errorTraceId,
+ });
+ }
+ }, [snapshot.error, snapshot.errorTraceId]);
const refresh = useCallback(() => {
if (accountId) {
managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId);
@@ -40,7 +53,7 @@ export function useManagedRelayEnvironments() {
}, [accountId]);
return {
- ...readManagedRelaySnapshotState(result),
+ ...snapshot,
accountId,
refresh,
};
@@ -53,6 +66,16 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron
? managedRelayQueryManager.environmentStatusAtom({ accountId, environment })
: EMPTY_ENVIRONMENT_STATUS_ATOM;
const result = useAtomValue(atom);
+ const snapshot = readManagedRelaySnapshotState(result);
+ useEffect(() => {
+ if (snapshot.error) {
+ console.error("[t3-cloud] Relay environment status failed", {
+ environmentId: environment.environmentId,
+ message: snapshot.error,
+ traceId: snapshot.errorTraceId,
+ });
+ }
+ }, [environment.environmentId, snapshot.error, snapshot.errorTraceId]);
const refresh = useCallback(() => {
if (accountId) {
managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, {
@@ -63,7 +86,7 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron
}, [accountId, environment]);
return {
- ...readManagedRelaySnapshotState(result),
+ ...snapshot,
accountId,
refresh,
};
diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts
new file mode 100644
index 00000000000..616fc1add7c
--- /dev/null
+++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts
@@ -0,0 +1,51 @@
+import { expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import { vi } from "vite-plus/test";
+
+const secureStore = vi.hoisted(() => new Map());
+
+vi.mock("expo-secure-store", () => ({
+ getItemAsync: vi.fn((key: string) => Promise.resolve(secureStore.get(key) ?? null)),
+ setItemAsync: vi.fn((key: string, value: string) => {
+ secureStore.set(key, value);
+ return Promise.resolve();
+ }),
+ deleteItemAsync: vi.fn((key: string) => {
+ secureStore.delete(key);
+ return Promise.resolve();
+ }),
+}));
+
+import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";
+
+it.effect("round-trips and clears persisted managed relay access tokens", () =>
+ Effect.gen(function* () {
+ secureStore.clear();
+ const entries = [
+ {
+ accountId: "user-1",
+ clientId: "t3-mobile",
+ relayUrl: "https://relay.example.test",
+ thumbprint: "thumbprint",
+ scopes: ["environment:connect"],
+ accessToken: "access-token",
+ expiresAtMillis: 1_800_000,
+ },
+ ] as const;
+
+ yield* managedRelayAccessTokenStore.save(entries);
+ expect(yield* managedRelayAccessTokenStore.load).toEqual(entries);
+
+ yield* managedRelayAccessTokenStore.clear;
+ expect(yield* managedRelayAccessTokenStore.load).toEqual([]);
+ }),
+);
+
+it.effect("falls back to an empty cache when persisted data is invalid", () =>
+ Effect.gen(function* () {
+ secureStore.clear();
+ secureStore.set("t3code.cloud.relay-access-tokens", "not-json");
+
+ expect(yield* managedRelayAccessTokenStore.load).toEqual([]);
+ }),
+);
diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts
new file mode 100644
index 00000000000..54153a426a1
--- /dev/null
+++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts
@@ -0,0 +1,107 @@
+import {
+ type ManagedRelayAccessTokenCacheEntry,
+ type ManagedRelayAccessTokenStore,
+} from "@t3tools/client-runtime/relay";
+import * as Data from "effect/Data";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SecureStore from "expo-secure-store";
+
+const MANAGED_RELAY_TOKEN_CACHE_KEY = "t3code.cloud.relay-access-tokens";
+const MANAGED_RELAY_TOKEN_CACHE_VERSION = 1;
+
+const ManagedRelayAccessTokenCacheEntrySchema = Schema.Struct({
+ accountId: Schema.String,
+ clientId: Schema.Literals(["t3-mobile", "t3-web"]),
+ relayUrl: Schema.String,
+ thumbprint: Schema.String,
+ scopes: Schema.Array(
+ Schema.Literals(["environment:connect", "environment:status", "mobile:registration"]),
+ ),
+ accessToken: Schema.String,
+ expiresAtMillis: Schema.Number,
+});
+
+const ManagedRelayAccessTokenCacheSchema = Schema.fromJsonString(
+ Schema.Struct({
+ version: Schema.Literal(MANAGED_RELAY_TOKEN_CACHE_VERSION),
+ entries: Schema.Array(ManagedRelayAccessTokenCacheEntrySchema),
+ }),
+);
+
+const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect(
+ ManagedRelayAccessTokenCacheSchema,
+);
+const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema);
+
+export class ManagedRelayTokenStoreError extends Data.TaggedError("ManagedRelayTokenStoreError")<{
+ readonly message: string;
+ readonly cause: unknown;
+}> {}
+
+const storeError =
+ (message: string) =>
+ (cause: unknown): ManagedRelayTokenStoreError =>
+ new ManagedRelayTokenStoreError({ message, cause });
+
+function logStoreFailure(operation: string) {
+ return (error: ManagedRelayTokenStoreError) =>
+ Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe(
+ Effect.annotateLogs({
+ errorTag: error._tag,
+ message: error.message,
+ }),
+ );
+}
+
+const loadManagedRelayAccessTokens = Effect.tryPromise({
+ try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY),
+ catch: storeError("Could not read persisted relay access tokens."),
+}).pipe(
+ Effect.flatMap((encoded) =>
+ encoded === null
+ ? Effect.succeed>([])
+ : decodeManagedRelayAccessTokenCache(encoded).pipe(
+ Effect.map((cache) => cache.entries),
+ Effect.mapError(storeError("Persisted relay access tokens are invalid.")),
+ ),
+ ),
+);
+
+const saveManagedRelayAccessTokens = (entries: ReadonlyArray) =>
+ encodeManagedRelayAccessTokenCache({
+ version: MANAGED_RELAY_TOKEN_CACHE_VERSION,
+ entries,
+ }).pipe(
+ Effect.mapError(storeError("Could not encode relay access tokens.")),
+ Effect.flatMap((encoded) =>
+ Effect.tryPromise({
+ try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded),
+ catch: storeError("Could not persist relay access tokens."),
+ }),
+ ),
+ );
+
+const clearManagedRelayAccessTokens = Effect.tryPromise({
+ try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY),
+ catch: storeError("Could not clear persisted relay access tokens."),
+});
+
+export const managedRelayAccessTokenStore: ManagedRelayAccessTokenStore = {
+ load: loadManagedRelayAccessTokens.pipe(
+ Effect.tapError(logStoreFailure("load")),
+ Effect.orElseSucceed(() => []),
+ Effect.withSpan("mobile.managedRelayTokenStore.load"),
+ ),
+ save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) =>
+ saveManagedRelayAccessTokens(entries).pipe(
+ Effect.tapError(logStoreFailure("save")),
+ Effect.ignore,
+ ),
+ ),
+ clear: clearManagedRelayAccessTokens.pipe(
+ Effect.tapError(logStoreFailure("clear")),
+ Effect.ignore,
+ Effect.withSpan("mobile.managedRelayTokenStore.clear"),
+ ),
+};
diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts
index d5094d71b8b..0307fcdab30 100644
--- a/apps/mobile/src/features/cloud/publicConfig.test.ts
+++ b/apps/mobile/src/features/cloud/publicConfig.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vite-plus/test";
-import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig";
+import { hasTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig";
vi.mock("expo-constants", () => ({
default: {
@@ -94,9 +94,9 @@ describe("resolveCloudPublicConfig", () => {
});
it("keeps tracing disabled unless every public tracing value is configured", () => {
- expect(hasMobileTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false);
+ expect(hasTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false);
expect(
- hasMobileTracingPublicConfig(
+ hasTracingPublicConfig(
resolveCloudPublicConfig({
observability: {
tracesUrl: "https://api.axiom.co/v1/traces",
@@ -106,7 +106,7 @@ describe("resolveCloudPublicConfig", () => {
),
).toBe(false);
expect(
- hasMobileTracingPublicConfig(
+ hasTracingPublicConfig(
resolveCloudPublicConfig({
observability: {
tracesUrl: "https://api.axiom.co/v1/traces",
diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts
index 7a8822eb9db..2d304da7c02 100644
--- a/apps/mobile/src/features/cloud/publicConfig.ts
+++ b/apps/mobile/src/features/cloud/publicConfig.ts
@@ -70,13 +70,13 @@ type Configured = {
readonly [Key in keyof T]: NonNullable;
};
-type MobileTracingPublicConfig = Omit & {
+type TracingPublicConfig = Omit & {
readonly observability: Configured;
};
-export function hasMobileTracingPublicConfig(
+export function hasTracingPublicConfig(
config: CloudPublicConfig = resolveCloudPublicConfig(),
-): config is MobileTracingPublicConfig {
+): config is TracingPublicConfig {
return Boolean(
config.observability.tracesUrl &&
config.observability.tracesDataset &&
diff --git a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts
index 3356642776a..af3373ba7c9 100644
--- a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts
+++ b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts
@@ -91,9 +91,9 @@ async function syncNativeSession(sessionId: string): Promise {
export function useNativeClerkAuthModal() {
const presentingRef = useRef(false);
- const presentAuth = useCallback(async (): Promise => {
+ const presentAuth = useCallback(async (): Promise => {
if (presentingRef.current || !NativeClerk?.presentAuth) {
- return;
+ return false;
}
presentingRef.current = true;
@@ -117,6 +117,7 @@ export function useNativeClerkAuthModal() {
const sessionId = result?.sessionId ?? result?.session?.id ?? null;
if (sessionId && !result?.cancelled) {
await syncNativeSession(sessionId);
+ return true;
}
} catch (error) {
if (__DEV__) {
@@ -125,6 +126,7 @@ export function useNativeClerkAuthModal() {
} finally {
presentingRef.current = false;
}
+ return false;
}, []);
return {
diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
index dd26e2e6ffb..3a66556e20d 100644
--- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
+++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
@@ -1,4 +1,5 @@
import { SymbolView } from "expo-symbols";
+import { connectionStatusText } from "@t3tools/client-runtime/connection";
import type { EnvironmentId } from "@t3tools/contracts";
import { useCallback, useState } from "react";
import { Pressable, View } from "react-native";
@@ -6,26 +7,17 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim
import { useThemeColor } from "../../lib/useThemeColor";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
+import { cn } from "../../lib/cn";
+import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
import { ConnectionStatusDot } from "./ConnectionStatusDot";
function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
- if (environment.connectionError) {
- return null;
- }
-
- switch (environment.connectionState) {
- case "ready":
- return "Connected";
- case "connecting":
- return "Connecting";
- case "reconnecting":
- return "Reconnecting";
- case "disconnected":
- return null;
- case "idle":
- return null;
- }
+ return connectionStatusText({
+ phase: environment.connectionState,
+ error: environment.connectionError,
+ traceId: environment.connectionErrorTraceId,
+ });
}
export function ConnectionEnvironmentRow(props: {
@@ -47,7 +39,11 @@ export function ConnectionEnvironmentRow(props: {
const primaryFg = useThemeColor("--color-primary-foreground");
const dangerFg = useThemeColor("--color-danger-foreground");
const statusLabel = connectionStatusLabel(props.environment);
-
+ const statusTraceId = props.environment.connectionErrorTraceId;
+ const hasConnectionFailure = props.environment.connectionError !== null;
+ const isRetrying =
+ props.environment.connectionState === "connecting" ||
+ props.environment.connectionState === "reconnecting";
const handleSave = useCallback(() => {
props.onUpdate(props.environment.environmentId, {
label: label.trim(),
@@ -64,10 +60,7 @@ export function ConnectionEnvironmentRow(props: {
>
@@ -82,16 +75,35 @@ export function ConnectionEnvironmentRow(props: {
{props.environment.displayUrl}
{statusLabel ? (
-
- {statusLabel}
-
- ) : null}
- {props.environment.connectionError ? (
- {props.environment.connectionError}
+ {statusLabel}
+ {statusTraceId ? (
+ <>
+ {" Trace ID: "}
+ {
+ event.stopPropagation();
+ copyTextWithHaptic(statusTraceId);
+ }}
+ onPress={(event) => {
+ event.stopPropagation();
+ }}
+ style={{ textDecorationStyle: "dotted" }}
+ >
+ {statusTraceId}
+
+ >
+ ) : null}
) : null}
diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx
index 60d86e0118c..ce5c6a6419e 100644
--- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx
+++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx
@@ -11,12 +11,19 @@ import Animated, {
import type { RemoteClientConnectionState } from "../../lib/connection";
-function statusDotTone(state: RemoteClientConnectionState): {
+export type ConnectionStatusDotState = RemoteClientConnectionState;
+
+function statusDotTone(state: ConnectionStatusDotState): {
readonly dotColor: string;
readonly haloColor: string;
} {
switch (state) {
- case "ready":
+ case "available":
+ return {
+ dotColor: "#9ca3af",
+ haloColor: "rgba(156,163,175,0.42)",
+ };
+ case "connected":
return {
dotColor: "#34d399",
haloColor: "rgba(52,211,153,0.48)",
@@ -27,8 +34,8 @@ function statusDotTone(state: RemoteClientConnectionState): {
dotColor: "#f59e0b",
haloColor: "rgba(245,158,11,0.5)",
};
- case "idle":
- case "disconnected":
+ case "offline":
+ case "error":
return {
dotColor: "#ef4444",
haloColor: "rgba(239,68,68,0.48)",
@@ -63,7 +70,7 @@ function usePulseAnimation(pulse: boolean) {
}
export function ConnectionStatusDot(props: {
- readonly state: RemoteClientConnectionState;
+ readonly state: ConnectionStatusDotState;
readonly pulse: boolean;
readonly size?: number;
}) {
diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx
new file mode 100644
index 00000000000..15852cc3c88
--- /dev/null
+++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx
@@ -0,0 +1,108 @@
+import {
+ type EnvironmentConnectionPhase,
+ type EnvironmentConnectionPresentation,
+} from "@t3tools/client-runtime/connection";
+import { SymbolView } from "expo-symbols";
+import { ActivityIndicator, Pressable, View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
+import { useThemeColor } from "../../lib/useThemeColor";
+
+function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string {
+ switch (phase) {
+ case "offline":
+ return "You are offline";
+ case "connecting":
+ return `Connecting to ${environmentLabel}...`;
+ case "reconnecting":
+ return `Reconnecting to ${environmentLabel}...`;
+ case "error":
+ return `${environmentLabel} is unavailable`;
+ case "available":
+ return `${environmentLabel} is disconnected`;
+ case "connected":
+ return "";
+ }
+}
+
+function noticeDetail(
+ phase: EnvironmentConnectionPhase,
+ resourceName: string,
+ error: string | null,
+): string {
+ if (error) {
+ return `The app will keep retrying automatically. ${error}`;
+ }
+
+ switch (phase) {
+ case "offline":
+ return `Cached data remains available. The ${resourceName} will load when your connection returns.`;
+ case "connecting":
+ case "reconnecting":
+ return `The ${resourceName} will load as soon as the environment is ready.`;
+ case "available":
+ case "error":
+ return `Reconnect the environment to load the ${resourceName}.`;
+ case "connected":
+ return "";
+ }
+}
+
+export function EnvironmentConnectionNotice(props: {
+ readonly environmentLabel: string;
+ readonly connection: EnvironmentConnectionPresentation;
+ readonly resourceName: string;
+ readonly onRetry: () => void;
+}) {
+ const iconColor = String(useThemeColor("--color-icon-muted"));
+ const isRetrying =
+ props.connection.phase === "connecting" || props.connection.phase === "reconnecting";
+
+ return (
+
+
+ {isRetrying ? (
+
+ ) : (
+
+ )}
+
+
+ {noticeTitle(props.connection.phase, props.environmentLabel)}
+
+
+ {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)}
+ {props.connection.traceId ? (
+ <>
+ {" Trace ID: "}
+ copyTextWithHaptic(props.connection.traceId!)}
+ >
+ {props.connection.traceId}
+
+ >
+ ) : null}
+
+
+ {props.connection.phase !== "offline" ? (
+
+ Retry now
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts
index 5e17b469de2..0de49ceabf6 100644
--- a/apps/mobile/src/features/connection/connectionTone.ts
+++ b/apps/mobile/src/features/connection/connectionTone.ts
@@ -3,7 +3,7 @@ import type { RemoteClientConnectionState } from "../../lib/connection";
export function connectionTone(state: RemoteClientConnectionState): StatusTone {
switch (state) {
- case "ready":
+ case "connected":
return {
label: "Connected",
pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16",
@@ -21,15 +21,21 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone {
pillClassName: "bg-sky-500/12 dark:bg-sky-500/16",
textClassName: "text-sky-700 dark:text-sky-300",
};
- case "disconnected":
+ case "error":
return {
- label: "Disconnected",
+ label: "Connection failed",
pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
textClassName: "text-rose-700 dark:text-rose-300",
};
- case "idle":
+ case "offline":
return {
- label: "Idle",
+ label: "Offline",
+ pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
+ textClassName: "text-rose-700 dark:text-rose-300",
+ };
+ case "available":
+ return {
+ label: "Available",
pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16",
textClassName: "text-neutral-600 dark:text-neutral-300",
};
diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts
new file mode 100644
index 00000000000..497af4bfac4
--- /dev/null
+++ b/apps/mobile/src/features/connection/environmentSections.test.ts
@@ -0,0 +1,130 @@
+import { EnvironmentId } from "@t3tools/contracts";
+import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
+import { describe, expect, it } from "vite-plus/test";
+import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
+import { splitEnvironmentSections } from "./environmentSections";
+
+function connectedEnvironment(
+ input: Omit, "environmentId"> & {
+ readonly environmentId: string;
+ readonly isRelayManaged: boolean;
+ },
+): ConnectedEnvironmentSummary {
+ return {
+ environmentId: EnvironmentId.make(input.environmentId),
+ environmentLabel: input.environmentLabel ?? input.environmentId,
+ displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`,
+ isRelayManaged: input.isRelayManaged,
+ connectionState: input.connectionState ?? "connected",
+ connectionError: input.connectionError ?? null,
+ connectionErrorTraceId: input.connectionErrorTraceId ?? null,
+ };
+}
+
+function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord {
+ return {
+ environmentId: EnvironmentId.make(environmentId),
+ label: environmentId,
+ endpoint: {
+ httpBaseUrl: `https://${environmentId}.cloud.example.test/`,
+ wsBaseUrl: `wss://${environmentId}.cloud.example.test/ws`,
+ providerKind: "cloudflare_tunnel",
+ },
+ linkedAt: "2026-01-01T00:00:00.000Z",
+ };
+}
+
+describe("mobile environment settings sections", () => {
+ it("keeps saved relay-managed connections under T3 Cloud", () => {
+ const local = connectedEnvironment({
+ environmentId: "environment-local",
+ isRelayManaged: false,
+ });
+ const cloud = connectedEnvironment({
+ environmentId: "environment-cloud",
+ isRelayManaged: true,
+ });
+
+ const sections = splitEnvironmentSections({
+ connectedEnvironments: [cloud, local],
+ cloudEnvironments: [
+ cloudEnvironment("environment-cloud"),
+ cloudEnvironment("environment-new"),
+ ],
+ });
+
+ expect(sections.localEnvironments).toEqual([local]);
+ expect(sections.connectedCloudEnvironments).toEqual([cloud]);
+ expect(
+ sections.availableCloudEnvironments.map((environment) => environment.environmentId),
+ ).toEqual([EnvironmentId.make("environment-new")]);
+ });
+
+ it("keeps saved relay-managed connections visible when cloud listing is unavailable", () => {
+ const cloud = connectedEnvironment({
+ environmentId: "environment-cloud",
+ isRelayManaged: true,
+ connectionState: "reconnecting",
+ connectionError: "Environment did not respond before the connection timeout.",
+ });
+
+ const sections = splitEnvironmentSections({
+ connectedEnvironments: [cloud],
+ cloudEnvironments: null,
+ });
+
+ expect(sections.localEnvironments).toEqual([]);
+ expect(sections.connectedCloudEnvironments).toEqual([cloud]);
+ expect(sections.availableCloudEnvironments).toEqual([]);
+ });
+
+ it("keeps an available saved relay environment as a fallback when listing is unavailable", () => {
+ const cloud = connectedEnvironment({
+ environmentId: "environment-cloud",
+ isRelayManaged: true,
+ connectionState: "available",
+ });
+
+ const sections = splitEnvironmentSections({
+ connectedEnvironments: [cloud],
+ cloudEnvironments: null,
+ });
+
+ expect(sections.connectedCloudEnvironments).toEqual([cloud]);
+ expect(sections.availableCloudEnvironments).toEqual([]);
+ });
+
+ it("does not duplicate a saved relay environment in the available cloud listing", () => {
+ const cloud = connectedEnvironment({
+ environmentId: "environment-cloud",
+ isRelayManaged: true,
+ connectionState: "available",
+ });
+ const listedCloud = cloudEnvironment("environment-cloud");
+
+ const sections = splitEnvironmentSections({
+ connectedEnvironments: [cloud],
+ cloudEnvironments: [listedCloud],
+ });
+
+ expect(sections.connectedCloudEnvironments).toEqual([cloud]);
+ expect(sections.availableCloudEnvironments).toEqual([]);
+ });
+
+ it("keeps failed relay environments in the local connection row", () => {
+ const cloud = connectedEnvironment({
+ environmentId: "environment-cloud",
+ isRelayManaged: true,
+ connectionState: "error",
+ connectionError: "Connection failed.",
+ });
+
+ const sections = splitEnvironmentSections({
+ connectedEnvironments: [cloud],
+ cloudEnvironments: [cloudEnvironment("environment-cloud")],
+ });
+
+ expect(sections.connectedCloudEnvironments).toEqual([cloud]);
+ expect(sections.availableCloudEnvironments).toEqual([]);
+ });
+});
diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts
new file mode 100644
index 00000000000..fc6db479c2f
--- /dev/null
+++ b/apps/mobile/src/features/connection/environmentSections.ts
@@ -0,0 +1,31 @@
+import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
+import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
+
+export interface EnvironmentSectionsInput {
+ readonly connectedEnvironments: ReadonlyArray;
+ readonly cloudEnvironments: ReadonlyArray | null;
+}
+
+export interface EnvironmentSections {
+ readonly localEnvironments: ReadonlyArray;
+ readonly connectedCloudEnvironments: ReadonlyArray;
+ readonly availableCloudEnvironments: ReadonlyArray;
+}
+
+export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections {
+ const savedEnvironmentIds = new Set(
+ input.connectedEnvironments.map((environment) => environment.environmentId),
+ );
+
+ return {
+ localEnvironments: input.connectedEnvironments.filter(
+ (environment) => !environment.isRelayManaged,
+ ),
+ connectedCloudEnvironments: input.connectedEnvironments.filter(
+ (environment) => environment.isRelayManaged,
+ ),
+ availableCloudEnvironments: (input.cloudEnvironments ?? []).filter(
+ (environment) => !savedEnvironmentIds.has(environment.environmentId),
+ ),
+ };
+}
diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts
new file mode 100644
index 00000000000..8968b7ace33
--- /dev/null
+++ b/apps/mobile/src/features/connection/useConnectionController.ts
@@ -0,0 +1,84 @@
+import { useAtomValue } from "@effect/atom-react";
+import type { EnvironmentId } from "@t3tools/contracts";
+import type {
+ RelayClientEnvironmentRecord,
+ RelayEnvironmentStatusResponse,
+} from "@t3tools/contracts/relay";
+import * as Option from "effect/Option";
+import { useCallback, useMemo } from "react";
+
+import { useEnvironmentActions, useEnvironments } from "../../state/environments";
+import { relayEnvironmentDiscovery } from "../../state/relay";
+import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel";
+
+export interface RelayEnvironmentView {
+ readonly environment: RelayClientEnvironmentRecord;
+ readonly availability: "checking" | "online" | "offline" | "error";
+ readonly status: RelayEnvironmentStatusResponse | null;
+ readonly error: string | null;
+ readonly traceId: string | null;
+}
+
+export function useConnectionController() {
+ const { environments } = useEnvironments();
+ const actions = useEnvironmentActions();
+ const discovery = useAtomValue(relayEnvironmentDiscovery.stateValueAtom);
+
+ const connectedEnvironments = useMemo>(
+ () => environments.map(projectWorkspaceEnvironment),
+ [environments],
+ );
+ const registeredIds = useMemo(
+ () => new Set(connectedEnvironments.map((environment) => environment.environmentId)),
+ [connectedEnvironments],
+ );
+ const relayEnvironments = useMemo>(
+ () =>
+ [...discovery.environments.values()].map((entry) => ({
+ environment: entry.environment,
+ availability: entry.availability,
+ status: Option.getOrNull(entry.status),
+ error: Option.getOrNull(entry.error)?.message ?? null,
+ traceId: Option.getOrNull(entry.error)?.traceId ?? null,
+ })),
+ [discovery.environments],
+ );
+ const availableRelayEnvironments = useMemo(
+ () => relayEnvironments.filter((entry) => !registeredIds.has(entry.environment.environmentId)),
+ [registeredIds, relayEnvironments],
+ );
+
+ const connectPairingUrl = useCallback(
+ (pairingUrl: string) => actions.connectPairingUrl(pairingUrl),
+ [actions],
+ );
+ const connectRelayEnvironment = useCallback(
+ (environment: RelayClientEnvironmentRecord) => actions.connectRelayEnvironment(environment),
+ [actions],
+ );
+ const removeEnvironment = useCallback(
+ (environmentId: EnvironmentId) => actions.removeEnvironment(environmentId),
+ [actions],
+ );
+ const retryEnvironment = useCallback(
+ (environmentId: EnvironmentId) => actions.retryEnvironment(environmentId),
+ [actions],
+ );
+
+ return {
+ connectedEnvironments,
+ relayEnvironments,
+ availableRelayEnvironments,
+ relayDiscovery: {
+ isRefreshing: discovery.refreshing,
+ isOffline: discovery.offline,
+ error: Option.getOrNull(discovery.error)?.message ?? null,
+ errorTraceId: Option.getOrNull(discovery.error)?.traceId ?? null,
+ },
+ connectPairingUrl,
+ connectRelayEnvironment,
+ removeEnvironment,
+ retryEnvironment,
+ refreshRelayEnvironments: actions.refreshRelayEnvironments,
+ };
+}
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 00e4582957c..59efd82d12f 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -1,11 +1,11 @@
-import type {
- EnvironmentScopedProjectShell,
- EnvironmentScopedThreadShell,
- VcsStatusState,
-} from "@t3tools/client-runtime";
+import {
+ type EnvironmentProject,
+ type EnvironmentThreadShell,
+} from "@t3tools/client-runtime/state/shell";
import { SymbolView } from "expo-symbols";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Arr from "effect/Array";
import * as Order from "effect/Order";
import { useThemeColor } from "../../lib/useThemeColor";
@@ -13,29 +13,29 @@ import { useThemeColor } from "../../lib/useThemeColor";
import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import { ProjectFavicon } from "../../components/ProjectFavicon";
+import type { WorkspaceState } from "../../state/workspaceModel";
import type { SavedRemoteConnection } from "../../lib/connection";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { relativeTime } from "../../lib/time";
-import type { RemoteCatalogState } from "../../state/use-remote-catalog";
-import { useVcsStatus } from "../../state/use-vcs-status";
import { threadStatusTone } from "../threads/threadPresentation";
/* ─── Types ──────────────────────────────────────────────────────────── */
interface HomeScreenProps {
- readonly projects: ReadonlyArray;
- readonly threads: ReadonlyArray;
- readonly catalogState: RemoteCatalogState;
+ readonly projects: ReadonlyArray;
+ readonly threads: ReadonlyArray;
+ readonly catalogState: WorkspaceState;
readonly savedConnectionsById: Readonly>;
readonly searchQuery: string;
readonly onAddConnection: () => void;
- readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void;
+ readonly onOpenEnvironments: () => void;
+ readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
}
interface ProjectGroup {
readonly key: string;
- readonly project: EnvironmentScopedProjectShell;
- readonly threads: ReadonlyArray;
+ readonly project: EnvironmentProject;
+ readonly threads: ReadonlyArray;
}
const projectGroupActivityOrder = Order.mapInput(
@@ -49,7 +49,7 @@ const projectGroupActivityOrder = Order.mapInput(
/* ─── Status indicator colors ────────────────────────────────────────── */
-function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: string } {
+function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } {
switch (thread.session?.status) {
case "running":
return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" };
@@ -67,11 +67,11 @@ function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: s
const COLLAPSED_THREAD_LIMIT = 6;
function deriveEmptyState(props: {
- readonly catalogState: RemoteCatalogState;
+ readonly catalogState: WorkspaceState;
readonly projectCount: number;
}): { readonly title: string; readonly detail: string; readonly loading: boolean } {
const { catalogState } = props;
- if (catalogState.isLoadingSavedConnections) {
+ if (catalogState.isLoadingConnections) {
return {
title: "Loading environments",
detail: "Checking saved environments on this device.",
@@ -79,7 +79,7 @@ function deriveEmptyState(props: {
};
}
- if (!catalogState.hasSavedConnections) {
+ if (!catalogState.hasConnections) {
return {
title: "No environments connected",
detail: "Add an environment to load projects and start coding sessions.",
@@ -87,7 +87,12 @@ function deriveEmptyState(props: {
};
}
- if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) {
+ if (
+ (catalogState.connectionState === "available" ||
+ catalogState.connectionState === "offline" ||
+ catalogState.connectionState === "error") &&
+ !catalogState.hasLoadedShellSnapshot
+ ) {
return {
title: "Environment unavailable",
detail:
@@ -127,7 +132,7 @@ function deriveEmptyState(props: {
/* ─── Project group header ───────────────────────────────────────────── */
function ProjectGroupLabel(props: {
- readonly project: EnvironmentScopedProjectShell;
+ readonly project: EnvironmentProject;
readonly totalThreadCount: number;
readonly httpBaseUrl: string | null;
readonly bearerToken: string | null;
@@ -167,26 +172,11 @@ function ProjectGroupLabel(props: {
);
}
-/* ─── Git summary line ──────────────────────────────────────────────── */
-
-function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray {
- if (!gitStatus.data) return [];
- const { data } = gitStatus;
- const parts: string[] = [];
- if (data.hasWorkingTreeChanges) {
- parts.push(`${data.workingTree.files.length} changed`);
- }
- if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`);
- if (data.behindCount > 0) parts.push(`${data.behindCount} behind`);
- if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`);
- return parts;
-}
-
/* ─── Thread row ─────────────────────────────────────────────────────── */
function ThreadRow(props: {
- readonly thread: EnvironmentScopedThreadShell;
- readonly projectCwd: string | null;
+ readonly thread: EnvironmentThreadShell;
+ readonly environmentLabel: string | null;
readonly onPress: () => void;
readonly isLast: boolean;
}) {
@@ -195,15 +185,9 @@ function ThreadRow(props: {
const tone = threadStatusTone(props.thread);
const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt);
const branch = props.thread.branch;
-
- // Subscribe to live git status — only when thread has a branch set.
- // Threads sharing the same cwd share one WS subscription via ref-counting.
- const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null;
- const gitStatus = useVcsStatus({
- environmentId: cwd ? props.thread.environmentId : null,
- cwd,
- });
- const gitParts = gitSummaryParts(gitStatus);
+ const subtitleParts = [props.environmentLabel, branch].filter((part): part is string =>
+ Boolean(part),
+ );
return (
({ opacity: pressed ? 0.7 : 1 })}>
@@ -261,8 +245,8 @@ function ThreadRow(props: {
- {/* Branch + git info */}
- {branch ? (
+ {/* Environment + branch */}
+ {subtitleParts.length > 0 ? (
- {branch}
+ {subtitleParts.join(" · ")}
- {gitParts.length > 0 ? (
-
- {" · " + gitParts.join(" · ")}
-
- ) : null}
) : null}
@@ -292,8 +271,61 @@ function ThreadRow(props: {
/* ─── Main screen ────────────────────────────────────────────────────── */
+function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string {
+ if (props.catalogState.networkStatus === "offline") {
+ return "You are offline";
+ }
+ const connectingEnvironments = props.catalogState.connectingEnvironments;
+ if (connectingEnvironments.length === 1) {
+ return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`;
+ }
+ if (connectingEnvironments.length > 1) {
+ return `Reconnecting ${connectingEnvironments.length} environments`;
+ }
+ return "Not connected";
+}
+
+function StaleCatalogStatusPill(props: {
+ readonly catalogState: WorkspaceState;
+ readonly onPress: () => void;
+}) {
+ const iconColor = useThemeColor("--color-icon-muted");
+ const label = staleCatalogPillLabel(props);
+ const isReconnecting = props.catalogState.connectingEnvironments.length > 0;
+
+ return (
+
+ {isReconnecting ? (
+
+ ) : (
+
+ )}
+
+ {label}
+
+
+ );
+}
+
export function HomeScreen(props: HomeScreenProps) {
const [expandedProjects, setExpandedProjects] = useState>(() => new Set());
+ const insets = useSafeAreaInsets();
const accentColor = useThemeColor("--color-icon-muted");
const toggleExpanded = useCallback((key: string) => {
@@ -327,7 +359,7 @@ export function HomeScreen(props: HomeScreenProps) {
/* Group filtered threads by project */
const projectGroups = useMemo>(() => {
- const byProject = new Map();
+ const byProject = new Map();
for (const thread of filteredThreads) {
const key = scopedProjectKey(thread.environmentId, thread.projectId);
const existing = byProject.get(key);
@@ -350,77 +382,96 @@ export function HomeScreen(props: HomeScreenProps) {
/* Empty states */
const hasAnyThreads = props.threads.length > 0;
const hasResults = filteredThreads.length > 0;
+ const shouldShowConnectionStatus =
+ props.catalogState.networkStatus === "offline" ||
+ props.catalogState.hasConnectingEnvironment ||
+ (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment);
const emptyState = deriveEmptyState({
catalogState: props.catalogState,
projectCount: props.projects.length,
});
return (
-
- {!hasAnyThreads ? (
-
-
+
+ {!hasAnyThreads ? (
+
+
+ {emptyState.loading ? (
+
+
+
+ ) : null}
+
+ ) : !hasResults ? (
+
+ ) : (
+ projectGroups.map((group) => {
+ const connection = props.savedConnectionsById[group.project.environmentId];
+ const isExpanded = expandedProjects.has(group.key);
+ const visibleThreads = isExpanded
+ ? group.threads
+ : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
+
+ return (
+
+ toggleExpanded(group.key)}
+ />
+
+ {visibleThreads.map((thread, i) => (
+ props.onSelectThread(thread)}
+ isLast={i === visibleThreads.length - 1}
+ />
+ ))}
+
+
+ );
+ })
+ )}
+
+ {shouldShowConnectionStatus ? (
+
+
- {emptyState.loading ? (
-
-
-
- ) : null}
- ) : !hasResults ? (
-
- ) : (
- projectGroups.map((group) => {
- const connection = props.savedConnectionsById[group.project.environmentId];
- const isExpanded = expandedProjects.has(group.key);
- const visibleThreads = isExpanded
- ? group.threads
- : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
-
- return (
-
- toggleExpanded(group.key)}
- />
-
- {visibleThreads.map((thread, i) => (
- props.onSelectThread(thread)}
- isLast={i === visibleThreads.length - 1}
- />
- ))}
-
-
- );
- })
- )}
-
+ ) : null}
+
);
}
diff --git a/apps/mobile/src/features/observability/mobileTracing.test.ts b/apps/mobile/src/features/observability/mobileTracing.test.ts
deleted file mode 100644
index 0b0f83c6971..00000000000
--- a/apps/mobile/src/features/observability/mobileTracing.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { expect, it } from "@effect/vitest";
-import * as Effect from "effect/Effect";
-import * as Layer from "effect/Layer";
-import { vi } from "vite-plus/test";
-
-import { remoteHttpClientLayer } from "@t3tools/client-runtime";
-import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
-
-import { makeMobileTracingLayer } from "./mobileTracing";
-
-vi.mock("expo-constants", () => ({
- default: {
- expoConfig: {
- extra: {},
- },
- },
-}));
-
-it.effect("exports spans through the scoped mobile OTLP layer", () => {
- const fetchFn = vi.fn(async () => new Response(null, { status: 202 }));
- const tracingLayer = makeMobileTracingLayer(
- {
- tracesUrl: "https://api.axiom.test/v1/traces",
- tracesDataset: "mobile-traces",
- tracesToken: "public-ingest-token",
- },
- {
- appVariant: "test",
- serviceVersion: "1.2.3",
- },
- ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn)));
- const tracedApplication = Layer.effectDiscard(
- Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing),
- ).pipe(Layer.provide(tracingLayer));
-
- return Effect.gen(function* () {
- yield* Layer.build(tracedApplication);
-
- expect(fetchFn).not.toHaveBeenCalled();
- }).pipe(
- Effect.scoped,
- Effect.andThen(
- Effect.sync(() => {
- expect(fetchFn).toHaveBeenCalledOnce();
- const [url, init] = fetchFn.mock.calls[0]!;
- expect(String(url)).toBe("https://api.axiom.test/v1/traces");
- expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token");
- expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces");
- expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span");
- }),
- ),
- );
-});
diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts
new file mode 100644
index 00000000000..b0deb15be8c
--- /dev/null
+++ b/apps/mobile/src/features/observability/tracing.test.ts
@@ -0,0 +1,97 @@
+import { expect, it } from "@effect/vitest";
+import * as Cause from "effect/Cause";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import { vi } from "vite-plus/test";
+
+import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
+import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
+
+import { makeTracingLayer } from "./tracing";
+
+vi.mock("expo-constants", () => ({
+ default: {
+ expoConfig: {
+ extra: {},
+ },
+ },
+}));
+
+it.effect("exports spans through the scoped mobile OTLP layer", () => {
+ const fetchFn = vi.fn(async () => new Response(null, { status: 202 }));
+ const tracingLayer = makeTracingLayer(
+ {
+ tracesUrl: "https://api.axiom.test/v1/traces",
+ tracesDataset: "mobile-traces",
+ tracesToken: "public-ingest-token",
+ },
+ {
+ appVariant: "test",
+ serviceVersion: "1.2.3",
+ },
+ ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn)));
+ const tracedApplication = Layer.effectDiscard(
+ Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing),
+ ).pipe(Layer.provide(tracingLayer));
+
+ return Effect.gen(function* () {
+ yield* Layer.build(tracedApplication);
+
+ expect(fetchFn).not.toHaveBeenCalled();
+ }).pipe(
+ Effect.scoped,
+ Effect.andThen(
+ Effect.sync(() => {
+ expect(fetchFn).toHaveBeenCalledOnce();
+ const [url, init] = fetchFn.mock.calls[0]!;
+ expect(String(url)).toBe("https://api.axiom.test/v1/traces");
+ expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token");
+ expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces");
+ expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span");
+ }),
+ ),
+ );
+});
+
+it.effect("does not let OTLP serialization failures alter application effects", () => {
+ const fetchFn = vi.fn(async () => new Response(null, { status: 202 }));
+ const tracingLayer = makeTracingLayer(
+ {
+ tracesUrl: "https://api.axiom.test/v1/traces",
+ tracesDataset: "mobile-traces",
+ tracesToken: "public-ingest-token",
+ },
+ {
+ appVariant: "test",
+ serviceVersion: "1.2.3",
+ },
+ ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn)));
+ const failure = { durationNanos: 1n };
+ const tracedApplication = Layer.effectDiscard(
+ Effect.fail(failure).pipe(
+ Effect.withSpan("mobile.test.failed-span"),
+ withRelayClientTracing,
+ Effect.exit,
+ Effect.flatMap((exit) => {
+ const reason = exit._tag === "Failure" ? exit.cause.reasons[0] : undefined;
+ return reason && Cause.isFailReason(reason)
+ ? Effect.sync(() => {
+ expect(reason.error).toBe(failure);
+ })
+ : Effect.die(new Error("Expected the original typed failure."));
+ }),
+ ),
+ ).pipe(Layer.provide(tracingLayer));
+
+ return Layer.build(tracedApplication).pipe(
+ Effect.scoped,
+ Effect.andThen(
+ Effect.sync(() => {
+ expect(fetchFn).toHaveBeenCalledOnce();
+ expect(new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array)).toContain(
+ "mobile.test.failed-span",
+ );
+ }),
+ ),
+ );
+});
diff --git a/apps/mobile/src/features/observability/mobileTracing.ts b/apps/mobile/src/features/observability/tracing.ts
similarity index 64%
rename from apps/mobile/src/features/observability/mobileTracing.ts
rename to apps/mobile/src/features/observability/tracing.ts
index dfc6f875c1b..eb73abba292 100644
--- a/apps/mobile/src/features/observability/mobileTracing.ts
+++ b/apps/mobile/src/features/observability/tracing.ts
@@ -1,32 +1,29 @@
import Constants from "expo-constants";
import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";
-import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig";
+import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig";
-export interface MobileTracingConfig {
+export interface TracingConfig {
readonly tracesUrl: string;
readonly tracesDataset: string;
readonly tracesToken: string;
}
-export interface MobileTracingResource {
+export interface TracingResource {
readonly serviceVersion?: string;
readonly appVariant: string;
}
-export function resolveMobileTracingConfig(): MobileTracingConfig | null {
+export function resolveTracingConfig(): TracingConfig | null {
const config = resolveCloudPublicConfig();
- if (!hasMobileTracingPublicConfig(config)) {
+ if (!hasTracingPublicConfig(config)) {
return null;
}
const { tracesUrl, tracesDataset, tracesToken } = config.observability;
return { tracesUrl, tracesDataset, tracesToken };
}
-export function makeMobileTracingLayer(
- config: MobileTracingConfig | null,
- resource: MobileTracingResource,
-) {
+export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) {
return makeRelayClientTracingLayer(config, {
serviceName: "t3-mobile-relay-client",
serviceVersion: resource.serviceVersion,
@@ -35,7 +32,7 @@ export function makeMobileTracingLayer(
});
}
-export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), {
+export const tracingLayer = makeTracingLayer(resolveTracingConfig(), {
serviceVersion: Constants.expoConfig?.version,
appVariant:
typeof Constants.expoConfig?.extra?.appVariant === "string"
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index a7423966f67..997ae61e298 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -2,24 +2,27 @@ import {
addProjectRemoteSourceLabel,
addProjectRemoteSourcePathHint,
addProjectRemoteSourceProvider,
- appendBrowsePathSegment,
buildAddProjectRemoteSourceReadiness,
buildProjectCreateCommand,
- canNavigateUp,
- ensureBrowseDirectoryPath,
findExistingAddProject,
getAddProjectInitialQuery,
+ resolveAddProjectPath,
+ sortAddProjectProviderSources,
+ type AddProjectRemoteSource,
+} from "@t3tools/client-runtime/operations/projects";
+import {
+ appendBrowsePathSegment,
+ canNavigateUp,
+ ensureBrowseDirectoryPath,
getBrowseDirectoryPath,
getBrowseLeafPathSegment,
getBrowseParentPath,
hasTrailingPathSeparator,
inferProjectTitleFromPath,
isFilesystemBrowseQuery,
- resolveAddProjectPath,
- sortAddProjectProviderSources,
- type AddProjectRemoteSource,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/state/projects";
import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
+import { useAtomSet } from "@effect/atom-react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { SymbolView } from "expo-symbols";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
@@ -28,19 +31,17 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Arr from "effect/Array";
import * as Order from "effect/Order";
+import { useProjects, useServerConfigs } from "../../state/entities";
+import { filesystemEnvironment } from "../../state/filesystem";
+import { projectEnvironment } from "../../state/projects";
+import { useEnvironmentQuery } from "../../state/query";
+import { sourceControlEnvironment } from "../../state/sourceControl";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { ErrorBanner } from "../../components/ErrorBanner";
import { SourceControlIcon } from "../../components/SourceControlIcon";
import { useThemeColor } from "../../lib/useThemeColor";
import { uuidv4 } from "../../lib/uuid";
-import { getEnvironmentClient } from "../../state/environment-session-registry";
-import { useFilesystemBrowse } from "../../state/use-filesystem-browse";
-import { useRemoteCatalog } from "../../state/use-remote-catalog";
-import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
-import {
- refreshSourceControlDiscoveryForEnvironment,
- useSourceControlDiscovery,
-} from "../../state/use-source-control-discovery";
+import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
interface EnvironmentOption {
readonly environmentId: EnvironmentId;
@@ -224,12 +225,12 @@ function ProjectPathInput(props: {
}
function useEnvironmentOptions(): ReadonlyArray {
- const { serverConfigByEnvironmentId } = useRemoteCatalog();
- const { savedConnectionsById } = useRemoteEnvironmentState();
+ const serverConfigByEnvironmentId = useServerConfigs();
+ const { savedConnectionsById } = useSavedRemoteConnections();
return useMemo>(() => {
const options = Object.values(savedConnectionsById).map((connection) => {
- const config = serverConfigByEnvironmentId[connection.environmentId];
+ const config = serverConfigByEnvironmentId.get(connection.environmentId);
return {
environmentId: connection.environmentId,
label: connection.environmentLabel,
@@ -336,17 +337,19 @@ export function AddProjectSourceScreen() {
const iconColor = useThemeColor("--color-icon");
const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
useSelectedEnvironment();
- const discoveryState = useSourceControlDiscovery(selectedEnvironment?.environmentId ?? null);
+ const discoveryState = useEnvironmentQuery(
+ selectedEnvironment === null
+ ? null
+ : sourceControlEnvironment.discovery({
+ environmentId: selectedEnvironment.environmentId,
+ input: {},
+ }),
+ );
const readiness = useMemo(
() => buildAddProjectRemoteSourceReadiness(discoveryState.data),
[discoveryState.data],
);
- useEffect(() => {
- if (!selectedEnvironment) return;
- void refreshSourceControlDiscoveryForEnvironment(selectedEnvironment.environmentId);
- }, [selectedEnvironment]);
-
return (
{environmentOptions.length === 0 ? : null}
@@ -435,13 +438,12 @@ export function AddProjectSourceScreen() {
function useCreateProject(environment: EnvironmentOption | null) {
const router = useRouter();
- const { projects } = useRemoteCatalog();
+ const createProject = useAtomSet(projectEnvironment.create, { mode: "promise" });
+ const projects = useProjects();
return useCallback(
async (workspaceRoot: string) => {
if (!environment) return;
- const client = getEnvironmentClient(environment.environmentId);
- if (!client) throw new Error("Environment API is not available.");
const existing = findExistingAddProject({
projects,
@@ -462,14 +464,16 @@ function useCreateProject(environment: EnvironmentOption | null) {
}
const projectId = ProjectId.make(uuidv4());
- await client.orchestration.dispatchCommand(
- buildProjectCreateCommand({
- commandId: CommandId.make(uuidv4()),
- projectId,
- workspaceRoot,
- createdAt: new Date().toISOString(),
- }),
- );
+ const command = buildProjectCreateCommand({
+ commandId: CommandId.make(uuidv4()),
+ projectId,
+ workspaceRoot,
+ createdAt: new Date().toISOString(),
+ });
+ await createProject({
+ environmentId: environment.environmentId,
+ input: command,
+ });
router.replace({
pathname: "/new/draft",
params: {
@@ -479,7 +483,7 @@ function useCreateProject(environment: EnvironmentOption | null) {
},
});
},
- [environment, projects, router],
+ [createProject, environment, projects, router],
);
}
@@ -495,6 +499,9 @@ function useEnvironmentFromParam(): EnvironmentOption | null {
}
export function AddProjectRepositoryScreen() {
+ const lookupRepositoryMutation = useAtomSet(sourceControlEnvironment.lookupRepository, {
+ mode: "promise",
+ });
const router = useRouter();
const params = useLocalSearchParams<{ environmentId?: string; source?: string }>();
const environment = useEnvironmentFromParam();
@@ -523,11 +530,12 @@ export function AddProjectRepositoryScreen() {
return;
}
- const client = getEnvironmentClient(environment.environmentId);
- if (!client) throw new Error("Environment API is not available.");
- const repository = await client.sourceControl.lookupRepository({
- provider,
- repository: repositoryInput.trim(),
+ const repository = await lookupRepositoryMutation({
+ environmentId: environment.environmentId,
+ input: {
+ provider,
+ repository: repositoryInput.trim(),
+ },
});
router.push({
pathname: "/new/add-project/destination",
@@ -543,7 +551,7 @@ export function AddProjectRepositoryScreen() {
} finally {
setIsSubmitting(false);
}
- }, [environment, isSubmitting, repositoryInput, router, source]);
+ }, [environment, isSubmitting, lookupRepositoryMutation, repositoryInput, router, source]);
return (
@@ -593,7 +601,14 @@ function FolderBrowser(props: {
() => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null),
[browseDirectoryPath],
);
- const browseState = useFilesystemBrowse(props.environment.environmentId, browseInput);
+ const browseState = useEnvironmentQuery(
+ browseInput === null
+ ? null
+ : filesystemEnvironment.browse({
+ environmentId: props.environment.environmentId,
+ input: browseInput,
+ }),
+ );
const visibleBrowseEntries = useMemo(
() =>
Arr.sort(
@@ -725,6 +740,9 @@ export function AddProjectLocalFolderScreen() {
}
export function AddProjectDestinationScreen() {
+ const cloneRepository = useAtomSet(sourceControlEnvironment.cloneRepository, {
+ mode: "promise",
+ });
const params = useLocalSearchParams<{
environmentId?: string;
remoteUrl?: string;
@@ -760,11 +778,12 @@ export function AddProjectDestinationScreen() {
setIsSubmitting(true);
try {
- const client = getEnvironmentClient(environment.environmentId);
- if (!client) throw new Error("Environment API is not available.");
- const result = await client.sourceControl.cloneRepository({
- remoteUrl,
- destinationPath: resolved.path,
+ const result = await cloneRepository({
+ environmentId: environment.environmentId,
+ input: {
+ remoteUrl,
+ destinationPath: resolved.path,
+ },
});
await createProject(result.cwd);
} catch (nextError) {
@@ -772,7 +791,7 @@ export function AddProjectDestinationScreen() {
} finally {
setIsSubmitting(false);
}
- }, [createProject, environment, isSubmitting, pathInput, remoteUrl]);
+ }, [cloneRepository, createProject, environment, isSubmitting, pathInput, remoteUrl]);
return (
diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx
index c82ca71596a..2d7394635e9 100644
--- a/apps/mobile/src/features/review/ReviewSheet.tsx
+++ b/apps/mobile/src/features/review/ReviewSheet.tsx
@@ -16,8 +16,11 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
+import { useEnvironmentConnectionActions } from "../../state/environments";
+import { useEnvironmentPresentation } from "../../state/presentation";
import { useThemeColor } from "../../lib/useThemeColor";
import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
+import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
import { useReviewCacheForThread } from "./reviewState";
import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
import {
@@ -114,6 +117,9 @@ export function ReviewSheet() {
environmentId: EnvironmentId;
threadId: ThreadId;
}>();
+ const environment = useEnvironmentPresentation(environmentId);
+ const environmentActions = useEnvironmentConnectionActions();
+ const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
const reviewCache = useReviewCacheForThread({ environmentId, threadId });
const selectedTheme = colorScheme === "dark" ? "dark" : "light";
@@ -126,7 +132,12 @@ export function ReviewSheet() {
selectedSection,
refreshSelectedSection,
selectSection,
- } = useReviewSections({ environmentId, threadId, reviewCache });
+ } = useReviewSections({
+ enabled: isEnvironmentReady,
+ environmentId,
+ threadId,
+ reviewCache,
+ });
const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =
useReviewDiffData({
threadKey: reviewCache.threadKey,
@@ -187,6 +198,11 @@ export function ReviewSheet() {
const parsedDiffNotice =
parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;
+ const hasCachedSelectedDiff = selectedSection?.diff != null;
+ const showConnectionNotice = environment.isReady && !isEnvironmentReady && !hasCachedSelectedDiff;
+ const handleRetryEnvironment = useCallback(() => {
+ void environmentActions.retryNow(environmentId);
+ }, [environmentActions, environmentId]);
const listHeader = useMemo(() => {
const children: ReactElement[] = [];
@@ -312,34 +328,51 @@ export function ReviewSheet() {
}}
/>
-
-
- {reviewSections.map((section) => (
+ {showConnectionNotice ? null : (
+
+
+ {reviewSections.map((section) => (
+ selectSection(section.id)}
+ subtitle={section.subtitle ?? undefined}
+ >
+ {section.title}
+
+ ))}
selectSection(section.id)}
- subtitle={section.subtitle ?? undefined}
+ icon="arrow.clockwise"
+ disabled={
+ loadingGitDiffs ||
+ (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true)
+ }
+ onPress={() => void refreshSelectedSection()}
+ subtitle="Reload current diff"
>
- {section.title}
+ Refresh
- ))}
- void refreshSelectedSection()}
- subtitle="Reload current diff"
- >
- Refresh
-
-
-
+
+
+ )}
- {selectedSection && parsedDiff.kind === "files" ? (
+ {showConnectionNotice ? (
+
+
+
+ ) : selectedSection && parsedDiff.kind === "files" ? (
void;
-}
-
-function makeReviewDiffPreviewKey(input: {
- readonly environmentId: EnvironmentId;
- readonly cwd: string;
-}): string {
- return `${input.environmentId}${REVIEW_DIFF_PREVIEW_KEY_SEPARATOR}${input.cwd}`;
-}
-
-function parseReviewDiffPreviewKey(key: string): {
- readonly environmentId: EnvironmentId;
- readonly cwd: string;
-} {
- const [environmentId, cwd = ""] = key.split(REVIEW_DIFF_PREVIEW_KEY_SEPARATOR);
- return {
- environmentId: environmentId as EnvironmentId,
- cwd,
- };
-}
-
-const reviewDiffPreviewAtom = Atom.family((key: string) =>
- Atom.make(
- Effect.promise(async (): Promise => {
- const target = parseReviewDiffPreviewKey(key);
- const client = getEnvironmentClient(target.environmentId);
- if (!client) {
- throw new Error("Remote connection is not ready.");
- }
- return client.review.getDiffPreview({ cwd: target.cwd });
- }),
- ).pipe(
- Atom.swr({
- staleTime: REVIEW_DIFF_PREVIEW_STALE_TIME_MS,
- revalidateOnMount: true,
- }),
- Atom.setIdleTTL(REVIEW_DIFF_PREVIEW_IDLE_TTL_MS),
- Atom.withLabel(`mobile:review:diff-preview:${key}`),
- ),
-);
-
-const EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM = Atom.make(
- AsyncResult.initial(false),
-).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:diff-preview:null"));
-
-function readReviewDiffPreviewError(
- result: AsyncResult.AsyncResult,
-): string | null {
- if (result._tag !== "Failure") {
- return null;
- }
-
- const error = Cause.squash(result.cause);
- return error instanceof Error ? error.message : "Failed to load review diffs.";
-}
-
-export function useReviewDiffPreview(input: {
- readonly environmentId?: EnvironmentId;
- readonly cwd: string | null;
-}): ReviewDiffPreviewState {
- const key = useMemo(() => {
- if (!input.environmentId || !input.cwd) {
- return null;
- }
- return makeReviewDiffPreviewKey({ environmentId: input.environmentId, cwd: input.cwd });
- }, [input.cwd, input.environmentId]);
-
- const atom = key ? reviewDiffPreviewAtom(key) : null;
- const result = useAtomValue(atom ?? EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM);
- const refresh = useCallback(() => {
- if (atom) {
- appAtomRegistry.refresh(atom);
- }
- }, [atom]);
-
- if (!atom) {
- return {
- data: null,
- error: null,
- isPending: false,
- refresh,
- };
- }
-
- return {
- data: Option.getOrNull(AsyncResult.value(result)),
- error: readReviewDiffPreviewError(result),
- isPending: result.waiting,
- refresh,
- };
-}
diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts
index 4c5a1abffb1..87325490990 100644
--- a/apps/mobile/src/features/review/useReviewSections.ts
+++ b/apps/mobile/src/features/review/useReviewSections.ts
@@ -1,12 +1,12 @@
-import { useCallback, useEffect, useMemo, useRef } from "react";
+import { useCallback, useEffect, useMemo } from "react";
import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
-import { getEnvironmentClient } from "../../state/environment-session-registry";
-import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff";
-import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
+import { useCheckpointDiff } from "../../state/queries";
+import { useEnvironmentQuery } from "../../state/query";
+import { reviewEnvironment } from "../../state/review";
import { useSelectedThreadDetail } from "../../state/use-thread-detail";
-import { useReviewDiffPreview } from "./reviewDiffPreviewState";
+import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import {
buildReviewSectionItems,
getDefaultReviewSectionId,
@@ -17,29 +17,30 @@ import {
setReviewAsyncError,
setReviewGitSections,
setReviewSelectedSectionId,
- setReviewTurnDiffLoading,
setReviewTurnDiff,
+ setReviewTurnDiffLoading,
type ReviewCacheForThread,
} from "./reviewState";
export function useReviewSections(input: {
+ readonly enabled?: boolean;
readonly environmentId?: EnvironmentId;
readonly threadId?: ThreadId;
readonly reviewCache: ReviewCacheForThread;
}) {
const { environmentId, reviewCache, threadId } = input;
+ const enabled = input.enabled ?? true;
const selectedThread = useSelectedThreadDetail();
const { selectedThreadCwd } = useSelectedThreadWorktree();
- const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd });
- const refreshDiffPreview = diffPreview.refresh;
+ const diffPreview = useEnvironmentQuery(
+ enabled && environmentId !== undefined && selectedThreadCwd !== null
+ ? reviewEnvironment.diffPreview({
+ environmentId,
+ input: { cwd: selectedThreadCwd },
+ })
+ : null,
+ );
const { loadingTurnIds } = reviewCache.asyncState;
- const error = diffPreview.error ?? reviewCache.asyncState.error;
- const loadingGitDiffs = diffPreview.isPending;
- const turnDiffByIdRef = useRef(reviewCache.turnDiffById);
-
- useEffect(() => {
- turnDiffByIdRef.current = reviewCache.turnDiffById;
- }, [reviewCache.turnDiffById]);
useEffect(() => {
if (reviewCache.threadKey && diffPreview.data) {
@@ -51,14 +52,16 @@ export function useReviewSections(input: {
() => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []),
[selectedThread?.checkpoints],
);
- const checkpointBySectionId = useMemo(() => {
- return Object.fromEntries(
- readyCheckpoints.map((checkpoint) => [
- getReviewSectionIdForCheckpoint(checkpoint),
- checkpoint,
- ]),
- ) as Record;
- }, [readyCheckpoints]);
+ const checkpointBySectionId = useMemo(
+ () =>
+ Object.fromEntries(
+ readyCheckpoints.map((checkpoint) => [
+ getReviewSectionIdForCheckpoint(checkpoint),
+ checkpoint,
+ ]),
+ ) as Record,
+ [readyCheckpoints],
+ );
const reviewSections = useMemo(
() =>
buildReviewSectionItems({
@@ -87,7 +90,6 @@ export function useReviewSections(input: {
() => getDefaultReviewSectionId(reviewSections),
[reviewSections],
);
- const hasReviewSections = reviewSections.length > 0;
const selectedSectionIdExists = useMemo(
() =>
reviewCache.selectedSectionId
@@ -96,140 +98,69 @@ export function useReviewSections(input: {
[reviewCache.selectedSectionId, reviewSections],
);
- const loadTurnDiff = useCallback(
- async (checkpoint: OrchestrationCheckpointSummary, force = false) => {
- if (!environmentId || !threadId) {
- return;
- }
-
- const sectionId = getReviewSectionIdForCheckpoint(checkpoint);
- if (reviewCache.threadKey) {
- setReviewSelectedSectionId(reviewCache.threadKey, sectionId);
- }
-
- if (!force && turnDiffByIdRef.current[sectionId] !== undefined) {
- return;
- }
-
- const target = {
- environmentId,
- threadId,
- fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1),
- toTurnCount: checkpoint.checkpointTurnCount,
- ignoreWhitespace: false,
- cacheScope: sectionId,
- };
- const cached = checkpointDiffManager.getSnapshot(target).data;
- if (!force && cached) {
- if (reviewCache.threadKey) {
- setReviewTurnDiff(reviewCache.threadKey, sectionId, cached.diff);
- }
- return;
- }
-
- if (!getEnvironmentClient(environmentId)) {
- if (reviewCache.threadKey) {
- setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready.");
- }
- return;
- }
-
- if (reviewCache.threadKey) {
- setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true);
- setReviewAsyncError(reviewCache.threadKey, null);
- }
- try {
- const result = await loadCheckpointDiff(target, { force });
- if (reviewCache.threadKey) {
- if (result) {
- setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff);
- }
- }
- } catch (cause) {
- if (reviewCache.threadKey) {
- setReviewAsyncError(
- reviewCache.threadKey,
- cause instanceof Error ? cause.message : "Failed to load turn diff.",
- );
- }
- } finally {
- if (reviewCache.threadKey) {
- setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false);
- }
- }
- },
- [environmentId, reviewCache.threadKey, threadId],
- );
-
useEffect(() => {
- if (!hasReviewSections) {
- return;
- }
-
- if (reviewCache.threadKey && (!reviewCache.selectedSectionId || !selectedSectionIdExists)) {
+ if (
+ reviewSections.length > 0 &&
+ reviewCache.threadKey &&
+ (!reviewCache.selectedSectionId || !selectedSectionIdExists)
+ ) {
setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId);
}
}, [
fallbackSectionId,
- hasReviewSections,
reviewCache.selectedSectionId,
reviewCache.threadKey,
+ reviewSections.length,
selectedSectionIdExists,
]);
- const latestCheckpoint = readyCheckpoints[0] ?? null;
- const latestSectionId = latestCheckpoint
- ? getReviewSectionIdForCheckpoint(latestCheckpoint)
+ let activeCheckpoint = readyCheckpoints[0] ?? null;
+ if (selectedSection?.kind === "turn") {
+ activeCheckpoint = checkpointBySectionId[selectedSection.id] ?? activeCheckpoint;
+ }
+ const activeSectionId = activeCheckpoint
+ ? getReviewSectionIdForCheckpoint(activeCheckpoint)
: null;
- const latestTurnDiffLoaded = latestSectionId
- ? reviewCache.turnDiffById[latestSectionId] !== undefined
- : true;
- const latestTurnDiffLoading = latestSectionId ? loadingTurnIds[latestSectionId] === true : false;
+ const activeTurnDiff = useCheckpointDiff({
+ environmentId: enabled ? (environmentId ?? null) : null,
+ threadId: enabled ? (threadId ?? null) : null,
+ fromTurnCount:
+ enabled && activeCheckpoint ? Math.max(0, activeCheckpoint.checkpointTurnCount - 1) : null,
+ toTurnCount: enabled ? (activeCheckpoint?.checkpointTurnCount ?? null) : null,
+ ignoreWhitespace: false,
+ });
useEffect(() => {
- if (!latestCheckpoint || !latestSectionId || latestTurnDiffLoaded || latestTurnDiffLoading) {
+ if (!reviewCache.threadKey || !activeSectionId) {
return;
}
-
- void loadTurnDiff(latestCheckpoint);
- }, [
- latestCheckpoint,
- latestSectionId,
- latestTurnDiffLoaded,
- latestTurnDiffLoading,
- loadTurnDiff,
- ]);
-
- const selectedTurnCheckpoint =
- selectedSection?.kind === "turn" ? (checkpointBySectionId[selectedSection.id] ?? null) : null;
- const selectedTurnDiffMissing =
- selectedSection?.kind === "turn" && selectedSection.diff === null && selectedTurnCheckpoint;
- const selectedTurnDiffLoading =
- selectedSection?.kind === "turn" ? loadingTurnIds[selectedSection.id] === true : false;
+ setReviewTurnDiffLoading(reviewCache.threadKey, activeSectionId, activeTurnDiff.isPending);
+ }, [activeSectionId, activeTurnDiff.isPending, reviewCache.threadKey]);
useEffect(() => {
- if (!selectedTurnDiffMissing || selectedTurnDiffLoading) {
+ if (!reviewCache.threadKey || !activeSectionId || !activeTurnDiff.data) {
return;
}
+ setReviewTurnDiff(reviewCache.threadKey, activeSectionId, activeTurnDiff.data.diff);
+ setReviewAsyncError(reviewCache.threadKey, null);
+ }, [activeSectionId, activeTurnDiff.data, reviewCache.threadKey]);
- void loadTurnDiff(selectedTurnDiffMissing);
- }, [loadTurnDiff, selectedTurnDiffLoading, selectedTurnDiffMissing]);
+ useEffect(() => {
+ if (reviewCache.threadKey && activeTurnDiff.error) {
+ setReviewAsyncError(reviewCache.threadKey, activeTurnDiff.error);
+ }
+ }, [activeTurnDiff.error, reviewCache.threadKey]);
const refreshSelectedSection = useCallback(async () => {
- if (!selectedSection) {
+ if (!enabled) {
return;
}
-
- if (selectedSection.kind === "turn") {
- const checkpoint = checkpointBySectionId[selectedSection.id];
- if (checkpoint) {
- await loadTurnDiff(checkpoint, true);
- }
+ if (selectedSection?.kind === "turn") {
+ activeTurnDiff.refresh();
return;
}
-
- refreshDiffPreview();
- }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]);
+ diffPreview.refresh();
+ }, [activeTurnDiff, diffPreview, enabled, selectedSection?.kind]);
const selectSection = useCallback(
(sectionId: string) => {
@@ -241,8 +172,8 @@ export function useReviewSections(input: {
);
return {
- error,
- loadingGitDiffs,
+ error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error,
+ loadingGitDiffs: diffPreview.isPending,
loadingTurnIds,
reviewSections,
selectedSection,
diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx
index 71643336d54..647388c2a42 100644
--- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx
+++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx
@@ -1,18 +1,14 @@
+import { useAtomSet } from "@effect/atom-react";
import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
import { SymbolView } from "expo-symbols";
-import { memo, useCallback, useEffect, useRef, useState } from "react";
+import { memo, useCallback, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import { AppText as Text } from "../../components/AppText";
-import { getEnvironmentClient } from "../../state/environment-session-registry";
-import {
- attachTerminalSession,
- useTerminalSession,
- useTerminalSessionTarget,
-} from "../../state/use-terminal-session";
+import { terminalEnvironment } from "../../state/terminal";
+import { useAttachedTerminalSession } from "../../state/use-terminal-session";
import { TerminalSurface } from "./NativeTerminalSurface";
import { hasNativeTerminalSurface } from "./nativeTerminalModule";
-import { terminalDebugLog } from "./terminalDebugLog";
interface ThreadTerminalPanelProps {
readonly environmentId: EnvironmentId;
@@ -29,79 +25,60 @@ const DEFAULT_TERMINAL_ROWS = 24;
export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
props: ThreadTerminalPanelProps,
) {
+ const writeTerminal = useAtomSet(terminalEnvironment.write, { mode: "promise" });
+ const resizeTerminal = useAtomSet(terminalEnvironment.resize, { mode: "promise" });
const nativeTerminalAvailable = hasNativeTerminalSurface();
const terminalId = DEFAULT_TERMINAL_ID;
- const target = useTerminalSessionTarget({
- environmentId: props.environmentId,
- threadId: props.threadId,
- terminalId,
- });
- const terminal = useTerminalSession(target);
const [lastGridSize, setLastGridSize] = useState({
cols: DEFAULT_TERMINAL_COLS,
rows: DEFAULT_TERMINAL_ROWS,
});
- const lastGridSizeRef = useRef(lastGridSize);
- lastGridSizeRef.current = lastGridSize;
+ const attachInput = useMemo(
+ () =>
+ props.visible
+ ? {
+ threadId: props.threadId,
+ terminalId,
+ cwd: props.cwd,
+ worktreePath: props.worktreePath,
+ cols: lastGridSize.cols,
+ rows: lastGridSize.rows,
+ }
+ : null,
+ [
+ lastGridSize.cols,
+ lastGridSize.rows,
+ props.cwd,
+ props.threadId,
+ props.visible,
+ props.worktreePath,
+ terminalId,
+ ],
+ );
+ const terminal = useAttachedTerminalSession({
+ environmentId: props.environmentId,
+ terminal: attachInput,
+ });
const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`;
const isRunning = terminal.status === "running" || terminal.status === "starting";
- useEffect(() => {
- if (!props.visible) {
- return;
- }
-
- const client = getEnvironmentClient(props.environmentId);
- if (!client) {
- terminalDebugLog("panel:attach-skip", {
- reason: "no-environment-client",
- environmentId: props.environmentId,
- });
- return;
- }
-
- terminalDebugLog("panel:attach", {
- environmentId: props.environmentId,
- threadId: props.threadId,
- terminalId,
- });
-
- return attachTerminalSession({
- environmentId: props.environmentId,
- client,
- terminal: {
- threadId: props.threadId,
- terminalId,
- cwd: props.cwd,
- worktreePath: props.worktreePath,
- cols: lastGridSizeRef.current.cols,
- rows: lastGridSizeRef.current.rows,
- },
- });
- }, [
- props.cwd,
- props.environmentId,
- props.threadId,
- props.worktreePath,
- props.visible,
- terminalId,
- ]);
-
const handleInput = useCallback(
(data: string) => {
- const client = getEnvironmentClient(props.environmentId);
- if (!client || !isRunning) {
+ if (!isRunning) {
return;
}
- void client.terminal.write({
- threadId: props.threadId,
- terminalId,
- data,
+ void writeTerminal({
+ environmentId: props.environmentId,
+ input: {
+ threadId: props.threadId,
+ terminalId,
+ data,
+ },
});
},
- [isRunning, props.environmentId, props.threadId, terminalId],
+ [isRunning, props.environmentId, props.threadId, terminalId, writeTerminal],
);
const handleResize = useCallback(
@@ -111,16 +88,18 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
}
setLastGridSize(size);
- const client = getEnvironmentClient(props.environmentId);
- if (!client || !isRunning) {
+ if (!isRunning) {
return;
}
- void client.terminal.resize({
- threadId: props.threadId,
- terminalId,
- cols: size.cols,
- rows: size.rows,
+ void resizeTerminal({
+ environmentId: props.environmentId,
+ input: {
+ threadId: props.threadId,
+ terminalId,
+ cols: size.cols,
+ rows: size.rows,
+ },
});
},
[
@@ -129,6 +108,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
lastGridSize.rows,
props.environmentId,
props.threadId,
+ resizeTerminal,
terminalId,
],
);
diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
index e4ac3cc5c8b..2e8549de55d 100644
--- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
+++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
@@ -1,10 +1,6 @@
-import {
- DEFAULT_TERMINAL_ID,
- EnvironmentId,
- type TerminalAttachStreamEvent,
- ThreadId,
-} from "@t3tools/contracts";
-import type { KnownTerminalSession } from "@t3tools/client-runtime";
+import { useAtomSet } from "@effect/atom-react";
+import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
import { SymbolView } from "expo-symbols";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -24,17 +20,18 @@ import {
import { EmptyState } from "../../components/EmptyState";
import { GlassSurface } from "../../components/GlassSurface";
import { LoadingScreen } from "../../components/LoadingScreen";
+import { useEnvironmentConnectionActions } from "../../state/environments";
+import { useEnvironmentPresentation } from "../../state/presentation";
+import { terminalEnvironment } from "../../state/terminal";
+import { useWorkspaceState } from "../../state/workspace";
import { buildThreadTerminalNavigation } from "../../lib/routes";
-import { getEnvironmentClient } from "../../state/environment-session-registry";
-import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
import {
- attachTerminalSession,
+ useAttachedTerminalSession,
useKnownTerminalSessions,
- useTerminalSession,
- useTerminalSessionTarget,
} from "../../state/use-terminal-session";
import { useThreadSelection } from "../../state/use-thread-selection";
import { useSelectedThreadDetail } from "../../state/use-thread-detail";
+import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
import { TerminalSurface } from "./NativeTerminalSurface";
import { getPierreTerminalTheme } from "./terminalTheme";
import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
@@ -44,11 +41,10 @@ import {
getTerminalSurfaceReplayBuffer,
TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,
} from "./terminalBufferReplay";
-import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap";
import {
resolveTerminalOpenLocation,
- stagePendingTerminalLaunch,
takePendingTerminalLaunch,
+ type PendingTerminalLaunch,
} from "./terminalLaunchContext";
import {
basename,
@@ -158,8 +154,12 @@ function pickRunningTerminalSessionForBootstrap(
export function ThreadTerminalRouteScreen() {
const router = useRouter();
+ const writeTerminal = useAtomSet(terminalEnvironment.write, { mode: "promise" });
+ const resizeTerminal = useAtomSet(terminalEnvironment.resize, { mode: "promise" });
+ const clearTerminal = useAtomSet(terminalEnvironment.clear, { mode: "promise" });
+ const environmentActions = useEnvironmentConnectionActions();
const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
- const { isLoadingSavedConnection } = useRemoteEnvironmentState();
+ const { state: workspaceState } = useWorkspaceState();
const params = useLocalSearchParams<{
environmentId?: string | string[];
threadId?: string | string[];
@@ -174,6 +174,8 @@ export function ThreadTerminalRouteScreen() {
? EnvironmentId.make(routeEnvironmentIdRaw)
: null;
const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null;
+ const environment = useEnvironmentPresentation(routeEnvironmentId);
+ const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
const requestedTerminalId = firstRouteParam(params.terminalId);
const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;
const cachedFontSize = getCachedTerminalFontSize();
@@ -189,6 +191,47 @@ export function ThreadTerminalRouteScreen() {
environmentId: selectedThread?.environmentId ?? null,
threadId: selectedThread?.id ?? null,
});
+ const runningSession = useMemo(
+ () => pickRunningTerminalSessionForBootstrap(knownSessions),
+ [knownSessions],
+ );
+ const activeKnownSession = useMemo(
+ () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null,
+ [knownSessions, terminalId],
+ );
+ const launchTarget = useMemo(
+ () =>
+ selectedThread
+ ? {
+ environmentId: selectedThread.environmentId,
+ threadId: selectedThread.id,
+ terminalId,
+ }
+ : null,
+ [selectedThread?.environmentId, selectedThread?.id, terminalId],
+ );
+ const launchTargetKey = launchTarget
+ ? `${launchTarget.environmentId}:${launchTarget.threadId}:${launchTarget.terminalId}`
+ : null;
+ const [pendingLaunchEntry, setPendingLaunchEntry] = useState<{
+ readonly key: string | null;
+ readonly launch: PendingTerminalLaunch | null;
+ }>(() => ({
+ key: launchTargetKey,
+ launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget),
+ }));
+ const pendingLaunch =
+ pendingLaunchEntry.key === launchTargetKey ? pendingLaunchEntry.launch : null;
+ const hasResolvedPendingLaunch = pendingLaunchEntry.key === launchTargetKey;
+ const [initialAttachGridEntry, setInitialAttachGridEntry] = useState(() => ({
+ key: launchTargetKey,
+ size: cachedRouteGridSize ?? {
+ cols: DEFAULT_TERMINAL_COLS,
+ rows: DEFAULT_TERMINAL_ROWS,
+ },
+ }));
+ const initialAttachGridSize =
+ initialAttachGridEntry.key === launchTargetKey ? initialAttachGridEntry.size : null;
const [lastGridSize, setLastGridSize] = useState(
cachedRouteGridSize ?? {
cols: DEFAULT_TERMINAL_COLS,
@@ -198,11 +241,10 @@ export function ThreadTerminalRouteScreen() {
const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);
const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);
- const hasOpenedRef = useRef(false);
const bufferReplayTimerRef = useRef | null>(null);
- const attachStreamLogCountRef = useRef(0);
const firstNonEmptyBufferLoggedRef = useRef(false);
const lastBufferReplayKeyRef = useRef(null);
+ const sentInitialInputKeyRef = useRef(null);
const [readyBufferReplayKey, setReadyBufferReplayKey] = useState(null);
const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(
cachedFontSize !== null,
@@ -216,12 +258,78 @@ export function ThreadTerminalRouteScreen() {
terminalId,
value: null,
});
- const target = useTerminalSessionTarget({
+ const shouldRedirectToRunningTerminal =
+ requestedTerminalId === null &&
+ runningSession !== null &&
+ runningSession.target.terminalId !== terminalId;
+ const launchLocationCandidate = useMemo(() => {
+ if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
+ return null;
+ }
+ if (pendingLaunch) {
+ return {
+ cwd: pendingLaunch.cwd,
+ worktreePath: pendingLaunch.worktreePath,
+ };
+ }
+ return resolveTerminalOpenLocation({
+ terminalLocation: activeKnownSession?.state.summary ?? null,
+ activeSessionLocation: activeKnownSession?.state.summary ?? null,
+ workspaceRoot: selectedThreadProject.workspaceRoot,
+ threadShellWorktreePath: selectedThread.worktreePath ?? null,
+ threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,
+ });
+ }, [
+ activeKnownSession?.state.summary,
+ pendingLaunch,
+ selectedThread,
+ selectedThreadDetail?.worktreePath,
+ selectedThreadProject?.workspaceRoot,
+ ]);
+ const [initialLaunchLocationEntry, setInitialLaunchLocationEntry] = useState(() => ({
+ key: launchTargetKey,
+ location: launchLocationCandidate,
+ }));
+ const launchLocation =
+ initialLaunchLocationEntry.key === launchTargetKey ? initialLaunchLocationEntry.location : null;
+ const terminalAttachInput = useMemo(
+ () =>
+ selectedThread !== null &&
+ launchLocation !== null &&
+ hasResolvedPendingLaunch &&
+ initialAttachGridSize !== null &&
+ hasResolvedFontPreference &&
+ hasMeasuredSurface &&
+ isEnvironmentReady &&
+ !shouldRedirectToRunningTerminal
+ ? {
+ threadId: selectedThread.id,
+ terminalId,
+ cwd: launchLocation.cwd,
+ worktreePath: launchLocation.worktreePath,
+ cols: initialAttachGridSize.cols,
+ rows: initialAttachGridSize.rows,
+ ...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}),
+ ...(pendingLaunch ? { restartIfNotRunning: true } : {}),
+ }
+ : null,
+ [
+ hasMeasuredSurface,
+ hasResolvedFontPreference,
+ hasResolvedPendingLaunch,
+ initialAttachGridSize,
+ isEnvironmentReady,
+ launchLocation,
+ pendingLaunch,
+ selectedThread,
+ shouldRedirectToRunningTerminal,
+ terminalId,
+ ],
+ );
+ const terminal = useAttachedTerminalSession({
environmentId: selectedThread?.environmentId ?? null,
- threadId: selectedThread?.id ?? null,
- terminalId,
+ terminal: terminalAttachInput,
});
- const terminal = useTerminalSession(target);
const terminalKey = selectedThread
? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}`
: terminalId;
@@ -293,23 +401,6 @@ export function ThreadTerminalRouteScreen() {
() => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),
[selectedEnvironmentConnection?.environmentLabel],
);
- const runningSession = useMemo(
- () => pickRunningTerminalSessionForBootstrap(knownSessions),
- [knownSessions],
- );
- const activeKnownSession = useMemo(
- () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null,
- [knownSessions, terminalId],
- );
-
- const terminalAttachLaunchHintsRef = useRef({
- terminalSummary: terminal.summary,
- activeKnownSummary: activeKnownSession?.state.summary ?? null,
- });
- terminalAttachLaunchHintsRef.current = {
- terminalSummary: terminal.summary,
- activeKnownSummary: activeKnownSession?.state.summary ?? null,
- };
const terminalTheme = getPierreTerminalTheme(appearanceScheme);
const pendingModifier =
@@ -406,145 +497,88 @@ export function ThreadTerminalRouteScreen() {
],
);
- const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => {
- const n = ++attachStreamLogCountRef.current;
- if (event.type === "output" && n > 32 && n % 64 !== 0) {
+ useEffect(() => {
+ if (pendingLaunchEntry.key === launchTargetKey) {
return;
}
- if (event.type === "snapshot") {
- terminalDebugLog("attach:stream", {
- n,
- type: event.type,
- status: event.snapshot.status,
- historyLen: event.snapshot.history.length,
- cwd: event.snapshot.cwd,
- });
+ setPendingLaunchEntry({
+ key: launchTargetKey,
+ launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget),
+ });
+ }, [launchTarget, launchTargetKey, pendingLaunchEntry.key]);
+
+ useEffect(() => {
+ if (initialAttachGridEntry.key === launchTargetKey) {
return;
}
- if (event.type === "output") {
- terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length });
+ setInitialAttachGridEntry({
+ key: launchTargetKey,
+ size: cachedRouteGridSize ?? {
+ cols: DEFAULT_TERMINAL_COLS,
+ rows: DEFAULT_TERMINAL_ROWS,
+ },
+ });
+ }, [cachedRouteGridSize, initialAttachGridEntry.key, launchTargetKey]);
+
+ useEffect(() => {
+ if (
+ initialLaunchLocationEntry.key === launchTargetKey &&
+ initialLaunchLocationEntry.location !== null
+ ) {
return;
}
- terminalDebugLog("attach:stream", { n, type: event.type });
- }, []);
-
- const attachTerminal = useCallback(() => {
- if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
- terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" });
- return null;
+ if (initialLaunchLocationEntry.key === launchTargetKey && launchLocationCandidate === null) {
+ return;
}
+ setInitialLaunchLocationEntry({
+ key: launchTargetKey,
+ location: launchLocationCandidate,
+ });
+ }, [
+ initialLaunchLocationEntry.key,
+ initialLaunchLocationEntry.location,
+ launchLocationCandidate,
+ launchTargetKey,
+ ]);
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- terminalDebugLog("attach:abort", {
- reason: "no-environment-client",
- environmentId: selectedThread.environmentId,
- });
- return null;
+ useEffect(() => {
+ if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) {
+ return;
}
+ router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId));
+ }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]);
- const pendingLaunchTarget = {
+ useEffect(() => {
+ const initialInput = pendingLaunch?.initialInput;
+ if (
+ !initialInput ||
+ !selectedThread ||
+ terminal.version === 0 ||
+ sentInitialInputKeyRef.current === launchTargetKey
+ ) {
+ return;
+ }
+ sentInitialInputKeyRef.current = launchTargetKey;
+ void writeTerminal({
environmentId: selectedThread.environmentId,
- threadId: selectedThread.id,
- terminalId,
- };
- const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget);
- let initialInputSent = false;
-
- try {
- const launchLocation = pendingLaunch
- ? {
- cwd: pendingLaunch.cwd,
- worktreePath: pendingLaunch.worktreePath,
- }
- : resolveTerminalOpenLocation({
- terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary,
- activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary,
- workspaceRoot: selectedThreadProject.workspaceRoot,
- threadShellWorktreePath: selectedThread.worktreePath ?? null,
- threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,
- });
-
- terminalDebugLog("attach:start", {
- terminalId,
+ input: {
threadId: selectedThread.id,
- cols: lastGridSize.cols,
- rows: lastGridSize.rows,
- cwd: launchLocation.cwd,
- worktreePath: launchLocation.worktreePath,
- });
-
- return attachTerminalSession({
- environmentId: selectedThread.environmentId,
- client,
- terminal: {
- threadId: selectedThread.id,
- terminalId,
- cwd: launchLocation.cwd,
- worktreePath: launchLocation.worktreePath,
- cols: lastGridSize.cols,
- rows: lastGridSize.rows,
- env: pendingLaunch?.env,
- ...(pendingLaunch ? { restartIfNotRunning: true } : {}),
- },
- onEvent: logAttachStreamEvent,
- onSnapshot: () => {
- if (!pendingLaunch?.initialInput || initialInputSent) {
- return;
- }
-
- initialInputSent = true;
- void client.terminal.write({
- threadId: selectedThread.id,
- terminalId,
- data: pendingLaunch.initialInput,
- });
- },
- });
- } catch (error) {
- terminalDebugLog("attach:error", {
- message: error instanceof Error ? error.message : String(error),
- });
- if (pendingLaunch) {
- stagePendingTerminalLaunch({
- target: pendingLaunchTarget,
- launch: pendingLaunch,
- });
- }
-
- throw error;
- }
+ terminalId,
+ data: initialInput,
+ },
+ });
}, [
- lastGridSize.cols,
- lastGridSize.rows,
- logAttachStreamEvent,
- selectedThreadDetail?.worktreePath,
+ launchTargetKey,
+ pendingLaunch?.initialInput,
selectedThread,
- selectedThreadProject?.workspaceRoot,
+ terminal.version,
terminalId,
+ writeTerminal,
]);
- const attachTerminalRef = useRef(attachTerminal);
- attachTerminalRef.current = attachTerminal;
- const selectedThreadRef = useRef(selectedThread);
- selectedThreadRef.current = selectedThread;
- const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject);
- selectedThreadProjectBootstrapRef.current = selectedThreadProject;
- const runningSessionRef = useRef(runningSession);
- runningSessionRef.current = runningSession;
- const terminalBootstrapRef = useRef({
- status: terminal.status,
- bufferLen: terminal.buffer.length,
- });
- terminalBootstrapRef.current = {
- status: terminal.status,
- bufferLen: terminal.buffer.length,
- };
-
useEffect(() => {
- hasOpenedRef.current = false;
- attachStreamLogCountRef.current = 0;
firstNonEmptyBufferLoggedRef.current = false;
+ sentInitialInputKeyRef.current = null;
}, [terminalKey]);
const clearBufferReplayTimer = useCallback(() => {
@@ -638,99 +672,22 @@ export function ThreadTerminalRouteScreen() {
});
}, [fontSize, hasResolvedFontPreference]);
- // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change.
- // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when
- // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup
- // → detach immediately after the first snapshot.
- useEffect(() => {
- if (!hasResolvedFontPreference || !hasMeasuredSurface) {
- return;
- }
-
- const thread = selectedThreadRef.current;
- const project = selectedThreadProjectBootstrapRef.current;
- const running = runningSessionRef.current;
- const termSnap = terminalBootstrapRef.current;
-
- const bootstrapAction = resolveTerminalRouteBootstrap({
- hasThread: thread !== null,
- hasWorkspaceRoot: Boolean(project?.workspaceRoot),
- hasOpened: hasOpenedRef.current,
- requestedTerminalId,
- currentTerminalId: terminalId,
- runningTerminalId: running?.target.terminalId ?? null,
- currentTerminalStatus: termSnap.status,
- // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`;
- // treating summary as "hydrated" skipped attach while status was running → empty surface.
- hasCurrentTerminalHydration: termSnap.bufferLen > 0,
- });
- if (bootstrapAction.kind !== "idle") {
- terminalDebugLog("bootstrap:action", {
- kind: bootstrapAction.kind,
- hasOpenedBefore: hasOpenedRef.current,
- hasHydration: termSnap.bufferLen > 0,
- terminalStatus: termSnap.status,
- bufLen: termSnap.bufferLen,
- });
- }
- if (bootstrapAction.kind === "idle" || !thread) {
- return;
- }
-
- if (bootstrapAction.kind === "redirect") {
- router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId));
- return;
- }
-
- hasOpenedRef.current = true;
- try {
- const detach = attachTerminalRef.current();
- terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) });
- if (!detach) {
- hasOpenedRef.current = false;
- return;
- }
- return () => {
- detach();
- hasOpenedRef.current = false;
- terminalDebugLog("bootstrap:unsubscribe");
- };
- } catch (error) {
- hasOpenedRef.current = false;
- terminalDebugLog("bootstrap:attach-threw", {
- message: error instanceof Error ? error.message : String(error),
- });
- return;
- }
- }, [
- hasMeasuredSurface,
- hasResolvedFontPreference,
- requestedTerminalId,
- router,
- selectedThread?.environmentId,
- selectedThread?.id,
- selectedThreadProject?.workspaceRoot,
- terminalId,
- ]);
-
const writeInput = useCallback(
(data: string) => {
if (!selectedThread || !isRunning) {
return;
}
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return;
- }
-
- void client.terminal.write({
- threadId: selectedThread.id,
- terminalId,
- data,
+ void writeTerminal({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ terminalId,
+ data,
+ },
});
},
- [isRunning, selectedThread, terminalId],
+ [isRunning, selectedThread, terminalId, writeTerminal],
);
const handleInput = useCallback(
@@ -782,16 +739,14 @@ export function ThreadTerminalRouteScreen() {
return;
}
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return;
- }
-
- void client.terminal.resize({
- threadId: selectedThread.id,
- terminalId,
- cols: size.cols,
- rows: size.rows,
+ void resizeTerminal({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ terminalId,
+ cols: size.cols,
+ rows: size.rows,
+ },
});
},
[
@@ -802,6 +757,7 @@ export function ThreadTerminalRouteScreen() {
readyBufferReplayKey,
routeEnvironmentId,
routeThreadId,
+ resizeTerminal,
scheduleBufferReplayReady,
selectedThread,
terminalId,
@@ -855,17 +811,15 @@ export function ThreadTerminalRouteScreen() {
return;
}
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return;
- }
-
setPendingModifierState({ terminalId, value: null });
- void client.terminal.clear({
- threadId: selectedThread.id,
- terminalId,
+ void clearTerminal({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ terminalId,
+ },
});
- }, [selectedThread, terminalId]);
+ }, [clearTerminal, selectedThread, terminalId]);
const handleToolbarActionPress = useCallback(
(action: TerminalToolbarAction) => {
@@ -905,9 +859,14 @@ export function ThreadTerminalRouteScreen() {
const handleShowKeyboard = useCallback(() => {
setKeyboardFocusRequest((current) => current + 1);
}, []);
+ const handleRetryEnvironment = useCallback(() => {
+ if (routeEnvironmentId !== null) {
+ void environmentActions.retryNow(routeEnvironmentId);
+ }
+ }, [environmentActions, routeEnvironmentId]);
if (!selectedThread) {
- if (isLoadingSavedConnection) {
+ if (workspaceState.isLoadingConnections) {
return ;
}
@@ -932,6 +891,10 @@ export function ThreadTerminalRouteScreen() {
);
}
+ if (!environment.isReady && environment.presentation === null) {
+ return ;
+ }
+
return (
<>
-
-
-
- {getTerminalStatusLabel({
- status: terminal.status,
- hasRunningSubprocess: terminal.hasRunningSubprocess,
- })}
-
-
- Text size
-
- {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}
-
+ {isEnvironmentReady ? (
+
+
+
+ {getTerminalStatusLabel({
+ status: terminal.status,
+ hasRunningSubprocess: terminal.hasRunningSubprocess,
+ })}
+
+
+ Text size
+
+ {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}
+
+ = MAX_TERMINAL_FONT_SIZE}
+ discoverabilityLabel="Increase terminal text size"
+ onPress={handleIncreaseFontSize}
+ >
+ {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}
+
+
+ {terminalMenuSessions.map((session) => (
+ handleSelectTerminal(session.terminalId)}
+ subtitle={[
+ getTerminalStatusLabel({ status: session.status }),
+ basename(session.cwd),
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+ >
+ {session.displayLabel}
+
+ ))}
= MAX_TERMINAL_FONT_SIZE}
- discoverabilityLabel="Increase terminal text size"
- onPress={handleIncreaseFontSize}
+ icon="plus"
+ onPress={handleOpenNewTerminal}
+ subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`}
>
- {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}
+ Open new terminal
- {terminalMenuSessions.map((session) => (
- handleSelectTerminal(session.terminalId)}
- subtitle={[getTerminalStatusLabel({ status: session.status }), basename(session.cwd)]
- .filter(Boolean)
- .join(" · ")}
- >
- {session.displayLabel}
-
- ))}
-
- Open new terminal
-
-
-
+
+ ) : null}
-
-
-
+ ) : (
+ <>
+
+
+
- {isAccessoryVisible ? (
-
-
-
-
+
- {terminalToolbarActions.map((action) => {
- const active =
- action.kind === "modifier" && pendingModifier === action.modifier;
-
- return (
- 1 ? 56 : 44}
- onPress={() => handleToolbarActionPress(action)}
- showChevron={false}
- textTransform={
- action.kind === "modifier" || action.kind === "clear"
- ? "uppercase"
- : "none"
- }
- />
- );
- })}
-
-
-
-
-
- ) : !keyboardState.isVisible ? (
- ({
- bottom: 16,
- borderRadius: 28,
- opacity: pressed ? 0.72 : 1,
- position: "absolute",
- right: 16,
- })}
- >
-
-
-
-
- ) : null}
+
+
+ {terminalToolbarActions.map((action) => {
+ const active =
+ action.kind === "modifier" && pendingModifier === action.modifier;
+
+ return (
+ 1 ? 56 : 44}
+ onPress={() => handleToolbarActionPress(action)}
+ showChevron={false}
+ textTransform={
+ action.kind === "modifier" || action.kind === "clear"
+ ? "uppercase"
+ : "none"
+ }
+ />
+ );
+ })}
+
+
+
+
+
+ ) : !keyboardState.isVisible ? (
+ ({
+ bottom: 16,
+ borderRadius: 28,
+ opacity: pressed ? 0.72 : 1,
+ position: "absolute",
+ right: 16,
+ })}
+ >
+
+
+
+
+ ) : null}
+ >
+ )}
>
);
diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts
index 048ce2ac409..48c87e18dd4 100644
--- a/apps/mobile/src/features/terminal/terminalMenu.test.ts
+++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
-import type { KnownTerminalSession } from "@t3tools/client-runtime";
+import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts
index 0e0e80ef5d9..29374bdda6d 100644
--- a/apps/mobile/src/features/terminal/terminalMenu.ts
+++ b/apps/mobile/src/features/terminal/terminalMenu.ts
@@ -1,4 +1,4 @@
-import type { KnownTerminalSession } from "@t3tools/client-runtime";
+import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts";
import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
import * as Arr from "effect/Array";
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index d59275b0843..0749beb271b 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -26,7 +26,7 @@ import { ProviderIcon } from "../../components/ProviderIcon";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
import { buildThreadRoutePath } from "../../lib/routes";
-import { useRemoteCatalog } from "../../state/use-remote-catalog";
+import { useProjects } from "../../state/entities";
import { useNativePaste } from "../../lib/useNativePaste";
import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";
import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
@@ -66,7 +66,7 @@ export function NewTaskDraftScreen(props: {
readonly projectId?: string;
};
}) {
- const { projects } = useRemoteCatalog();
+ const projects = useProjects();
const { onCreateThreadWithOptions } = useProjectActions();
const flow = useNewTaskFlow();
const router = useRouter();
diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index e267ae9a07f..5475ed46745 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -2,7 +2,7 @@ import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass
import type {
EnvironmentId,
ModelSelection,
- OrchestrationThread,
+ OrchestrationThreadShell,
ProviderInteractionMode,
RuntimeMode,
ServerConfig as T3ServerConfig,
@@ -17,6 +17,7 @@ import { TextInputWrapper } from "expo-paste-input";
import type { ReactNode } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
+ ActivityIndicator,
Image,
Pressable,
TextInput as RNTextInput,
@@ -81,7 +82,9 @@ export interface ThreadComposerProps {
readonly placeholder: string;
readonly bottomInset?: number;
readonly connectionState: RemoteClientConnectionState;
- readonly selectedThread: OrchestrationThread;
+ readonly connectionError: string | null;
+ readonly environmentLabel: string | null;
+ readonly selectedThread: OrchestrationThreadShell;
readonly serverConfig: T3ServerConfig | null;
readonly queueCount: number;
readonly activeThreadBusy: boolean;
@@ -92,10 +95,11 @@ export interface ThreadComposerProps {
readonly onNativePasteImages: (uris: ReadonlyArray) => Promise;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => Promise;
- readonly onSendMessage: () => void;
+ readonly onSendMessage: () => Promise;
readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise;
readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => Promise;
readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => Promise;
+ readonly onReconnectEnvironment: () => void;
readonly onExpandedChange?: (expanded: boolean) => void;
}
@@ -154,6 +158,68 @@ function formatTitleCase(value: string): string {
return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}
+function composerConnectionStatus(input: {
+ readonly connectionError: string | null;
+ readonly connectionState: RemoteClientConnectionState;
+ readonly environmentLabel: string | null;
+}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null {
+ const environmentLabel = input.environmentLabel ?? "Environment";
+
+ switch (input.connectionState) {
+ case "connecting":
+ case "reconnecting":
+ return {
+ kind: "reconnecting",
+ label:
+ input.connectionError === null
+ ? `Reconnecting to ${environmentLabel}...`
+ : `Failed to connect. Retrying ${environmentLabel}...`,
+ };
+ case "offline":
+ return { kind: "unavailable", label: "You are offline" };
+ case "error":
+ return {
+ kind: "unavailable",
+ label: input.connectionError
+ ? `Failed to connect to ${environmentLabel}: ${input.connectionError}`
+ : `Failed to connect to ${environmentLabel}`,
+ };
+ case "available":
+ return { kind: "unavailable", label: `${environmentLabel} is not connected` };
+ case "connected":
+ return null;
+ }
+}
+
+const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: {
+ readonly onPress: () => void;
+ readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string };
+}) {
+ const isReconnecting = props.status.kind === "reconnecting";
+
+ return (
+
+
+ {isReconnecting ? (
+
+ ) : (
+
+ )}
+
+ {props.status.label}
+
+
+
+ );
+});
+
export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
const isDarkMode = useColorScheme() === "dark";
const themePlaceholderColor = useThemeColor("--color-placeholder");
@@ -167,7 +233,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const [previewImageUri, setPreviewImageUri] = useState(null);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
const isExpanded = isFocused;
- const canSend = props.connectionState === "ready" && hasContent;
+ const canSend = hasContent;
const onPressImage = useCallback(
(uri: string) => {
@@ -189,13 +255,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}, [isExpanded, onExpandedChange]);
const showStopAction =
props.selectedThread.session?.status === "running" ||
- props.selectedThread.session?.status === "starting" ||
- props.queueCount > 0;
+ props.selectedThread.session?.status === "starting";
- const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send";
+ const sendLabel =
+ props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0
+ ? "Queue"
+ : "Send";
const currentModelSelection = props.selectedThread.modelSelection;
const currentRuntimeMode = props.selectedThread.runtimeMode;
const currentInteractionMode = props.selectedThread.interactionMode ?? "default";
+ const connectionStatus = composerConnectionStatus({
+ connectionError: props.connectionError,
+ connectionState: props.connectionState,
+ environmentLabel: props.environmentLabel,
+ });
const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";
const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)";
const selectedProviderStatus = useMemo(() => {
@@ -394,8 +467,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
const handleSend = useCallback(() => {
- onSendMessage();
- inputRef.current?.blur();
+ void onSendMessage().then(() => {
+ inputRef.current?.blur();
+ });
}, [onSendMessage]);
const handleCommandSelect = useCallback(
(item: ComposerCommandItem) => {
@@ -652,6 +726,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
) : null}
+ {connectionStatus ? (
+
+ ) : null}
+
setIsFocused(true)}
onBlur={() => setIsFocused(false)}
textAlignVertical={isExpanded ? "top" : "center"}
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index 8e6050418fd..6eaf56eb35d 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -1,8 +1,9 @@
+import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import type {
ApprovalRequestId,
EnvironmentId,
ModelSelection,
- OrchestrationThread,
+ OrchestrationThreadShell,
ProviderApprovalDecision,
ProviderInteractionMode,
RuntimeMode,
@@ -21,7 +22,7 @@ import { runOnJS } from "react-native-reanimated";
import { AppText as Text } from "../../components/AppText";
import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
-import type { MobileLayoutVariant } from "../../lib/mobileLayout";
+import type { LayoutVariant } from "../../lib/layout";
import type {
PendingApproval,
PendingUserInput,
@@ -37,11 +38,14 @@ import {
ThreadComposer,
} from "./ThreadComposer";
import { ThreadFeed } from "./ThreadFeed";
+import type { ThreadContentPresentation } from "./threadContentPresentation";
export interface ThreadDetailScreenProps {
- readonly selectedThread: OrchestrationThread;
+ readonly selectedThread: OrchestrationThreadShell;
+ readonly contentPresentation: ThreadContentPresentation;
readonly screenTone: StatusTone;
readonly connectionError: string | null;
+ readonly environmentLabel: string | null;
readonly httpBaseUrl: string | null;
readonly bearerToken: string | null;
readonly selectedThreadFeed: ReadonlyArray;
@@ -54,13 +58,13 @@ export interface ThreadDetailScreenProps {
readonly respondingUserInputId: ApprovalRequestId | null;
readonly draftMessage: string;
readonly draftAttachments: ReadonlyArray;
- readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
+ readonly connectionStateLabel: EnvironmentConnectionPhase;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly selectedThreadQueueCount: number;
readonly serverConfig: T3ServerConfig | null;
- readonly layoutVariant?: MobileLayoutVariant;
+ readonly layoutVariant?: LayoutVariant;
readonly onOpenDrawer: () => void;
readonly onOpenConnectionEditor: () => void;
readonly onChangeDraftMessage: (value: string) => void;
@@ -68,7 +72,8 @@ export interface ThreadDetailScreenProps {
readonly onNativePasteImages: (uris: ReadonlyArray) => Promise;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => Promise;
- readonly onSendMessage: () => void;
+ readonly onSendMessage: () => Promise;
+ readonly onReconnectEnvironment: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise;
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise;
readonly onUpdateThreadInteractionMode: (
@@ -252,6 +257,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
;
+ readonly contentPresentation: ThreadContentPresentation;
readonly httpBaseUrl: string | null;
readonly bearerToken: string | null;
readonly agentLabel: string;
readonly contentBottomInset?: number;
- readonly layoutVariant?: MobileLayoutVariant;
+ readonly layoutVariant?: LayoutVariant;
readonly composerExpanded?: boolean;
}
@@ -821,6 +823,37 @@ function compactFileName(filePath: string): string {
const IOS_NAV_BAR_HEIGHT = 44;
+function ThreadFeedPlaceholder(props: {
+ readonly bottomInset: number;
+ readonly detail: string;
+ readonly horizontalPadding: number;
+ readonly loading?: boolean;
+ readonly title: string;
+ readonly topInset: number;
+}) {
+ return (
+
+
+ {props.loading ? : null}
+ {props.title}
+
+ {props.detail}
+
+
+
+ );
+}
+
export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const listRef = useRef(null);
const copyFeedbackTimeoutRef = useRef | null>(null);
@@ -912,24 +945,40 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
],
);
+ if (props.contentPresentation.kind === "loading") {
+ return (
+
+ );
+ }
+
+ if (props.contentPresentation.kind === "unavailable") {
+ return (
+
+ );
+ }
+
if (props.feed.length === 0) {
return (
-
-
-
+
);
}
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx
index 500594f0ba7..a66f2082171 100644
--- a/apps/mobile/src/features/threads/ThreadGitControls.tsx
+++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx
@@ -9,7 +9,7 @@ import {
type GitActionRequestInput,
requiresDefaultBranchConfirmation,
resolveQuickAction,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/state/vcs";
import { useLocalSearchParams, useRouter } from "expo-router";
import Stack from "expo-router/stack";
import { useCallback, useMemo } from "react";
diff --git a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx
index 84ae71dce5c..77a80fff550 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx
@@ -1,6 +1,13 @@
import { SymbolView } from "expo-symbols";
import { useCallback, useEffect, useMemo, useState } from "react";
-import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
+import {
+ type ColorValue,
+ Modal,
+ Pressable,
+ ScrollView,
+ useWindowDimensions,
+ View,
+} from "react-native";
import * as Arr from "effect/Array";
import * as Order from "effect/Order";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -15,21 +22,19 @@ import { useThemeColor } from "../../lib/useThemeColor";
import { AppText as Text } from "../../components/AppText";
import { StatusPill } from "../../components/StatusPill";
+import { useProjects, useThreadShells } from "../../state/entities";
import { groupProjectsByRepository } from "../../lib/repositoryGroups";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { relativeTime } from "../../lib/time";
import { threadStatusTone } from "./threadPresentation";
-import {
- EnvironmentScopedProjectShell,
- EnvironmentScopedThreadShell,
-} from "@t3tools/client-runtime";
+import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
const threadActivityOrder = Order.mapInput(
Order.Struct({
activityAt: Order.flip(Order.Number),
title: Order.String,
}),
- (thread: EnvironmentScopedThreadShell) => ({
+ (thread: EnvironmentThreadShell) => ({
activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),
title: thread.title,
}),
@@ -37,11 +42,9 @@ const threadActivityOrder = Order.mapInput(
export function ThreadNavigationDrawer(props: {
readonly visible: boolean;
- readonly projects: ReadonlyArray;
- readonly threads: ReadonlyArray;
readonly selectedThreadKey: string | null;
readonly onClose: () => void;
- readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void;
+ readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
readonly onStartNewTask: () => void;
}) {
const insets = useSafeAreaInsets();
@@ -57,26 +60,6 @@ export function ThreadNavigationDrawer(props: {
const primaryForeground = useThemeColor("--color-primary-foreground");
const borderSubtleColor = useThemeColor("--color-border-subtle");
- const repositoryGroups = useMemo(
- () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }),
- [props.projects, props.threads],
- );
- const groupedThreads = useMemo(
- () =>
- repositoryGroups.map((group) => {
- const threads: EnvironmentScopedThreadShell[] = [];
- for (const projectGroup of group.projects) {
- threads.push(...projectGroup.threads);
- }
- return {
- key: group.key,
- title: group.projects[0]?.project.title ?? group.title,
- threads: Arr.sort(threads, threadActivityOrder),
- };
- }),
- [repositoryGroups],
- );
-
useEffect(() => {
if (props.visible) {
setMounted(true);
@@ -186,76 +169,116 @@ export function ThreadNavigationDrawer(props: {
-
- {groupedThreads.map((group) => (
-
-
- {group.title}
-
-
-
- {group.threads.length === 0 ? (
-
-
- No threads yet
-
-
- ) : (
- group.threads.map((thread, index) => {
- const threadKey = scopedThreadKey(thread.environmentId, thread.id);
- const selected = props.selectedThreadKey === threadKey;
-
- return (
- {
- props.onSelectThread(thread);
- props.onClose();
- }}
- style={{
- paddingHorizontal: 16,
- paddingVertical: 15,
- borderTopWidth: index === 0 ? 0 : 1,
- borderTopColor: borderSubtleColor,
- backgroundColor: selected ? undefined : "transparent",
- }}
- className={selected ? "bg-subtle" : undefined}
- >
-
-
-
- {thread.title}
-
-
- {relativeTime(thread.updatedAt ?? thread.createdAt)}
-
-
-
-
-
- );
- })
- )}
-
-
- ))}
-
+
);
}
+
+function ThreadNavigationDrawerContent(props: {
+ readonly bottomInset: number;
+ readonly borderSubtleColor: ColorValue;
+ readonly selectedThreadKey: string | null;
+ readonly onClose: () => void;
+ readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
+}) {
+ const projects = useProjects();
+ const threads = useThreadShells();
+ const repositoryGroups = useMemo(
+ () => groupProjectsByRepository({ projects, threads }),
+ [projects, threads],
+ );
+ const groupedThreads = useMemo(
+ () =>
+ repositoryGroups.map((group) => {
+ const threads: EnvironmentThreadShell[] = [];
+ for (const projectGroup of group.projects) {
+ threads.push(...projectGroup.threads);
+ }
+ return {
+ key: group.key,
+ title: group.projects[0]?.project.title ?? group.title,
+ threads: Arr.sort(threads, threadActivityOrder),
+ };
+ }),
+ [repositoryGroups],
+ );
+
+ return (
+
+ {groupedThreads.map((group) => (
+
+
+ {group.title}
+
+
+
+ {group.threads.length === 0 ? (
+
+
+ No threads yet
+
+
+ ) : (
+ group.threads.map((thread, index) => {
+ const threadKey = scopedThreadKey(thread.environmentId, thread.id);
+ const selected = props.selectedThreadKey === threadKey;
+
+ return (
+ {
+ props.onSelectThread(thread);
+ props.onClose();
+ }}
+ style={{
+ paddingHorizontal: 16,
+ paddingVertical: 15,
+ borderTopWidth: index === 0 ? 0 : 1,
+ borderTopColor: props.borderSubtleColor,
+ backgroundColor: selected ? undefined : "transparent",
+ }}
+ className={selected ? "bg-subtle" : undefined}
+ >
+
+
+
+ {thread.title}
+
+
+ {relativeTime(thread.updatedAt ?? thread.createdAt)}
+
+
+
+
+
+ );
+ })
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index fd73a45b0c8..2e6baa04778 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -1,14 +1,14 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useMemo, useState } from "react";
-import * as Arr from "effect/Array";
import * as Option from "effect/Option";
-import { pipe } from "effect/Function";
import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
+import { useWorkspaceState } from "../../state/workspace";
import { useThemeColor } from "../../lib/useThemeColor";
-import { useVcsStatus } from "../../state/use-vcs-status";
+import { useEnvironmentQuery } from "../../state/query";
import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
+import { vcsEnvironment } from "../../state/vcs";
import { EmptyState } from "../../components/EmptyState";
import { LoadingScreen } from "../../components/LoadingScreen";
@@ -16,13 +16,13 @@ import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/r
import { scopedThreadKey } from "../../lib/scopedEntities";
import { connectionTone } from "../connection/connectionTone";
-import { useRemoteCatalog } from "../../state/use-remote-catalog";
import {
+ useRemoteConnections,
useRemoteConnectionStatus,
- useRemoteEnvironmentState,
+ useRemoteEnvironmentRuntime,
} from "../../state/use-remote-environment-registry";
import { useKnownTerminalSessions } from "../../state/use-terminal-session";
-import { useSelectedThreadDetail } from "../../state/use-thread-detail";
+import { useSelectedThreadDetailState } from "../../state/use-thread-detail";
import { useThreadSelection } from "../../state/use-thread-selection";
import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
import {
@@ -44,6 +44,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useThreadComposerState } from "../../state/use-thread-composer-state";
+import { projectThreadContentPresentation } from "./threadContentPresentation";
function firstRouteParam(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
@@ -58,14 +59,13 @@ function OpeningThreadLoadingScreen() {
}
export function ThreadRouteScreen() {
- const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } =
- useRemoteEnvironmentState();
- const { connectionState, connectionError: aggregateConnectionError } =
- useRemoteConnectionStatus();
- const { projects, threads } = useRemoteCatalog();
+ const { state: workspaceState } = useWorkspaceState();
+ const { connectionState } = useRemoteConnectionStatus();
+ const { onReconnectEnvironment } = useRemoteConnections();
const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
useThreadSelection();
- const selectedThreadDetail = useSelectedThreadDetail();
+ const selectedThreadDetailState = useSelectedThreadDetailState();
+ const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
const { selectedThreadCwd } = useSelectedThreadWorktree();
const composer = useThreadComposerState();
const gitState = useSelectedThreadGitState();
@@ -83,12 +83,10 @@ export function ThreadRouteScreen() {
const environmentIdRaw = firstRouteParam(params.environmentId);
const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
const threadId = firstRouteParam(params.threadId);
- const routeEnvironmentRuntime = environmentId
- ? (environmentStateById[environmentId] ?? null)
- : null;
- const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? connectionState;
- const routeConnectionError =
- pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError;
+ const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
+ const routeConnectionState =
+ routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
+ const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null;
/* ─── Native header theming ──────────────────────────────────────── */
const isDark = useColorScheme() === "dark";
@@ -97,10 +95,14 @@ export function ThreadRouteScreen() {
const secondaryFg = isDark ? "#a3a3a3" : "#525252";
/* ─── Git status for native header trigger ───────────────────────── */
- const gitStatus = useVcsStatus({
- environmentId: selectedThread?.environmentId ?? null,
- cwd: selectedThreadCwd,
- });
+ const gitStatus = useEnvironmentQuery(
+ selectedThread !== null && selectedThreadCwd !== null
+ ? vcsEnvironment.status({
+ environmentId: selectedThread.environmentId,
+ input: { cwd: selectedThreadCwd },
+ })
+ : null,
+ );
const knownTerminalSessions = useKnownTerminalSessions({
environmentId: selectedThread?.environmentId ?? null,
threadId: selectedThread?.id ?? null,
@@ -114,6 +116,12 @@ export function ThreadRouteScreen() {
[knownTerminalSessions, selectedThreadProject?.workspaceRoot],
);
const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null;
+ const handleReconnectEnvironment = useCallback(() => {
+ if (!environmentId) {
+ return;
+ }
+ onReconnectEnvironment(environmentId);
+ }, [environmentId, onReconnectEnvironment]);
/* ─── Git action progress (for overlay banner) ──────────────────── */
const gitActionProgressTarget = useMemo(
@@ -239,7 +247,7 @@ export function ThreadRouteScreen() {
if (!selectedThread) {
const stillHydrating =
- isLoadingSavedConnection ||
+ workspaceState.isLoadingConnections ||
routeConnectionState === "connecting" ||
routeConnectionState === "reconnecting";
@@ -266,19 +274,14 @@ export function ThreadRouteScreen() {
);
}
- if (!selectedThreadDetail) {
- return ;
- }
-
const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);
- const serverConfig =
- routeEnvironmentRuntime?.serverConfig ??
- pipe(
- Object.values(environmentStateById),
- Arr.map((runtime) => runtime.serverConfig),
- Arr.findFirst((value) => value !== null),
- Option.getOrNull,
- );
+ const contentPresentation = projectThreadContentPresentation({
+ hasDetail: selectedThreadDetail !== null,
+ detailError: Option.getOrNull(selectedThreadDetailState.error),
+ detailDeleted: selectedThreadDetailState.status === "deleted",
+ connectionState: routeConnectionState,
+ });
+ const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
const headerSubtitle = [
selectedThreadProject?.title ?? null,
@@ -314,7 +317,7 @@ export function ThreadRouteScreen() {
letterSpacing: -0.4,
}}
>
- {selectedThreadDetail.title}
+ {selectedThread.title}
setDrawerVisible(false)}
onSelectThread={(thread) => {
diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx
index a6b29fbe431..3fbea89ba32 100644
--- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx
+++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx
@@ -6,11 +6,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../../lib/useThemeColor";
import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
-import { useVcsStatus } from "../../../state/use-vcs-status";
+import { useEnvironmentQuery } from "../../../state/query";
import { useThreadSelection } from "../../../state/use-thread-selection";
import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
+import { vcsEnvironment } from "../../../state/vcs";
import { SheetActionButton } from "./gitSheetComponents";
export function GitBranchesSheet() {
@@ -27,10 +28,14 @@ export function GitBranchesSheet() {
const foregroundColor = useThemeColor("--color-foreground");
const subtleStrongColor = useThemeColor("--color-subtle-strong");
- const gitStatus = useVcsStatus({
- environmentId: selectedThread?.environmentId ?? null,
- cwd: selectedThreadCwd,
- });
+ const gitStatus = useEnvironmentQuery(
+ selectedThread !== null && selectedThreadCwd !== null
+ ? vcsEnvironment.status({
+ environmentId: selectedThread.environmentId,
+ input: { cwd: selectedThreadCwd },
+ })
+ : null,
+ );
const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
const currentWorktreePath = selectedThreadWorktreePath;
diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx
index 478e2642035..9e20f5b1560 100644
--- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx
+++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx
@@ -5,11 +5,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../../lib/useThemeColor";
import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
-import { useVcsStatus } from "../../../state/use-vcs-status";
+import { useEnvironmentQuery } from "../../../state/query";
import { useThreadSelection } from "../../../state/use-thread-selection";
import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
+import { vcsEnvironment } from "../../../state/vcs";
import { SheetActionButton } from "./gitSheetComponents";
export function GitCommitSheet() {
@@ -27,10 +28,14 @@ export function GitCommitSheet() {
const inputBg = useThemeColor("--color-input");
const foregroundColor = useThemeColor("--color-foreground");
- const gitStatus = useVcsStatus({
- environmentId: selectedThread?.environmentId ?? null,
- cwd: selectedThreadCwd,
- });
+ const gitStatus = useEnvironmentQuery(
+ selectedThread !== null && selectedThreadCwd !== null
+ ? vcsEnvironment.status({
+ environmentId: selectedThread.environmentId,
+ input: { cwd: selectedThreadCwd },
+ })
+ : null,
+ );
const busy = gitState.gitOperationLabel !== null;
const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;
diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx
index 65e0488622e..3d196715284 100644
--- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx
+++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx
@@ -1,4 +1,4 @@
-import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime";
+import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/state/vcs";
import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
import * as Arr from "effect/Array";
import * as Result from "effect/Result";
diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
index a940fcdfcc3..314d0cfcd20 100644
--- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
+++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
@@ -3,7 +3,7 @@ import {
buildMenuItems,
getGitActionDisabledReason,
requiresDefaultBranchConfirmation,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/state/vcs";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useLocalSearchParams, useRouter } from "expo-router";
import { SymbolView } from "expo-symbols";
@@ -14,11 +14,12 @@ import { useThemeColor } from "../../../lib/useThemeColor";
import { AppText as Text } from "../../../components/AppText";
import { buildThreadReviewRoutePath } from "../../../lib/routes";
-import { useVcsStatus } from "../../../state/use-vcs-status";
+import { useEnvironmentQuery } from "../../../state/query";
import { useThreadSelection } from "../../../state/use-thread-selection";
import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
+import { vcsEnvironment } from "../../../state/vcs";
import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
export function GitOverviewSheet() {
@@ -36,10 +37,14 @@ export function GitOverviewSheet() {
const iconColor = useThemeColor("--color-icon");
const borderColor = useThemeColor("--color-border");
- const gitStatus = useVcsStatus({
- environmentId: selectedThread?.environmentId ?? null,
- cwd: selectedThreadCwd,
- });
+ const gitStatus = useEnvironmentQuery(
+ selectedThread !== null && selectedThreadCwd !== null
+ ? vcsEnvironment.status({
+ environmentId: selectedThread.environmentId,
+ input: { cwd: selectedThreadCwd },
+ })
+ : null,
+ );
const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
const currentWorktreePath = selectedThreadWorktreePath;
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 1de8eaa688e..5729eef36e3 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -10,6 +10,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tool
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
+import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
@@ -22,21 +23,18 @@ import {
setComposerDraftText,
useComposerDraft,
} from "../../state/use-composer-drafts";
-import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs";
-import { useRemoteCatalog } from "../../state/use-remote-catalog";
+import { useBranches } from "../../state/queries";
import {
setPendingConnectionError,
- useRemoteEnvironmentState,
+ useSavedRemoteConnections,
} from "../../state/use-remote-environment-registry";
-import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime";
+import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
+import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
import type { ClaudeAgentEffort } from "./claudeEffortOptions";
type WorkspaceMode = "local" | "worktree";
-function normalizeSelectedWorktreePath(
- project: EnvironmentScopedProjectShell,
- branch: VcsRef,
-): string | null {
+function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null {
if (!branch.worktreePath) {
return null;
}
@@ -46,7 +44,7 @@ function normalizeSelectedWorktreePath(
export function branchBadgeLabel(input: {
readonly branch: VcsRef;
- readonly project: EnvironmentScopedProjectShell | null;
+ readonly project: EnvironmentProject | null;
}): string | null {
if (input.branch.current) {
return "current";
@@ -66,7 +64,7 @@ export function branchBadgeLabel(input: {
type NewTaskFlowContextValue = {
readonly logicalProjects: ReadonlyArray<{
readonly key: string;
- readonly project: EnvironmentScopedProjectShell;
+ readonly project: EnvironmentProject;
}>;
readonly selectedEnvironmentId: EnvironmentId | null;
readonly selectedProjectKey: string | null;
@@ -90,14 +88,14 @@ type NewTaskFlowContextValue = {
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
}>;
- readonly selectedProject: EnvironmentScopedProjectShell | null;
+ readonly selectedProject: EnvironmentProject | null;
readonly modelOptions: ReadonlyArray;
readonly selectedModel: ModelSelection | null;
readonly selectedModelOption: ModelOption | null;
readonly providerGroups: ReadonlyArray;
readonly filteredBranches: ReadonlyArray;
readonly reset: () => void;
- readonly setProject: (project: EnvironmentScopedProjectShell) => void;
+ readonly setProject: (project: EnvironmentProject) => void;
readonly selectEnvironment: (environmentId: EnvironmentId) => void;
readonly setSelectedModelKey: (key: string | null) => void;
readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
@@ -121,8 +119,9 @@ type NewTaskFlowContextValue = {
const NewTaskFlowContext = React.createContext(null);
export function NewTaskFlowProvider(props: React.PropsWithChildren) {
- const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog();
- const { savedConnectionsById } = useRemoteEnvironmentState();
+ const projects = useProjects();
+ const threads = useThreadShells();
+ const { savedConnectionsById } = useSavedRemoteConnections();
const repositoryGroups = useMemo(
() => groupProjectsByRepository({ projects, threads }),
@@ -144,7 +143,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
entry,
): entry is {
readonly key: string;
- readonly project: EnvironmentScopedProjectShell;
+ readonly project: EnvironmentProject;
} => entry !== null,
),
),
@@ -252,6 +251,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
) ??
projectsForEnvironment[0] ??
null;
+ const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
+ selectedProject?.environmentId ?? null,
+ );
const selectedProjectDraftKey = selectedProject
? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
: null;
@@ -262,12 +264,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const modelOptions = useMemo(
() =>
buildModelOptions(
- selectedProject
- ? (serverConfigByEnvironmentId[selectedProject.environmentId] ?? null)
- : null,
+ selectedEnvironmentServerConfig,
selectedProject?.defaultModelSelection ?? null,
),
- [selectedProject, serverConfigByEnvironmentId],
+ [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection],
);
const selectedModel =
@@ -335,7 +335,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
}),
[selectedProject?.environmentId, selectedProject?.workspaceRoot],
);
- const branchState = useVcsRefs(branchTarget);
+ const branchState = useBranches(branchTarget);
const branchesLoading = branchState.isPending;
const availableBranches = useMemo(
() =>
@@ -358,7 +358,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
);
}, [availableBranches, branchQuery]);
- const setProject = useCallback((project: EnvironmentScopedProjectShell) => {
+ const setProject = useCallback((project: EnvironmentProject) => {
const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
branchLoadVersionRef.current += 1;
setSelectedEnvironmentId(project.environmentId);
@@ -392,37 +392,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const loadVersion = ++branchLoadVersionRef.current;
const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id);
- try {
- const result = await vcsRefManager.load({
- environmentId: selectedProject.environmentId,
- cwd: selectedProject.workspaceRoot,
- query: null,
- });
- if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) {
- return;
- }
- setPendingConnectionError(null);
- const branches = pipe(
- result?.refs ?? [],
- Arr.filter((branch) => !branch.isRemote),
- );
-
- if (workspaceMode === "worktree" && !selectedBranchName) {
- const preferredBranch =
- branches.find((branch) => branch.current)?.name ??
- branches.find((branch) => branch.isDefault)?.name ??
- null;
- if (preferredBranch) {
- setSelectedBranchName(preferredBranch);
- }
- }
- } catch {
- if (loadVersion !== branchLoadVersionRef.current) {
- return;
+ branchState.refresh();
+ if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) {
+ return;
+ }
+ setPendingConnectionError(null);
+ if (workspaceMode === "worktree" && !selectedBranchName) {
+ const preferredBranch =
+ availableBranches.find((branch) => branch.current)?.name ??
+ availableBranches.find((branch) => branch.isDefault)?.name ??
+ null;
+ if (preferredBranch) {
+ setSelectedBranchName(preferredBranch);
}
- setPendingConnectionError("Failed to load branches.");
}
- }, [selectedBranchName, selectedProject, selectedProjectKey, workspaceMode]);
+ }, [
+ availableBranches,
+ branchState,
+ selectedBranchName,
+ selectedProject,
+ selectedProjectKey,
+ workspaceMode,
+ ]);
const value = useMemo(
() => ({
diff --git a/apps/mobile/src/features/threads/threadContentPresentation.test.ts b/apps/mobile/src/features/threads/threadContentPresentation.test.ts
new file mode 100644
index 00000000000..f179e756fbf
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadContentPresentation.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import { projectThreadContentPresentation } from "./threadContentPresentation";
+
+describe("thread content presentation", () => {
+ it("renders cached detail while its environment reconnects", () => {
+ expect(
+ projectThreadContentPresentation({
+ hasDetail: true,
+ detailError: null,
+ detailDeleted: false,
+ connectionState: "reconnecting",
+ }),
+ ).toEqual({ kind: "ready" });
+ });
+
+ it("loads missing detail inside the thread screen when connected", () => {
+ expect(
+ projectThreadContentPresentation({
+ hasDetail: false,
+ detailError: null,
+ detailDeleted: false,
+ connectionState: "connected",
+ }),
+ ).toEqual({ kind: "loading" });
+ });
+
+ it("explains uncached detail while disconnected instead of loading forever", () => {
+ expect(
+ projectThreadContentPresentation({
+ hasDetail: false,
+ detailError: null,
+ detailDeleted: false,
+ connectionState: "error",
+ }),
+ ).toEqual({
+ kind: "unavailable",
+ title: "Messages not cached",
+ detail: "Reconnect this environment to load the conversation.",
+ });
+ });
+
+ it("surfaces detail errors before presenting a loading state", () => {
+ expect(
+ projectThreadContentPresentation({
+ hasDetail: false,
+ detailError: "The thread stream failed.",
+ detailDeleted: false,
+ connectionState: "connected",
+ }),
+ ).toEqual({
+ kind: "unavailable",
+ title: "Could not load conversation",
+ detail: "The thread stream failed.",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadContentPresentation.ts b/apps/mobile/src/features/threads/threadContentPresentation.ts
new file mode 100644
index 00000000000..c806e6dfc46
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadContentPresentation.ts
@@ -0,0 +1,43 @@
+import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+
+export type ThreadContentPresentation =
+ | { readonly kind: "ready" }
+ | { readonly kind: "loading" }
+ | {
+ readonly kind: "unavailable";
+ readonly title: string;
+ readonly detail: string;
+ };
+
+export function projectThreadContentPresentation(input: {
+ readonly hasDetail: boolean;
+ readonly detailError: string | null;
+ readonly detailDeleted: boolean;
+ readonly connectionState: EnvironmentConnectionPhase;
+}): ThreadContentPresentation {
+ if (input.hasDetail) {
+ return { kind: "ready" };
+ }
+ if (input.detailDeleted) {
+ return {
+ kind: "unavailable",
+ title: "Thread unavailable",
+ detail: "This thread was deleted or is no longer available.",
+ };
+ }
+ if (input.detailError !== null) {
+ return {
+ kind: "unavailable",
+ title: "Could not load conversation",
+ detail: input.detailError,
+ };
+ }
+ if (input.connectionState === "connected") {
+ return { kind: "loading" };
+ }
+ return {
+ kind: "unavailable",
+ title: "Messages not cached",
+ detail: "Reconnect this environment to load the conversation.",
+ };
+}
diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts
index 4253cedbc7e..9a1bc67c27c 100644
--- a/apps/mobile/src/features/threads/threadPresentation.ts
+++ b/apps/mobile/src/features/threads/threadPresentation.ts
@@ -1,12 +1,12 @@
import type { StatusTone } from "../../components/StatusPill";
-import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
+import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
-export function threadSortValue(thread: EnvironmentScopedThreadShell): number {
+export function threadSortValue(thread: EnvironmentThreadShell): number {
const candidate = Date.parse(thread.updatedAt ?? thread.createdAt);
return Number.isNaN(candidate) ? 0 : candidate;
}
-export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTone {
+export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone {
const status = thread.session?.status;
if (status === "running") {
return {
diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts
index 029e1bbdcf6..1b66ac2e250 100644
--- a/apps/mobile/src/features/threads/use-project-actions.ts
+++ b/apps/mobile/src/features/threads/use-project-actions.ts
@@ -1,67 +1,25 @@
+import { useAtomSet } from "@effect/atom-react";
import { useCallback } from "react";
-import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime";
+import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import {
- CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
- type EnvironmentId,
+ CommandId,
MessageId,
ThreadId,
type ModelSelection,
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
-import { buildTemporaryWorktreeBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
-import { uuidv4 } from "../../lib/uuid";
+import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
+import { threadEnvironment } from "../../state/threads";
+import { useThreadShells } from "../../state/entities";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
-import { getEnvironmentClient } from "../../state/environment-session-registry";
-import { environmentRuntimeManager } from "../../state/use-environment-runtime";
-import { vcsRefManager } from "../../state/use-vcs-refs";
-import { useRemoteCatalog } from "../../state/use-remote-catalog";
-import {
- setPendingConnectionError,
- useRemoteEnvironmentState,
-} from "../../state/use-remote-environment-registry";
-
-function useRefreshRemoteData() {
- const { savedConnectionsById } = useRemoteEnvironmentState();
-
- return useCallback(
- async (environmentIds?: ReadonlyArray) => {
- const targets =
- environmentIds ??
- Object.values(savedConnectionsById).map((connection) => connection.environmentId);
-
- await Promise.all(
- targets.map(async (environmentId) => {
- const client = getEnvironmentClient(environmentId);
- if (!client) {
- return;
- }
-
- try {
- const serverConfig = await client.server.getConfig();
- environmentRuntimeManager.patch({ environmentId }, (current) => ({
- ...current,
- serverConfig,
- connectionError: null,
- }));
- } catch (error) {
- environmentRuntimeManager.patch({ environmentId }, (current) => ({
- ...current,
- connectionError:
- error instanceof Error ? error.message : "Failed to refresh remote data.",
- }));
- }
- }),
- );
- },
- [savedConnectionsById],
- );
-}
+import { uuidv4 } from "../../lib/uuid";
+import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
function deriveThreadTitleFromPrompt(value: string): string {
const trimmed = value.trim();
@@ -74,12 +32,12 @@ function deriveThreadTitleFromPrompt(value: string): string {
}
export function useProjectActions() {
- const { threads } = useRemoteCatalog();
- const refreshRemoteData = useRefreshRemoteData();
+ const startTurn = useAtomSet(threadEnvironment.startTurn, { mode: "promise" });
+ const threads = useThreadShells();
const onCreateThreadWithOptions = useCallback(
async (input: {
- readonly project: EnvironmentScopedProjectShell;
+ readonly project: EnvironmentProject;
readonly modelSelection: ModelSelection;
readonly envMode: "local" | "worktree";
readonly branch: string | null;
@@ -89,14 +47,8 @@ export function useProjectActions() {
readonly initialMessageText: string;
readonly initialAttachments: ReadonlyArray;
}) => {
- const client = getEnvironmentClient(input.project.environmentId);
- if (!client) {
- return null;
- }
-
const metadata = makeTurnCommandMetadata();
const threadId = ThreadId.make(metadata.threadId);
- const createdAt = metadata.createdAt;
const initialMessageText = input.initialMessageText.trim();
const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);
@@ -108,57 +60,57 @@ export function useProjectActions() {
}
const isWorktree = input.envMode === "worktree";
-
- await client.orchestration.dispatchCommand({
- type: "thread.turn.start",
- commandId: CommandId.make(metadata.commandId),
- threadId,
- message: {
- messageId: MessageId.make(metadata.messageId),
- role: "user",
- text: initialMessageText,
- attachments: input.initialAttachments,
- },
- modelSelection: input.modelSelection,
- titleSeed: nextTitle,
- runtimeMode: input.runtimeMode,
- interactionMode: input.interactionMode,
- bootstrap: {
- createThread: {
- projectId: input.project.id,
- title: nextTitle,
- modelSelection: input.modelSelection,
- runtimeMode: input.runtimeMode,
- interactionMode: input.interactionMode,
- branch: input.branch,
- worktreePath: isWorktree ? null : input.worktreePath,
- createdAt,
+ await startTurn({
+ environmentId: input.project.environmentId,
+ input: {
+ commandId: CommandId.make(metadata.commandId),
+ threadId,
+ message: {
+ messageId: MessageId.make(metadata.messageId),
+ role: "user",
+ text: initialMessageText,
+ attachments: input.initialAttachments,
+ },
+ modelSelection: input.modelSelection,
+ titleSeed: nextTitle,
+ runtimeMode: input.runtimeMode,
+ interactionMode: input.interactionMode,
+ bootstrap: {
+ createThread: {
+ projectId: input.project.id,
+ title: nextTitle,
+ modelSelection: input.modelSelection,
+ runtimeMode: input.runtimeMode,
+ interactionMode: input.interactionMode,
+ branch: input.branch,
+ worktreePath: isWorktree ? null : input.worktreePath,
+ createdAt: metadata.createdAt,
+ },
+ ...(isWorktree
+ ? {
+ prepareWorktree: {
+ projectCwd: input.project.workspaceRoot,
+ baseBranch: input.branch!,
+ branch: buildTemporaryWorktreeBranchName(uuidv4),
+ },
+ runSetupScript: true,
+ }
+ : {}),
},
- ...(isWorktree
- ? {
- prepareWorktree: {
- projectCwd: input.project.workspaceRoot,
- baseBranch: input.branch!,
- branch: buildTemporaryWorktreeBranchName(uuidv4),
- },
- runSetupScript: true,
- }
- : {}),
+ createdAt: metadata.createdAt,
},
- createdAt,
});
- await refreshRemoteData([input.project.environmentId]);
return {
environmentId: input.project.environmentId,
threadId,
};
},
- [refreshRemoteData],
+ [startTurn],
);
const onCreateThread = useCallback(
- async (project: EnvironmentScopedProjectShell) => {
+ async (project: EnvironmentProject) => {
const latestProjectThread =
threads.find(
(thread) =>
@@ -186,77 +138,8 @@ export function useProjectActions() {
[onCreateThreadWithOptions, threads],
);
- const onListProjectBranches = useCallback(
- async (project: EnvironmentScopedProjectShell): Promise> => {
- const client = getEnvironmentClient(project.environmentId);
- if (!client) {
- return [];
- }
-
- try {
- const result = await vcsRefManager.load(
- { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null },
- client.vcs,
- { limit: 100 },
- );
- return (result?.refs ?? []).filter((branch) => !branch.isRemote);
- } catch (error) {
- setPendingConnectionError(
- error instanceof Error ? error.message : "Failed to load branches.",
- );
- return [];
- }
- },
- [],
- );
-
- const onCreateProjectWorktree = useCallback(
- async (
- project: EnvironmentScopedProjectShell,
- nextWorktree: {
- readonly baseBranch: string;
- readonly newBranch: string;
- },
- ): Promise<{
- readonly branch: string;
- readonly worktreePath: string;
- } | null> => {
- const client = getEnvironmentClient(project.environmentId);
- if (!client) {
- return null;
- }
-
- try {
- const result = await client.vcs.createWorktree({
- cwd: project.workspaceRoot,
- refName: nextWorktree.baseBranch,
- newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch),
- path: null,
- });
- vcsRefManager.invalidate({
- environmentId: project.environmentId,
- cwd: project.workspaceRoot,
- query: null,
- });
- return {
- branch: result.worktree.refName,
- worktreePath: result.worktree.path,
- };
- } catch (error) {
- setPendingConnectionError(
- error instanceof Error ? error.message : "Failed to create worktree.",
- );
- return null;
- }
- },
- [],
- );
-
return {
onCreateThread,
onCreateThreadWithOptions,
- onListProjectBranches,
- onCreateProjectWorktree,
- onRefreshProjects: refreshRemoteData,
};
}
diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts
index b341c7b6bd4..09897b6186e 100644
--- a/apps/mobile/src/lib/authClientMetadata.ts
+++ b/apps/mobile/src/lib/authClientMetadata.ts
@@ -1,7 +1,7 @@
import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
import { Platform } from "react-native";
-export function mobileAuthClientMetadata(): AuthClientPresentationMetadata {
+export function authClientMetadata(): AuthClientPresentationMetadata {
return {
label: "T3 Code Mobile",
deviceType: "mobile",
diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts
index 68813b0b3b1..f1f30b298b6 100644
--- a/apps/mobile/src/lib/connection.test.ts
+++ b/apps/mobile/src/lib/connection.test.ts
@@ -3,13 +3,13 @@ import { EnvironmentId } from "@t3tools/contracts";
import {
isRelayManagedConnection,
- mobileAuthClientMetadata,
+ authClientMetadata,
redactPairingCredential,
toStableSavedRemoteConnection,
} from "./connection";
vi.mock("./runtime", () => ({
- mobileRuntime: {
+ runtime: {
runPromise: vi.fn(),
},
}));
@@ -22,7 +22,7 @@ vi.mock("react-native", () => ({
describe("mobile remote connection records", () => {
it("identifies mobile token exchanges for authorized-client presentation", () => {
- expect(mobileAuthClientMetadata()).toEqual({
+ expect(authClientMetadata()).toEqual({
label: "T3 Code Mobile",
deviceType: "mobile",
os: "iOS",
diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts
index aa92c6f5d58..839bc70e6d9 100644
--- a/apps/mobile/src/lib/connection.ts
+++ b/apps/mobile/src/lib/connection.ts
@@ -1,18 +1,8 @@
import { EnvironmentId } from "@t3tools/contracts";
-import {
- bootstrapRemoteBearerSession,
- fetchRemoteEnvironmentDescriptor,
-} from "@t3tools/client-runtime";
-import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";
-import * as Effect from "effect/Effect";
-import { mobileAuthClientMetadata } from "./authClientMetadata";
-import { mobileRuntime } from "./runtime";
+import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
+import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
-export { mobileAuthClientMetadata } from "./authClientMetadata";
-
-export interface RemoteConnectionInput {
- readonly pairingUrl: string;
-}
+export { authClientMetadata } from "./authClientMetadata";
export interface SavedRemoteConnection {
readonly environmentId: EnvironmentId;
@@ -27,12 +17,7 @@ export interface SavedRemoteConnection {
readonly relayManaged?: true;
}
-export type RemoteClientConnectionState =
- | "idle"
- | "connecting"
- | "ready"
- | "reconnecting"
- | "disconnected";
+export type RemoteClientConnectionState = EnvironmentConnectionPhase;
export function redactPairingCredential(pairingUrl: string): string {
const trimmed = pairingUrl.trim();
@@ -59,38 +44,3 @@ export function toStableSavedRemoteConnection(
const { dpopAccessToken: _, ...stableConnection } = connection;
return stableConnection;
}
-
-export async function bootstrapRemoteConnection(
- input: RemoteConnectionInput,
-): Promise {
- const target = resolveRemotePairingTarget({
- pairingUrl: input.pairingUrl,
- });
-
- const { descriptor, bootstrap } = await mobileRuntime.runPromise(
- Effect.all(
- {
- descriptor: fetchRemoteEnvironmentDescriptor({
- httpBaseUrl: target.httpBaseUrl,
- }),
- bootstrap: bootstrapRemoteBearerSession({
- httpBaseUrl: target.httpBaseUrl,
- credential: target.credential,
- clientMetadata: mobileAuthClientMetadata(),
- }),
- },
- { concurrency: "unbounded" },
- ),
- );
-
- return {
- environmentId: descriptor.environmentId,
- environmentLabel: descriptor.label,
- pairingUrl: redactPairingCredential(input.pairingUrl),
- displayUrl: target.httpBaseUrl,
- httpBaseUrl: target.httpBaseUrl,
- wsBaseUrl: target.wsBaseUrl,
- bearerToken: bootstrap.access_token,
- authenticationMethod: "bearer",
- };
-}
diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts
new file mode 100644
index 00000000000..d15a3a1a59b
--- /dev/null
+++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts
@@ -0,0 +1,34 @@
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const mocks = vi.hoisted(() => ({
+ impactAsync: vi.fn(),
+ setStringAsync: vi.fn(),
+}));
+
+vi.mock("expo-clipboard", () => ({
+ setStringAsync: mocks.setStringAsync,
+}));
+
+vi.mock("expo-haptics", () => ({
+ ImpactFeedbackStyle: {
+ Light: "light",
+ },
+ impactAsync: mocks.impactAsync,
+}));
+
+import { copyTextWithHaptic } from "./copyTextWithHaptic";
+
+describe("copyTextWithHaptic", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.setStringAsync.mockReturnValue(new Promise(() => undefined));
+ mocks.impactAsync.mockResolvedValue(undefined);
+ });
+
+ it("triggers haptic feedback without waiting for the clipboard promise", () => {
+ copyTextWithHaptic("trace-123");
+
+ expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123");
+ expect(mocks.impactAsync).toHaveBeenCalledWith("light");
+ });
+});
diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts
new file mode 100644
index 00000000000..80f725f5b00
--- /dev/null
+++ b/apps/mobile/src/lib/copyTextWithHaptic.ts
@@ -0,0 +1,7 @@
+import * as Clipboard from "expo-clipboard";
+import * as Haptics from "expo-haptics";
+
+export function copyTextWithHaptic(value: string): void {
+ void Clipboard.setStringAsync(value);
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+}
diff --git a/apps/mobile/src/lib/mobileLayout.ts b/apps/mobile/src/lib/layout.ts
similarity index 74%
rename from apps/mobile/src/lib/mobileLayout.ts
rename to apps/mobile/src/lib/layout.ts
index 0ae284e463f..2ae4314fdba 100644
--- a/apps/mobile/src/lib/mobileLayout.ts
+++ b/apps/mobile/src/lib/layout.ts
@@ -2,19 +2,16 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
-export type MobileLayoutVariant = "compact" | "split";
+export type LayoutVariant = "compact" | "split";
-export interface MobileLayout {
- readonly variant: MobileLayoutVariant;
+export interface Layout {
+ readonly variant: LayoutVariant;
readonly usesSplitView: boolean;
readonly listPaneWidth: number | null;
readonly shellPadding: number;
}
-export function deriveMobileLayout(input: {
- readonly width: number;
- readonly height: number;
-}): MobileLayout {
+export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout {
const { width, height } = input;
const shortestEdge = Math.min(width, height);
const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700);
diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts
index 191afe03c18..8cea5df2307 100644
--- a/apps/mobile/src/lib/repositoryGroups.test.ts
+++ b/apps/mobile/src/lib/repositoryGroups.test.ts
@@ -3,15 +3,11 @@ import { describe, expect, it } from "vite-plus/test";
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import { groupProjectsByRepository } from "./repositoryGroups";
-import {
- EnvironmentScopedProjectShell,
- EnvironmentScopedThreadShell,
-} from "@t3tools/client-runtime";
+import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
function makeProject(
- input: Partial &
- Pick,
-): EnvironmentScopedProjectShell {
+ input: Partial & Pick,
+): EnvironmentProject {
return {
workspaceRoot: `/workspaces/${input.id}`,
repositoryIdentity: null,
@@ -24,12 +20,9 @@ function makeProject(
}
function makeThread(
- input: Partial &
- Pick<
- EnvironmentScopedThreadShell,
- "environmentId" | "id" | "projectId" | "title" | "modelSelection"
- >,
-): EnvironmentScopedThreadShell {
+ input: Partial &
+ Pick,
+): EnvironmentThreadShell {
return {
runtimeMode: "full-access",
interactionMode: "default",
diff --git a/apps/mobile/src/lib/repositoryGroups.ts b/apps/mobile/src/lib/repositoryGroups.ts
index 5238411a643..bf4c2f3fccd 100644
--- a/apps/mobile/src/lib/repositoryGroups.ts
+++ b/apps/mobile/src/lib/repositoryGroups.ts
@@ -3,21 +3,18 @@ import * as Arr from "effect/Array";
import type { RepositoryIdentity } from "@t3tools/contracts";
import { scopedProjectKey } from "./scopedEntities";
-import {
- EnvironmentScopedProjectShell,
- EnvironmentScopedThreadShell,
-} from "@t3tools/client-runtime";
+import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
const DateDescending = Order.flip(Order.Date);
-export interface MobileRepositoryProjectGroup {
+export interface RepositoryProjectGroup {
readonly key: string;
- readonly project: EnvironmentScopedProjectShell;
- readonly threads: ReadonlyArray;
+ readonly project: EnvironmentProject;
+ readonly threads: ReadonlyArray;
readonly latestActivityAt: string;
}
-export interface MobileRepositoryGroup {
+export interface RepositoryGroup {
readonly key: string;
readonly title: string;
readonly subtitle: string | null;
@@ -25,20 +22,20 @@ export interface MobileRepositoryGroup {
readonly projectCount: number;
readonly threadCount: number;
readonly latestActivityAt: string;
- readonly projects: ReadonlyArray;
+ readonly projects: ReadonlyArray;
}
function compareIsoDateDescending(left: string, right: string): number {
return new Date(right).getTime() - new Date(left).getTime();
}
-function deriveRepositoryGroupKey(project: EnvironmentScopedProjectShell): string {
+function deriveRepositoryGroupKey(project: EnvironmentProject): string {
return (
project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id)
);
}
-function deriveRepositoryTitle(project: EnvironmentScopedProjectShell): string {
+function deriveRepositoryTitle(project: EnvironmentProject): string {
const identity = project.repositoryIdentity;
return identity?.displayName ?? identity?.name ?? project.title;
}
@@ -54,18 +51,18 @@ function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefine
}
function deriveProjectLatestActivity(
- project: EnvironmentScopedProjectShell,
- threads: ReadonlyArray,
+ project: EnvironmentProject,
+ threads: ReadonlyArray,
): string {
const latestThread = threads[0];
return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt;
}
export function groupProjectsByRepository(input: {
- readonly projects: ReadonlyArray;
- readonly threads: ReadonlyArray;
-}): ReadonlyArray {
- const threadsByProjectKey = new Map();
+ readonly projects: ReadonlyArray;
+ readonly threads: ReadonlyArray;
+}): ReadonlyArray {
+ const threadsByProjectKey = new Map();
for (const thread of input.threads) {
const key = scopedProjectKey(thread.environmentId, thread.projectId);
@@ -77,7 +74,7 @@ export function groupProjectsByRepository(input: {
}
}
- const grouped = new Map();
+ const grouped = new Map();
for (const project of input.projects) {
const key = deriveRepositoryGroupKey(project);
@@ -89,7 +86,7 @@ export function groupProjectsByRepository(input: {
);
const latestActivityAt = deriveProjectLatestActivity(project, threads);
- const projectGroup: MobileRepositoryProjectGroup = {
+ const projectGroup: RepositoryProjectGroup = {
key: projectKey,
project,
threads,
diff --git a/apps/mobile/src/lib/routes.ts b/apps/mobile/src/lib/routes.ts
index bf49a20ac41..56d5663212c 100644
--- a/apps/mobile/src/lib/routes.ts
+++ b/apps/mobile/src/lib/routes.ts
@@ -1,5 +1,5 @@
import type { Href, useRouter } from "expo-router";
-import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
+import { type EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { SelectedThreadRef } from "../state/remote-runtime-types";
@@ -8,7 +8,7 @@ type Router = ReturnType;
type ThreadRouteInput =
| Pick
- | Pick;
+ | Pick;
type PlainThreadRouteInput =
| {
environmentId: EnvironmentId;
diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts
index ce37a41e8ab..bb8c1e8398a 100644
--- a/apps/mobile/src/lib/runtime.ts
+++ b/apps/mobile/src/lib/runtime.ts
@@ -1,25 +1,29 @@
import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
+import * as Socket from "effect/unstable/socket/Socket";
-import { remoteHttpClientLayer } from "@t3tools/client-runtime";
+import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
-import { mobileCryptoLayer } from "../features/cloud/dpop";
-import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer";
+import { cryptoLayer } from "../features/cloud/dpop";
+import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer";
import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
-import { mobileTracingLayer } from "../features/observability/mobileTracing";
+import { tracingLayer } from "../features/observability/tracing";
function configuredRelayUrl(): string {
return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid";
}
-const mobileHttpClientLayer = remoteHttpClientLayer(fetch);
+const httpClientLayer = remoteHttpClientLayer(fetch);
-export const mobileRuntime = ManagedRuntime.make(
- mobileManagedRelayClientLayer(configuredRelayUrl()).pipe(
- Layer.provideMerge(mobileCryptoLayer),
- Layer.provideMerge(mobileHttpClientLayer),
- Layer.provideMerge(mobileTracingLayer.pipe(Layer.provide(mobileHttpClientLayer))),
- ),
+export const runtimeLayer = Layer.merge(
+ managedRelayClientLayer(configuredRelayUrl()),
+ Socket.layerWebSocketConstructorGlobal,
+).pipe(
+ Layer.provideMerge(cryptoLayer),
+ Layer.provideMerge(httpClientLayer),
+ Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))),
);
-export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect);
+export const runtime = ManagedRuntime.make(runtimeLayer);
+
+export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect);
diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts
index 83ff2db5748..c3dd28ac3a1 100644
--- a/apps/mobile/src/lib/storage.test.ts
+++ b/apps/mobile/src/lib/storage.test.ts
@@ -25,7 +25,7 @@ vi.mock("react-native", () => ({
}));
vi.mock("./runtime", () => ({
- mobileRuntime: {
+ runtime: {
runPromise: vi.fn(),
},
}));
diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts
index 2f9e4962c1a..da54f92949b 100644
--- a/apps/mobile/src/lib/storage.ts
+++ b/apps/mobile/src/lib/storage.ts
@@ -1,9 +1,7 @@
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
-import * as Option from "effect/Option";
-import * as Schema from "effect/Schema";
import * as SecureStore from "expo-secure-store";
-import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";
+import { EnvironmentId } from "@t3tools/contracts";
import {
isRelayManagedConnection,
@@ -14,29 +12,12 @@ import {
const CONNECTIONS_KEY = "t3code.connections";
const PREFERENCES_KEY = "t3code.preferences";
const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
-const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
-const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
-
-export interface CachedShellSnapshot {
- readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION;
- readonly environmentId: EnvironmentId;
- readonly snapshotReceivedAt: string;
- readonly snapshot: OrchestrationShellSnapshot;
-}
-export interface MobilePreferences {
+export interface Preferences {
readonly liveActivitiesEnabled?: boolean;
readonly terminalFontSize?: number;
}
-const CachedShellSnapshotSchema = Schema.Struct({
- schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
- environmentId: EnvironmentId,
- snapshotReceivedAt: Schema.String,
- snapshot: OrchestrationShellSnapshot,
-});
-const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema);
-
async function readStorageItem(key: string): Promise {
return await SecureStore.getItemAsync(key);
}
@@ -58,77 +39,6 @@ async function readJsonStorageItem(key: string): Promise {
}
}
-function cachedShellSnapshotFileName(environmentId: EnvironmentId): string {
- return `${encodeURIComponent(environmentId)}.json`;
-}
-
-async function getShellSnapshotCacheDirectory() {
- const { Directory, Paths } = await import("expo-file-system");
- const directory = new Directory(Paths.document, SHELL_SNAPSHOT_CACHE_DIRECTORY);
- directory.create({ idempotent: true, intermediates: true });
- return directory;
-}
-
-export async function loadCachedShellSnapshot(
- environmentId: EnvironmentId,
-): Promise {
- try {
- const { File } = await import("expo-file-system");
- const directory = await getShellSnapshotCacheDirectory();
- const file = new File(directory, cachedShellSnapshotFileName(environmentId));
- if (!file.exists) {
- return null;
- }
-
- const parsed = JSON.parse(await file.text()) as unknown;
- const decoded = decodeCachedShellSnapshot(parsed);
- if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) {
- return null;
- }
-
- return decoded.value;
- } catch {
- return null;
- }
-}
-
-export async function saveCachedShellSnapshot(
- environmentId: EnvironmentId,
- snapshot: OrchestrationShellSnapshot,
-): Promise {
- try {
- const { File } = await import("expo-file-system");
- const directory = await getShellSnapshotCacheDirectory();
- const file = new File(directory, cachedShellSnapshotFileName(environmentId));
- const document: CachedShellSnapshot = {
- schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
- environmentId,
- snapshotReceivedAt: new Date().toISOString(),
- snapshot,
- };
-
- if (!file.exists) {
- file.create({ intermediates: true, overwrite: true });
- }
- file.write(JSON.stringify(document));
- } catch {
- // Cache persistence is best-effort and should never block live data.
- }
-}
-
-export async function clearCachedShellSnapshot(environmentId: EnvironmentId): Promise {
- try {
- const { File } = await import("expo-file-system");
- const directory = await getShellSnapshotCacheDirectory();
- const file = new File(directory, cachedShellSnapshotFileName(environmentId));
- if (file.exists) {
- file.delete();
- }
- } catch {
- // Ignore cache cleanup failures.
- }
-}
-
export async function loadSavedConnections(): Promise> {
const parsed = await readJsonStorageItem<{
readonly connections?: ReadonlyArray;
@@ -169,8 +79,8 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis
await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
}
-export async function loadPreferences(): Promise {
- const parsed = await readJsonStorageItem(PREFERENCES_KEY);
+export async function loadPreferences(): Promise {
+ const parsed = await readJsonStorageItem(PREFERENCES_KEY);
if (!parsed || typeof parsed !== "object") {
return {};
}
@@ -190,11 +100,9 @@ export async function loadPreferences(): Promise {
return preferences;
}
-export async function savePreferencesPatch(
- patch: Partial,
-): Promise {
+export async function savePreferencesPatch(patch: Partial): Promise {
const current = await loadPreferences();
- const next: MobilePreferences = {
+ const next: Preferences = {
...current,
...patch,
};
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 6ff27cadfee..489d9b22687 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -1,17 +1,14 @@
import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";
import type {
- CommandId,
- EnvironmentId,
MessageId,
OrchestrationThread,
OrchestrationThreadActivity,
- TurnId,
ToolLifecycleItemType,
- ThreadId,
+ TurnId,
UserInputQuestion,
} from "@t3tools/contracts";
-import type { DraftComposerImageAttachment } from "./composerImages";
+import type { QueuedThreadMessage } from "../state/thread-outbox";
import * as Arr from "effect/Array";
import * as Order from "effect/Order";
@@ -33,16 +30,6 @@ export interface PendingUserInputDraftAnswer {
readonly customAnswer?: string;
}
-export interface QueuedThreadMessage {
- readonly environmentId: EnvironmentId;
- readonly threadId: ThreadId;
- readonly messageId: MessageId;
- readonly commandId: CommandId;
- readonly text: string;
- readonly attachments: ReadonlyArray;
- readonly createdAt: string;
-}
-
export interface ThreadFeedActivity {
readonly id: string;
readonly createdAt: string;
diff --git a/apps/mobile/src/state/auth.ts b/apps/mobile/src/state/auth.ts
new file mode 100644
index 00000000000..835dee7f783
--- /dev/null
+++ b/apps/mobile/src/state/auth.ts
@@ -0,0 +1,5 @@
+import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/cloud.ts b/apps/mobile/src/state/cloud.ts
new file mode 100644
index 00000000000..a11fa1cb2e6
--- /dev/null
+++ b/apps/mobile/src/state/cloud.ts
@@ -0,0 +1,5 @@
+import { createCloudEnvironmentAtoms } from "@t3tools/client-runtime/state/cloud";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const cloudEnvironment = createCloudEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts
new file mode 100644
index 00000000000..9eec5dc1250
--- /dev/null
+++ b/apps/mobile/src/state/entities.ts
@@ -0,0 +1,59 @@
+import { useAtomValue } from "@effect/atom-react";
+import type {
+ EnvironmentProject,
+ EnvironmentThreadShell,
+} from "@t3tools/client-runtime/state/shell";
+import type {
+ EnvironmentId,
+ ScopedProjectRef,
+ ScopedThreadRef,
+ ServerConfig,
+} from "@t3tools/contracts";
+import { Atom } from "effect/unstable/reactivity";
+
+import { environmentProjects } from "./projects";
+import { environmentServerConfigsAtom } from "./server";
+import { environmentSession } from "./session";
+import { environmentThreadShells } from "./threads";
+
+const EMPTY_PROJECT_ATOM = Atom.make(null).pipe(
+ Atom.withLabel("mobile-project:empty"),
+);
+const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe(
+ Atom.withLabel("mobile-thread-shell:empty"),
+);
+const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe(
+ Atom.withLabel("mobile-server-config:empty"),
+);
+
+export function useProjects(): ReadonlyArray {
+ return useAtomValue(environmentProjects.projectsAtom);
+}
+
+export function useThreadShells(): ReadonlyArray {
+ return useAtomValue(environmentThreadShells.threadShellsAtom);
+}
+
+export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null {
+ return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref));
+}
+
+export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null {
+ return useAtomValue(
+ ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref),
+ );
+}
+
+export function useEnvironmentServerConfig(
+ environmentId: EnvironmentId | null,
+): ServerConfig | null {
+ return useAtomValue(
+ environmentId === null
+ ? EMPTY_SERVER_CONFIG_ATOM
+ : environmentSession.configValueAtom(environmentId),
+ );
+}
+
+export function useServerConfigs(): ReadonlyMap {
+ return useAtomValue(environmentServerConfigsAtom);
+}
diff --git a/apps/mobile/src/state/environment-session-registry.ts b/apps/mobile/src/state/environment-session-registry.ts
deleted file mode 100644
index 3eb94b32c06..00000000000
--- a/apps/mobile/src/state/environment-session-registry.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { EnvironmentId } from "@t3tools/contracts";
-
-import type { EnvironmentSession } from "./remote-runtime-types";
-
-const environmentSessions = new Map();
-const environmentConnectionListeners = new Set<() => void>();
-
-export function getEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null {
- return environmentSessions.get(environmentId) ?? null;
-}
-
-export function getEnvironmentClient(environmentId: EnvironmentId) {
- return getEnvironmentSession(environmentId)?.client ?? null;
-}
-
-export function setEnvironmentSession(
- environmentId: EnvironmentId,
- session: EnvironmentSession,
-): void {
- environmentSessions.set(environmentId, session);
-}
-
-export function removeEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null {
- const session = getEnvironmentSession(environmentId);
- environmentSessions.delete(environmentId);
- return session;
-}
-
-export function drainEnvironmentSessions(): ReadonlyArray {
- const sessions = [...environmentSessions.values()];
- environmentSessions.clear();
- return sessions;
-}
-
-export function notifyEnvironmentConnectionListeners() {
- for (const listener of environmentConnectionListeners) listener();
-}
-
-/**
- * Subscribe to environment-connection changes (connect / disconnect / reconnect).
- * Returns an unsubscribe function.
- */
-export function subscribeEnvironmentConnections(listener: () => void): () => void {
- environmentConnectionListeners.add(listener);
- return () => {
- environmentConnectionListeners.delete(listener);
- };
-}
diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts
new file mode 100644
index 00000000000..ece9c4c2e55
--- /dev/null
+++ b/apps/mobile/src/state/environments.ts
@@ -0,0 +1,105 @@
+import { useAtomSet, useAtomValue } from "@effect/atom-react";
+import {
+ connectionCatalogDisplayUrl,
+ type EnvironmentPresentation as BaseEnvironmentPresentation,
+} from "@t3tools/client-runtime/connection";
+import {
+ RelayConnectionRegistration,
+ RelayConnectionTarget,
+} from "@t3tools/client-runtime/connection";
+import type { EnvironmentId } from "@t3tools/contracts";
+import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
+import { useCallback, useMemo } from "react";
+
+import { environmentCatalog } from "../connection/catalog";
+import { connectPairingUrl as connectPairingUrlAtom } from "../connection/onboarding";
+import { environmentPresentations } from "./presentation";
+import { useEnvironmentQuery } from "./query";
+import { relayEnvironmentDiscovery } from "./relay";
+
+export interface EnvironmentPresentation extends BaseEnvironmentPresentation {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly displayUrl: string | null;
+ readonly relayManaged: boolean;
+}
+
+export function projectEnvironmentPresentation(
+ environmentId: EnvironmentId,
+ presentation: BaseEnvironmentPresentation,
+): EnvironmentPresentation {
+ return {
+ ...presentation,
+ environmentId,
+ label: presentation.entry.target.label,
+ displayUrl: connectionCatalogDisplayUrl(presentation.entry),
+ relayManaged: presentation.entry.target._tag === "RelayConnectionTarget",
+ };
+}
+
+export function useEnvironments() {
+ const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
+ const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom);
+ const presentationById = useAtomValue(environmentPresentations.presentationsAtom);
+
+ const environments = useMemo(
+ () =>
+ [...presentationById.entries()].map(([environmentId, presentation]) =>
+ projectEnvironmentPresentation(environmentId, presentation),
+ ),
+ [presentationById],
+ );
+
+ return {
+ isReady: catalog.isReady,
+ networkStatus,
+ environments,
+ presentationById,
+ };
+}
+
+export function useEnvironmentConnectionState(environmentId: EnvironmentId) {
+ return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId));
+}
+
+export function useEnvironmentConnectionActions() {
+ return {
+ register: useAtomSet(environmentCatalog.register, { mode: "promise" }),
+ remove: useAtomSet(environmentCatalog.remove, { mode: "promise" }),
+ removeRelayEnvironments: useAtomSet(environmentCatalog.removeRelayEnvironments, {
+ mode: "promise",
+ }),
+ retryNow: useAtomSet(environmentCatalog.retryNow, { mode: "promise" }),
+ };
+}
+
+export function useEnvironmentActions() {
+ const connectPairingUrl = useAtomSet(connectPairingUrlAtom, {
+ mode: "promise",
+ });
+ const { register, remove, retryNow } = useEnvironmentConnectionActions();
+ const refreshRelayEnvironments = useAtomSet(relayEnvironmentDiscovery.refresh, {
+ mode: "promise",
+ });
+
+ const connectRelayEnvironment = useCallback(
+ (environment: RelayClientEnvironmentRecord) =>
+ register(
+ new RelayConnectionRegistration({
+ target: new RelayConnectionTarget({
+ environmentId: environment.environmentId,
+ label: environment.label,
+ }),
+ }),
+ ),
+ [register],
+ );
+
+ return {
+ connectPairingUrl,
+ connectRelayEnvironment,
+ removeEnvironment: remove,
+ retryEnvironment: retryNow,
+ refreshRelayEnvironments,
+ };
+}
diff --git a/apps/mobile/src/state/filesystem.ts b/apps/mobile/src/state/filesystem.ts
new file mode 100644
index 00000000000..19d5b53c4e0
--- /dev/null
+++ b/apps/mobile/src/state/filesystem.ts
@@ -0,0 +1,5 @@
+import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/git.ts b/apps/mobile/src/state/git.ts
new file mode 100644
index 00000000000..66bb3dc0bde
--- /dev/null
+++ b/apps/mobile/src/state/git.ts
@@ -0,0 +1,5 @@
+import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/orchestration.ts b/apps/mobile/src/state/orchestration.ts
new file mode 100644
index 00000000000..8c6e1738857
--- /dev/null
+++ b/apps/mobile/src/state/orchestration.ts
@@ -0,0 +1,5 @@
+import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/presentation.ts b/apps/mobile/src/state/presentation.ts
new file mode 100644
index 00000000000..83d1fdce462
--- /dev/null
+++ b/apps/mobile/src/state/presentation.ts
@@ -0,0 +1,31 @@
+import { useAtomValue } from "@effect/atom-react";
+import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection";
+import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation";
+import type { EnvironmentId } from "@t3tools/contracts";
+import { Atom } from "effect/unstable/reactivity";
+
+import { environmentCatalog } from "../connection/catalog";
+import { environmentSession } from "./session";
+
+export const environmentPresentations = createEnvironmentPresentationAtoms({
+ catalogValueAtom: environmentCatalog.catalogValueAtom,
+ stateAtom: environmentCatalog.stateAtom,
+ configValueAtom: environmentSession.configValueAtom,
+});
+
+const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe(
+ Atom.withLabel("mobile-environment-presentation:empty"),
+);
+
+export function useEnvironmentPresentation(environmentId: EnvironmentId | null) {
+ const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
+ const presentation = useAtomValue(
+ environmentId === null
+ ? EMPTY_ENVIRONMENT_PRESENTATION_ATOM
+ : environmentPresentations.presentationAtom(environmentId),
+ );
+ return {
+ isReady: catalog.isReady,
+ presentation,
+ };
+}
diff --git a/apps/mobile/src/state/projects.ts b/apps/mobile/src/state/projects.ts
new file mode 100644
index 00000000000..7a879988328
--- /dev/null
+++ b/apps/mobile/src/state/projects.ts
@@ -0,0 +1,12 @@
+import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects";
+import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects";
+
+import { environmentCatalog } from "../connection/catalog";
+import { connectionAtomRuntime } from "../connection/runtime";
+import { environmentSnapshotAtom } from "./shell";
+
+export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime);
+export const environmentProjects = createEnvironmentProjectAtoms({
+ catalogValueAtom: environmentCatalog.catalogValueAtom,
+ snapshotAtom: environmentSnapshotAtom,
+});
diff --git a/apps/mobile/src/state/queries.test.ts b/apps/mobile/src/state/queries.test.ts
new file mode 100644
index 00000000000..68c23202308
--- /dev/null
+++ b/apps/mobile/src/state/queries.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "@effect/vitest";
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+
+import { buildCheckpointDiffTargets, normalizeComposerPathSearchQuery } from "./queryTargets";
+
+describe("appQueries", () => {
+ it("normalizes composer path search input", () => {
+ expect(normalizeComposerPathSearchQuery(" src/app ")).toBe("src/app");
+ expect(normalizeComposerPathSearchQuery(null)).toBe("");
+ });
+
+ it("routes the first turn range through the full-thread diff query", () => {
+ const environmentId = EnvironmentId.make("environment-a");
+ const threadId = ThreadId.make("thread-a");
+
+ expect(
+ buildCheckpointDiffTargets({
+ environmentId,
+ threadId,
+ fromTurnCount: 0,
+ toTurnCount: 4,
+ ignoreWhitespace: true,
+ }),
+ ).toEqual({
+ fullThread: {
+ environmentId,
+ input: {
+ threadId,
+ toTurnCount: 4,
+ ignoreWhitespace: true,
+ },
+ },
+ turn: null,
+ });
+ });
+
+ it("routes later ranges through the incremental turn diff query", () => {
+ const environmentId = EnvironmentId.make("environment-a");
+ const threadId = ThreadId.make("thread-a");
+
+ expect(
+ buildCheckpointDiffTargets({
+ environmentId,
+ threadId,
+ fromTurnCount: 3,
+ toTurnCount: 4,
+ ignoreWhitespace: false,
+ }),
+ ).toEqual({
+ fullThread: null,
+ turn: {
+ environmentId,
+ input: {
+ threadId,
+ fromTurnCount: 3,
+ toTurnCount: 4,
+ ignoreWhitespace: false,
+ },
+ },
+ });
+ });
+});
diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts
new file mode 100644
index 00000000000..ea625995928
--- /dev/null
+++ b/apps/mobile/src/state/queries.ts
@@ -0,0 +1,134 @@
+import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { useEffect, useMemo, useState } from "react";
+
+import { orchestrationEnvironment } from "./orchestration";
+import { projectEnvironment } from "./projects";
+import { useEnvironmentQuery } from "./query";
+import { useEnvironmentThread } from "./threads";
+import { vcsEnvironment } from "./vcs";
+import {
+ buildCheckpointDiffTargets,
+ normalizeComposerPathSearchQuery,
+ type CheckpointDiffTarget,
+} from "./queryTargets";
+
+const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;
+const COMPOSER_PATH_SEARCH_LIMIT = 20;
+const VCS_REF_LIST_LIMIT = 100;
+
+export interface ThreadDetailView {
+ readonly data: OrchestrationThread | null;
+ readonly error: string | null;
+ readonly isPending: boolean;
+ readonly isDeleted: boolean;
+}
+
+export interface ComposerPathSearchTarget {
+ readonly environmentId: EnvironmentId | null;
+ readonly cwd: string | null;
+ readonly query: string | null;
+}
+
+function useDebouncedValue(value: A, delayMs: number): A {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebounced(value);
+ }, delayMs);
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [delayMs, value]);
+
+ return debounced;
+}
+
+export function useThreadDetail(
+ environmentId: EnvironmentId | null,
+ threadId: ThreadId | null,
+): ThreadDetailView {
+ const state = useEnvironmentThread(environmentId, threadId);
+ return {
+ data: Option.getOrNull(state.data),
+ error: Option.getOrNull(state.error),
+ isPending: state.status === "synchronizing",
+ isDeleted: state.status === "deleted",
+ };
+}
+
+export function useBranches(input: {
+ readonly environmentId: EnvironmentId | null;
+ readonly cwd: string | null;
+ readonly query?: string | null;
+}) {
+ const query = input.query?.trim() ?? "";
+ return useEnvironmentQuery(
+ input.environmentId !== null && input.cwd !== null
+ ? vcsEnvironment.listRefs({
+ environmentId: input.environmentId,
+ input: {
+ cwd: input.cwd,
+ ...(query.length > 0 ? { query } : {}),
+ limit: VCS_REF_LIST_LIMIT,
+ },
+ })
+ : null,
+ );
+}
+
+export function useComposerPathSearch(target: ComposerPathSearchTarget) {
+ const normalizedTarget = useMemo(
+ () => ({
+ environmentId: target.environmentId,
+ cwd: target.cwd,
+ query: normalizeComposerPathSearchQuery(target.query),
+ }),
+ [target.cwd, target.environmentId, target.query],
+ );
+ const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS);
+ const result = useEnvironmentQuery(
+ debouncedTarget.environmentId !== null &&
+ debouncedTarget.cwd !== null &&
+ debouncedTarget.query.length > 0
+ ? projectEnvironment.searchEntries({
+ environmentId: debouncedTarget.environmentId,
+ input: {
+ cwd: debouncedTarget.cwd,
+ query: debouncedTarget.query,
+ limit: COMPOSER_PATH_SEARCH_LIMIT,
+ },
+ })
+ : null,
+ );
+
+ return {
+ entries: result.data?.entries ?? [],
+ error: result.error,
+ isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending,
+ refresh: result.refresh,
+ };
+}
+
+export function useCheckpointDiff(target: CheckpointDiffTarget) {
+ const targets = useMemo(
+ () => buildCheckpointDiffTargets(target),
+ [
+ target.environmentId,
+ target.fromTurnCount,
+ target.ignoreWhitespace,
+ target.threadId,
+ target.toTurnCount,
+ ],
+ );
+ const fullThread = useEnvironmentQuery(
+ targets.fullThread === null
+ ? null
+ : orchestrationEnvironment.fullThreadDiff(targets.fullThread),
+ );
+ const turn = useEnvironmentQuery(
+ targets.turn === null ? null : orchestrationEnvironment.turnDiff(targets.turn),
+ );
+ return targets.fullThread === null ? turn : fullThread;
+}
diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts
new file mode 100644
index 00000000000..c29d01d397b
--- /dev/null
+++ b/apps/mobile/src/state/query.ts
@@ -0,0 +1,36 @@
+import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
+import * as Cause from "effect/Cause";
+import * as Option from "effect/Option";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
+
+const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe(
+ Atom.withLabel("mobile-environment-query:empty"),
+);
+
+export interface EnvironmentQueryView {
+ readonly data: A | null;
+ readonly error: string | null;
+ readonly isPending: boolean;
+ readonly refresh: () => void;
+}
+
+function formatError(cause: Cause.Cause): string {
+ const error = Cause.squash(cause);
+ return error instanceof Error && error.message.trim().length > 0
+ ? error.message
+ : "The environment request failed.";
+}
+
+export function useEnvironmentQuery(
+ atom: Atom.Atom> | null,
+): EnvironmentQueryView {
+ const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM;
+ const result = useAtomValue(selectedAtom);
+ const refresh = useAtomRefresh(selectedAtom);
+ return {
+ data: Option.getOrNull(AsyncResult.value(result)),
+ error: result._tag === "Failure" ? formatError(result.cause) : null,
+ isPending: atom !== null && result.waiting,
+ refresh,
+ };
+}
diff --git a/apps/mobile/src/state/queryTargets.ts b/apps/mobile/src/state/queryTargets.ts
new file mode 100644
index 00000000000..a52da3fc134
--- /dev/null
+++ b/apps/mobile/src/state/queryTargets.ts
@@ -0,0 +1,51 @@
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+
+export interface CheckpointDiffTarget {
+ readonly environmentId: EnvironmentId | null;
+ readonly threadId: ThreadId | null;
+ readonly fromTurnCount: number | null;
+ readonly toTurnCount: number | null;
+ readonly ignoreWhitespace: boolean;
+}
+
+export function normalizeComposerPathSearchQuery(query: string | null): string {
+ return query?.trim() ?? "";
+}
+
+export function buildCheckpointDiffTargets(target: CheckpointDiffTarget) {
+ if (
+ target.environmentId === null ||
+ target.threadId === null ||
+ target.fromTurnCount === null ||
+ target.toTurnCount === null
+ ) {
+ return { fullThread: null, turn: null } as const;
+ }
+
+ if (target.fromTurnCount === 0) {
+ return {
+ fullThread: {
+ environmentId: target.environmentId,
+ input: {
+ threadId: target.threadId,
+ toTurnCount: target.toTurnCount,
+ ignoreWhitespace: target.ignoreWhitespace,
+ },
+ },
+ turn: null,
+ } as const;
+ }
+
+ return {
+ fullThread: null,
+ turn: {
+ environmentId: target.environmentId,
+ input: {
+ threadId: target.threadId,
+ fromTurnCount: target.fromTurnCount,
+ toTurnCount: target.toTurnCount,
+ ignoreWhitespace: target.ignoreWhitespace,
+ },
+ },
+ } as const;
+}
diff --git a/apps/mobile/src/state/relay.ts b/apps/mobile/src/state/relay.ts
new file mode 100644
index 00000000000..f078572736b
--- /dev/null
+++ b/apps/mobile/src/state/relay.ts
@@ -0,0 +1,6 @@
+import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const relayEnvironmentDiscovery =
+ createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts
index 054203715bd..89abd3c222e 100644
--- a/apps/mobile/src/state/remote-runtime-types.ts
+++ b/apps/mobile/src/state/remote-runtime-types.ts
@@ -1,27 +1,24 @@
-import type {
- EnvironmentConnection,
- EnvironmentConnectionState,
- WsRpcClient,
-} from "@t3tools/client-runtime";
-import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+import { EnvironmentId, ThreadId, type ServerConfig } from "@t3tools/contracts";
-export type { EnvironmentRuntimeState } from "@t3tools/client-runtime";
+export interface EnvironmentRuntimeState {
+ readonly connectionState: EnvironmentConnectionPhase;
+ readonly connectionError: string | null;
+ readonly connectionErrorTraceId: string | null;
+ readonly serverConfig: ServerConfig | null;
+}
export interface ConnectedEnvironmentSummary {
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
readonly displayUrl: string;
readonly isRelayManaged: boolean;
- readonly connectionState: EnvironmentConnectionState;
+ readonly connectionState: EnvironmentConnectionPhase;
readonly connectionError: string | null;
+ readonly connectionErrorTraceId: string | null;
}
export interface SelectedThreadRef {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
}
-
-export interface EnvironmentSession {
- readonly client: WsRpcClient;
- readonly connection: EnvironmentConnection;
-}
diff --git a/apps/mobile/src/state/review.ts b/apps/mobile/src/state/review.ts
new file mode 100644
index 00000000000..e4289d1f1d5
--- /dev/null
+++ b/apps/mobile/src/state/review.ts
@@ -0,0 +1,5 @@
+import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts
new file mode 100644
index 00000000000..920c36bac8d
--- /dev/null
+++ b/apps/mobile/src/state/server.ts
@@ -0,0 +1,12 @@
+import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
+import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";
+
+import { environmentCatalog } from "../connection/catalog";
+import { connectionAtomRuntime } from "../connection/runtime";
+import { environmentSession } from "./session";
+
+export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime);
+export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
+ catalogValueAtom: environmentCatalog.catalogValueAtom,
+ configValueAtom: environmentSession.configValueAtom,
+});
diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts
new file mode 100644
index 00000000000..5b23f48f6cc
--- /dev/null
+++ b/apps/mobile/src/state/session.ts
@@ -0,0 +1,26 @@
+import { useAtomValue } from "@effect/atom-react";
+import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
+import type { EnvironmentId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { Atom } from "effect/unstable/reactivity";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+import { useEnvironmentQuery } from "./query";
+
+export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime);
+
+const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(
+ Atom.withLabel("mobile-prepared-connection:empty"),
+);
+
+export function useEnvironmentConfig(environmentId: EnvironmentId) {
+ return useEnvironmentQuery(environmentSession.configAtom(environmentId));
+}
+
+export function usePreparedConnection(environmentId: EnvironmentId | null) {
+ return useAtomValue(
+ environmentId === null
+ ? EMPTY_PREPARED_CONNECTION_ATOM
+ : environmentSession.preparedConnectionValueAtom(environmentId),
+ );
+}
diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts
new file mode 100644
index 00000000000..e879dd25e29
--- /dev/null
+++ b/apps/mobile/src/state/shell.ts
@@ -0,0 +1,17 @@
+import {
+ createEnvironmentShellAtoms,
+ createEnvironmentShellSummaryAtom,
+ createEnvironmentSnapshotAtom,
+ createShellEnvironmentAtoms,
+} from "@t3tools/client-runtime/state/shell";
+
+import { environmentCatalog } from "../connection/catalog";
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime);
+export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime);
+export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom);
+export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({
+ catalogValueAtom: environmentCatalog.catalogValueAtom,
+ shellStateValueAtom: environmentShell.stateValueAtom,
+});
diff --git a/apps/mobile/src/state/sourceControl.ts b/apps/mobile/src/state/sourceControl.ts
new file mode 100644
index 00000000000..aa6255f85ff
--- /dev/null
+++ b/apps/mobile/src/state/sourceControl.ts
@@ -0,0 +1,5 @@
+import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/terminal.ts b/apps/mobile/src/state/terminal.ts
new file mode 100644
index 00000000000..920267c33d5
--- /dev/null
+++ b/apps/mobile/src/state/terminal.ts
@@ -0,0 +1,5 @@
+import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts
new file mode 100644
index 00000000000..ce50e2addfa
--- /dev/null
+++ b/apps/mobile/src/state/thread-outbox.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "@effect/vitest";
+import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
+
+import {
+ decodeQueuedThreadMessage,
+ groupQueuedThreadMessages,
+ threadOutboxRetryDelayMs,
+ type QueuedThreadMessage,
+} from "./thread-outbox";
+
+function queuedMessage(input: {
+ readonly environmentId?: string;
+ readonly threadId?: string;
+ readonly messageId: string;
+ readonly createdAt: string;
+}): QueuedThreadMessage {
+ return {
+ environmentId: EnvironmentId.make(input.environmentId ?? "environment-1"),
+ threadId: ThreadId.make(input.threadId ?? "thread-1"),
+ messageId: MessageId.make(input.messageId),
+ commandId: CommandId.make(`command-${input.messageId}`),
+ text: input.messageId,
+ attachments: [],
+ createdAt: input.createdAt,
+ };
+}
+
+describe("thread outbox", () => {
+ it("groups messages by scoped thread and preserves creation order", () => {
+ const later = queuedMessage({
+ messageId: "message-2",
+ createdAt: "2026-06-08T10:00:02.000Z",
+ });
+ const earlier = queuedMessage({
+ messageId: "message-1",
+ createdAt: "2026-06-08T10:00:01.000Z",
+ });
+
+ expect(groupQueuedThreadMessages([later, earlier])).toEqual({
+ "environment-1:thread-1": [earlier, later],
+ });
+ });
+
+ it("decodes the persisted schema and rejects incomplete messages", () => {
+ const message = queuedMessage({
+ messageId: "message-1",
+ createdAt: "2026-06-08T10:00:01.000Z",
+ });
+
+ expect(
+ decodeQueuedThreadMessage({
+ schemaVersion: 1,
+ ...message,
+ }),
+ ).toEqual(message);
+ expect(() =>
+ decodeQueuedThreadMessage({
+ schemaVersion: 1,
+ environmentId: "environment-1",
+ }),
+ ).toThrow();
+ });
+
+ it("backs off queued delivery retries and caps them at sixteen seconds", () => {
+ expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([
+ 1_000, 2_000, 4_000, 8_000, 16_000, 16_000,
+ ]);
+ });
+});
diff --git a/apps/mobile/src/state/thread-outbox.ts b/apps/mobile/src/state/thread-outbox.ts
new file mode 100644
index 00000000000..6de14460732
--- /dev/null
+++ b/apps/mobile/src/state/thread-outbox.ts
@@ -0,0 +1,210 @@
+import { useAtomValue } from "@effect/atom-react";
+import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import { Atom } from "effect/unstable/reactivity";
+
+import type { DraftComposerImageAttachment } from "../lib/composerImages";
+import { scopedThreadKey } from "../lib/scopedEntities";
+import { appAtomRegistry } from "./atom-registry";
+
+const THREAD_OUTBOX_SCHEMA_VERSION = 1;
+const THREAD_OUTBOX_DIRECTORY = "thread-outbox";
+const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
+
+const DraftComposerImageAttachmentSchema = Schema.Struct({
+ id: Schema.String,
+ previewUri: Schema.String,
+ type: Schema.Literal("image"),
+ name: Schema.String,
+ mimeType: Schema.String,
+ sizeBytes: Schema.Number,
+ dataUrl: Schema.String,
+});
+
+export const QueuedThreadMessageSchema = Schema.Struct({
+ schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION),
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ messageId: MessageId,
+ commandId: CommandId,
+ text: Schema.String,
+ attachments: Schema.Array(DraftComposerImageAttachmentSchema),
+ createdAt: IsoDateTime,
+});
+
+const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema);
+const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema);
+
+type StoredQueuedThreadMessage = typeof QueuedThreadMessageSchema.Type;
+
+export interface QueuedThreadMessage {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly messageId: MessageId;
+ readonly commandId: CommandId;
+ readonly text: string;
+ readonly attachments: ReadonlyArray;
+ readonly createdAt: string;
+}
+
+export const queuedMessagesByThreadKeyAtom = Atom.make<
+ Record>
+>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-outbox:queued-messages"));
+
+let loadPromise: Promise | null = null;
+
+function storedMessage(message: QueuedThreadMessage): StoredQueuedThreadMessage {
+ return {
+ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION,
+ ...message,
+ };
+}
+
+function messageFileName(messageId: MessageId): string {
+ return `${encodeURIComponent(messageId)}.json`;
+}
+
+async function getOutboxDirectory() {
+ const { Directory, Paths } = await import("expo-file-system");
+ const directory = new Directory(Paths.document, THREAD_OUTBOX_DIRECTORY);
+ directory.create({ idempotent: true, intermediates: true });
+ return directory;
+}
+
+async function getMessageFile(messageId: MessageId) {
+ const { File } = await import("expo-file-system");
+ return new File(await getOutboxDirectory(), messageFileName(messageId));
+}
+
+export function groupQueuedThreadMessages(
+ messages: ReadonlyArray,
+): Record> {
+ const deduplicated = new Map();
+ for (const message of messages) {
+ deduplicated.set(message.messageId, message);
+ }
+
+ const grouped: Record> = {};
+ for (const message of deduplicated.values()) {
+ const threadKey = scopedThreadKey(message.environmentId, message.threadId);
+ (grouped[threadKey] ??= []).push(message);
+ }
+ for (const queue of Object.values(grouped)) {
+ queue.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
+ }
+ return grouped;
+}
+
+export function threadOutboxRetryDelayMs(attempt: number): number {
+ return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS);
+}
+
+export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage {
+ const { schemaVersion: _, ...message } = decodeStoredQueuedThreadMessage(value);
+ return message;
+}
+
+function flattenQueues(
+ queues: Record>,
+): ReadonlyArray {
+ return Object.values(queues).flat();
+}
+
+async function loadPersistedMessages(): Promise> {
+ const { File } = await import("expo-file-system");
+ const directory = await getOutboxDirectory();
+ const messages: Array = [];
+
+ for (const entry of directory.list()) {
+ if (!(entry instanceof File) || !entry.name.endsWith(".json")) {
+ continue;
+ }
+ try {
+ messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown));
+ } catch (error) {
+ console.warn("[thread-outbox] ignored invalid persisted message", entry.name, error);
+ }
+ }
+ return messages;
+}
+
+export function ensureThreadOutboxLoaded(): void {
+ if (loadPromise !== null) {
+ return;
+ }
+ loadPromise = loadPersistedMessages()
+ .then((persistedMessages) => {
+ const current = flattenQueues(appAtomRegistry.get(queuedMessagesByThreadKeyAtom));
+ appAtomRegistry.set(
+ queuedMessagesByThreadKeyAtom,
+ groupQueuedThreadMessages([...persistedMessages, ...current]),
+ );
+ })
+ .catch((error) => {
+ console.warn("[thread-outbox] failed to load persisted messages", error);
+ });
+}
+
+export async function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise {
+ const encoded = encodeStoredQueuedThreadMessage(storedMessage(message));
+ const file = await getMessageFile(message.messageId);
+ if (!file.exists) {
+ file.create({ intermediates: true, overwrite: true });
+ }
+ file.write(JSON.stringify(encoded));
+
+ const current = flattenQueues(appAtomRegistry.get(queuedMessagesByThreadKeyAtom));
+ appAtomRegistry.set(
+ queuedMessagesByThreadKeyAtom,
+ groupQueuedThreadMessages([...current, message]),
+ );
+}
+
+export async function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise {
+ const file = await getMessageFile(message.messageId);
+ if (file.exists) {
+ file.delete();
+ }
+
+ const current = flattenQueues(appAtomRegistry.get(queuedMessagesByThreadKeyAtom));
+ appAtomRegistry.set(
+ queuedMessagesByThreadKeyAtom,
+ groupQueuedThreadMessages(
+ current.filter((candidate) => candidate.messageId !== message.messageId),
+ ),
+ );
+}
+
+export async function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise {
+ const current = flattenQueues(appAtomRegistry.get(queuedMessagesByThreadKeyAtom));
+ const persisted = await loadPersistedMessages().catch((error) => {
+ console.warn("[thread-outbox] failed to load messages while clearing environment", error);
+ return [];
+ });
+ const allMessages = flattenQueues(groupQueuedThreadMessages([...persisted, ...current]));
+ const removed = allMessages.filter((message) => message.environmentId === environmentId);
+
+ await Promise.all(
+ removed.map(async (message) => {
+ try {
+ const file = await getMessageFile(message.messageId);
+ if (file.exists) {
+ file.delete();
+ }
+ } catch (error) {
+ console.warn("[thread-outbox] failed to clear persisted message", error);
+ }
+ }),
+ );
+
+ appAtomRegistry.set(
+ queuedMessagesByThreadKeyAtom,
+ groupQueuedThreadMessages(
+ allMessages.filter((message) => message.environmentId !== environmentId),
+ ),
+ );
+}
+
+export function useThreadOutboxMessages() {
+ return useAtomValue(queuedMessagesByThreadKeyAtom);
+}
diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts
new file mode 100644
index 00000000000..7f247123051
--- /dev/null
+++ b/apps/mobile/src/state/threads.ts
@@ -0,0 +1,45 @@
+import { useAtomValue } from "@effect/atom-react";
+import {
+ createEnvironmentThreadDetailAtoms,
+ createEnvironmentThreadShellAtoms,
+ createEnvironmentThreadStateAtoms,
+ EMPTY_ENVIRONMENT_THREAD_STATE,
+ type EnvironmentThreadState,
+ createThreadEnvironmentAtoms,
+} from "@t3tools/client-runtime/state/threads";
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
+
+import { environmentCatalog } from "../connection/catalog";
+import { connectionAtomRuntime } from "../connection/runtime";
+import { environmentSnapshotAtom } from "./shell";
+
+export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime);
+export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime);
+export const environmentThreadDetails = createEnvironmentThreadDetailAtoms(
+ environmentThreads.stateAtom,
+);
+export const environmentThreadShells = createEnvironmentThreadShellAtoms({
+ catalogValueAtom: environmentCatalog.catalogValueAtom,
+ snapshotAtom: environmentSnapshotAtom,
+});
+
+const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe(
+ Atom.withLabel("mobile-environment-thread:empty"),
+);
+
+export function useEnvironmentThread(
+ environmentId: EnvironmentId | null,
+ threadId: ThreadId | null,
+): EnvironmentThreadState {
+ const result = useAtomValue(
+ environmentId !== null && threadId !== null
+ ? environmentThreads.stateAtom(environmentId, threadId)
+ : EMPTY_THREAD_STATE_ATOM,
+ );
+ return Option.getOrElse(
+ AsyncResult.value(result),
+ () => EMPTY_ENVIRONMENT_THREAD_STATE,
+ ) as EnvironmentThreadState;
+}
diff --git a/apps/mobile/src/state/use-checkpoint-diff.ts b/apps/mobile/src/state/use-checkpoint-diff.ts
deleted file mode 100644
index 3111008f00a..00000000000
--- a/apps/mobile/src/state/use-checkpoint-diff.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { createCheckpointDiffManager, type CheckpointDiffTarget } from "@t3tools/client-runtime";
-
-import { appAtomRegistry } from "./atom-registry";
-import { getEnvironmentClient } from "./environment-session-registry";
-
-export const checkpointDiffManager = createCheckpointDiffManager({
- getRegistry: () => appAtomRegistry,
- getClient: (environmentId) => getEnvironmentClient(environmentId)?.orchestration ?? null,
-});
-
-export function loadCheckpointDiff(
- target: CheckpointDiffTarget,
- options?: { readonly force?: boolean },
-) {
- return checkpointDiffManager.load(target, undefined, options);
-}
diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts
new file mode 100644
index 00000000000..48e4e8703f0
--- /dev/null
+++ b/apps/mobile/src/state/use-composer-drafts.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "@effect/vitest";
+import { EnvironmentId } from "@t3tools/contracts";
+
+import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts";
+
+const DRAFT: ComposerDraft = {
+ text: "hello",
+ attachments: [],
+};
+
+describe("mobile composer drafts", () => {
+ it("removes only drafts owned by the selected environment", () => {
+ const environmentId = EnvironmentId.make("environment-cloud");
+ const retainedEnvironmentId = EnvironmentId.make("environment-local");
+
+ expect(
+ removeComposerDraftsForEnvironment(
+ {
+ [`${environmentId}:thread-cloud`]: DRAFT,
+ [`${retainedEnvironmentId}:thread-local`]: DRAFT,
+ },
+ environmentId,
+ ),
+ ).toEqual({
+ [`${retainedEnvironmentId}:thread-local`]: DRAFT,
+ });
+ });
+});
diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts
index 6ac9786ad0e..ab1fea9840d 100644
--- a/apps/mobile/src/state/use-composer-drafts.ts
+++ b/apps/mobile/src/state/use-composer-drafts.ts
@@ -1,4 +1,5 @@
import { useAtomValue } from "@effect/atom-react";
+import type { EnvironmentId } from "@t3tools/contracts";
import { useEffect } from "react";
import { Atom } from "effect/unstable/reactivity";
@@ -30,7 +31,7 @@ export const composerDraftsAtom = Atom.make>({}).p
Atom.withLabel("mobile:composer-drafts"),
);
-let loadStarted = false;
+let loadPromise: Promise | null = null;
let persistTimer: ReturnType | null = null;
function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
@@ -79,20 +80,24 @@ async function loadPersistedComposerDrafts(): Promise): Promise {
+ const file = await getComposerDraftsFile();
+ const nonEmptyDrafts = Object.fromEntries(
+ Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)),
+ );
+ const document: PersistedComposerDrafts = {
+ schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
+ drafts: nonEmptyDrafts,
+ };
+ if (!file.exists) {
+ file.create({ intermediates: true, overwrite: true });
+ }
+ file.write(JSON.stringify(document));
+}
+
async function savePersistedComposerDrafts(drafts: Record): Promise {
try {
- const file = await getComposerDraftsFile();
- const nonEmptyDrafts = Object.fromEntries(
- Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)),
- );
- const document: PersistedComposerDrafts = {
- schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
- drafts: nonEmptyDrafts,
- };
- if (!file.exists) {
- file.create({ intermediates: true, overwrite: true });
- }
- file.write(JSON.stringify(document));
+ await writePersistedComposerDrafts(drafts);
} catch {
// Draft persistence is best-effort; in-memory drafts still keep working.
}
@@ -109,20 +114,23 @@ function schedulePersistComposerDrafts(drafts: Record): v
}
export function ensureComposerDraftsLoaded(): void {
- if (loadStarted) {
+ if (loadPromise !== null) {
return;
}
- loadStarted = true;
- void loadPersistedComposerDrafts().then((persistedDrafts) => {
- if (Object.keys(persistedDrafts).length === 0) {
- return;
- }
- const current = appAtomRegistry.get(composerDraftsAtom);
- appAtomRegistry.set(composerDraftsAtom, {
- ...persistedDrafts,
- ...current,
+ loadPromise = loadPersistedComposerDrafts()
+ .then((persistedDrafts) => {
+ if (Object.keys(persistedDrafts).length === 0) {
+ return;
+ }
+ const current = appAtomRegistry.get(composerDraftsAtom);
+ appAtomRegistry.set(composerDraftsAtom, {
+ ...persistedDrafts,
+ ...current,
+ });
+ })
+ .catch(() => {
+ // Draft loading is best-effort; in-memory drafts still keep working.
});
- });
}
function updateComposerDrafts(
@@ -234,6 +242,35 @@ export function clearComposerDraft(draftKey: string): void {
});
}
+export function removeComposerDraftsForEnvironment(
+ drafts: Record,
+ environmentId: EnvironmentId,
+): Record {
+ const environmentPrefix = `${environmentId}:`;
+ return Object.fromEntries(
+ Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)),
+ );
+}
+
+export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise {
+ ensureComposerDraftsLoaded();
+ if (loadPromise !== null) {
+ await loadPromise;
+ }
+
+ const next = removeComposerDraftsForEnvironment(
+ appAtomRegistry.get(composerDraftsAtom),
+ environmentId,
+ );
+
+ if (persistTimer !== null) {
+ clearTimeout(persistTimer);
+ persistTimer = null;
+ }
+ appAtomRegistry.set(composerDraftsAtom, next);
+ await writePersistedComposerDrafts(next);
+}
+
export function useComposerDraft(draftKey: string | null): ComposerDraft {
const drafts = useAtomValue(composerDraftsAtom);
useEffect(() => {
diff --git a/apps/mobile/src/state/use-composer-path-search.ts b/apps/mobile/src/state/use-composer-path-search.ts
index a42143a427b..485b472dcb0 100644
--- a/apps/mobile/src/state/use-composer-path-search.ts
+++ b/apps/mobile/src/state/use-composer-path-search.ts
@@ -1,46 +1,7 @@
-import { useAtomValue } from "@effect/atom-react";
-import {
- type ComposerPathSearchState,
- type ComposerPathSearchTarget,
- EMPTY_COMPOSER_PATH_SEARCH_ATOM,
- EMPTY_COMPOSER_PATH_SEARCH_STATE,
- composerPathSearchStateAtom,
- createComposerPathSearchManager,
- getComposerPathSearchTargetKey,
- normalizeComposerPathSearchQuery,
-} from "@t3tools/client-runtime";
-import { useEffect, useMemo } from "react";
+import { type ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads";
-import { appAtomRegistry } from "./atom-registry";
-import {
- getEnvironmentClient,
- subscribeEnvironmentConnections,
-} from "./environment-session-registry";
+import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries";
-const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000;
-
-const composerPathSearchManager = createComposerPathSearchManager({
- getRegistry: () => appAtomRegistry,
- getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null,
- subscribeClientChanges: subscribeEnvironmentConnections,
- staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS,
-});
-
-export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState {
- const stableTarget = useMemo(
- () => ({
- environmentId: target.environmentId,
- cwd: target.cwd,
- query: normalizeComposerPathSearchQuery(target.query),
- }),
- [target.cwd, target.environmentId, target.query],
- );
- const targetKey = getComposerPathSearchTargetKey(stableTarget);
-
- useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]);
-
- const state = useAtomValue(
- targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM,
- );
- return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state;
+export function useComposerPathSearch(target: ComposerPathSearchTarget) {
+ return useComposerPathSearchQuery(target);
}
diff --git a/apps/mobile/src/state/use-environment-runtime.ts b/apps/mobile/src/state/use-environment-runtime.ts
deleted file mode 100644
index f4a65a0d283..00000000000
--- a/apps/mobile/src/state/use-environment-runtime.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { useAtomValue } from "@effect/atom-react";
-import {
- EMPTY_ENVIRONMENT_RUNTIME_ATOM,
- EMPTY_ENVIRONMENT_RUNTIME_STATE,
- createEnvironmentRuntimeManager,
- environmentRuntimeStateAtom,
- getEnvironmentRuntimeTargetKey,
- type EnvironmentRuntimeState,
-} from "@t3tools/client-runtime";
-import type { EnvironmentId } from "@t3tools/contracts";
-import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
-
-import { appAtomRegistry } from "./atom-registry";
-import * as Arr from "effect/Array";
-import * as Order from "effect/Order";
-
-export const environmentRuntimeManager = createEnvironmentRuntimeManager({
- getRegistry: () => appAtomRegistry,
-});
-
-export function useEnvironmentRuntime(
- environmentId: EnvironmentId | null,
-): EnvironmentRuntimeState {
- const targetKey = getEnvironmentRuntimeTargetKey({ environmentId });
- const state = useAtomValue(
- targetKey !== null ? environmentRuntimeStateAtom(targetKey) : EMPTY_ENVIRONMENT_RUNTIME_ATOM,
- );
- return targetKey === null ? EMPTY_ENVIRONMENT_RUNTIME_STATE : state;
-}
-
-export function useEnvironmentRuntimeStates(
- environmentIds: ReadonlyArray,
-): Readonly> {
- const stableEnvironmentIds = useMemo(
- () => Arr.sort(new Set(environmentIds), Order.String),
- [environmentIds],
- );
- const snapshotCacheRef = useRef>>({});
-
- const subscribe = useCallback(
- (onStoreChange: () => void) => {
- const unsubs = stableEnvironmentIds.map((environmentId) =>
- appAtomRegistry.subscribe(environmentRuntimeStateAtom(environmentId), onStoreChange),
- );
- return () => {
- for (const unsub of unsubs) {
- unsub();
- }
- };
- },
- [stableEnvironmentIds],
- );
-
- const getSnapshot = useCallback(() => {
- const previous = snapshotCacheRef.current;
- let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length;
- const next: Record = {};
-
- for (const environmentId of stableEnvironmentIds) {
- const snapshot = environmentRuntimeManager.getSnapshot({ environmentId });
- next[environmentId] = snapshot;
- if (!hasChanged && previous[environmentId] !== snapshot) {
- hasChanged = true;
- }
- }
-
- if (!hasChanged) {
- return previous;
- }
-
- snapshotCacheRef.current = next;
- return next;
- }, [stableEnvironmentIds]);
-
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
-}
diff --git a/apps/mobile/src/state/use-filesystem-browse.ts b/apps/mobile/src/state/use-filesystem-browse.ts
deleted file mode 100644
index e5ab77a80af..00000000000
--- a/apps/mobile/src/state/use-filesystem-browse.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useAtomValue } from "@effect/atom-react";
-import {
- EMPTY_FILESYSTEM_BROWSE_ATOM,
- EMPTY_FILESYSTEM_BROWSE_STATE,
- type FilesystemBrowseClient,
- type FilesystemBrowseState,
- type FilesystemBrowseTarget,
- createFilesystemBrowseManager,
- filesystemBrowseStateAtom,
- getFilesystemBrowseTargetKey,
-} from "@t3tools/client-runtime";
-import type {
- EnvironmentId,
- FilesystemBrowseInput,
- FilesystemBrowseResult,
-} from "@t3tools/contracts";
-import { useEffect, useMemo } from "react";
-
-import { appAtomRegistry } from "./atom-registry";
-import {
- getEnvironmentClient,
- subscribeEnvironmentConnections,
-} from "./environment-session-registry";
-
-const filesystemBrowseManager = createFilesystemBrowseManager({
- getRegistry: () => appAtomRegistry,
- getClient: (environmentId) => getEnvironmentClient(environmentId)?.filesystem ?? null,
- subscribeClientChanges: subscribeEnvironmentConnections,
-});
-
-function filesystemBrowseTargetForEnvironment(
- environmentId: EnvironmentId | null,
- input: FilesystemBrowseInput | null,
-): FilesystemBrowseTarget {
- return { key: environmentId, input };
-}
-
-export function refreshFilesystemBrowseForEnvironment(
- environmentId: EnvironmentId | null,
- input: FilesystemBrowseInput | null,
- client?: FilesystemBrowseClient | null,
-): Promise {
- return filesystemBrowseManager.refresh(
- filesystemBrowseTargetForEnvironment(environmentId, input),
- client ?? undefined,
- );
-}
-
-export function invalidateFilesystemBrowseForEnvironment(
- environmentId: EnvironmentId | null,
- input: FilesystemBrowseInput | null,
-): void {
- filesystemBrowseManager.invalidate(filesystemBrowseTargetForEnvironment(environmentId, input));
-}
-
-export function resetFilesystemBrowseState(): void {
- filesystemBrowseManager.reset();
-}
-
-export function resetFilesystemBrowseStateForTests(): void {
- resetFilesystemBrowseState();
-}
-
-export function useFilesystemBrowse(
- environmentId: EnvironmentId | null,
- input: FilesystemBrowseInput | null,
-): FilesystemBrowseState {
- const target = useMemo(
- () => filesystemBrowseTargetForEnvironment(environmentId, input),
- [environmentId, input],
- );
-
- useEffect(() => {
- return filesystemBrowseManager.watch(target);
- }, [target]);
-
- const targetKey = getFilesystemBrowseTargetKey(target);
- const state = useAtomValue(
- targetKey !== null ? filesystemBrowseStateAtom(targetKey) : EMPTY_FILESYSTEM_BROWSE_ATOM,
- );
- return targetKey === null ? EMPTY_FILESYSTEM_BROWSE_STATE : state;
-}
diff --git a/apps/mobile/src/state/use-remote-catalog.ts b/apps/mobile/src/state/use-remote-catalog.ts
deleted file mode 100644
index 8a5ddac2c0f..00000000000
--- a/apps/mobile/src/state/use-remote-catalog.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import { useMemo } from "react";
-import * as Order from "effect/Order";
-import * as Arr from "effect/Array";
-
-import {
- EnvironmentConnectionState,
- EnvironmentScopedProjectShell,
- EnvironmentScopedThreadShell,
- scopeProjectShell,
- scopeThreadShell,
-} from "@t3tools/client-runtime";
-
-import { ConnectedEnvironmentSummary } from "./remote-runtime-types";
-import type { SavedRemoteConnection } from "../lib/connection";
-import { useCachedShellSnapshotMetadata, useShellSnapshotStates } from "./use-shell-snapshot";
-import {
- useRemoteConnectionStatus,
- useRemoteEnvironmentState,
-} from "./use-remote-environment-registry";
-
-const projectsSortOrder = Order.mapInput(
- Order.Struct({
- title: Order.String,
- environmentId: Order.String,
- }),
- (project: EnvironmentScopedProjectShell) => ({
- title: project.title,
- environmentId: project.environmentId,
- }),
-);
-
-const threadsSortOrder = Order.mapInput(
- Order.Struct({
- activityAt: Order.flip(Order.String),
- environmentId: Order.String,
- }),
- (thread: EnvironmentScopedThreadShell) => ({
- activityAt: thread.updatedAt ?? thread.createdAt,
- environmentId: thread.environmentId,
- }),
-);
-
-function deriveOverallConnectionState(
- environments: ReadonlyArray,
-): EnvironmentConnectionState {
- if (environments.length === 0) {
- return "idle";
- }
- if (environments.some((environment) => environment.connectionState === "ready")) {
- return "ready";
- }
- if (environments.some((environment) => environment.connectionState === "reconnecting")) {
- return "reconnecting";
- }
- if (environments.some((environment) => environment.connectionState === "connecting")) {
- return "connecting";
- }
- return "disconnected";
-}
-
-function listRemoteCatalogEnvironmentIds(
- savedConnectionsById: Readonly>,
-): ReadonlyArray {
- const environmentIds: SavedRemoteConnection["environmentId"][] = [];
- for (const connection of Object.values(savedConnectionsById)) {
- environmentIds.push(connection.environmentId);
- }
- return environmentIds;
-}
-
-export interface RemoteCatalogState {
- readonly isLoadingSavedConnections: boolean;
- readonly hasSavedConnections: boolean;
- readonly hasLoadedShellSnapshot: boolean;
- readonly hasPendingShellSnapshot: boolean;
- readonly hasReadyEnvironment: boolean;
- readonly hasConnectingEnvironment: boolean;
- readonly connectionState: EnvironmentConnectionState;
- readonly connectionError: string | null;
- readonly shellSnapshotError: string | null;
- readonly isUsingCachedData: boolean;
- readonly latestCachedSnapshotReceivedAt: string | null;
-}
-
-export function useRemoteCatalog() {
- const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus();
- const { environmentStateById, isLoadingSavedConnection, savedConnectionsById } =
- useRemoteEnvironmentState();
- const catalogEnvironmentIds = useMemo(
- () => listRemoteCatalogEnvironmentIds(savedConnectionsById),
- [savedConnectionsById],
- );
- const shellSnapshotStates = useShellSnapshotStates(catalogEnvironmentIds);
- const cachedShellSnapshotMetadata = useCachedShellSnapshotMetadata();
-
- const projects = useMemo(() => {
- const scopedProjects: EnvironmentScopedProjectShell[] = [];
- for (const connection of Object.values(savedConnectionsById)) {
- const projects = shellSnapshotStates[connection.environmentId]?.data?.projects ?? [];
- for (const project of projects) {
- scopedProjects.push(scopeProjectShell(connection.environmentId, project));
- }
- }
- return Arr.sort(scopedProjects, projectsSortOrder);
- }, [savedConnectionsById, shellSnapshotStates]);
-
- const threads = useMemo(() => {
- const scopedThreads: EnvironmentScopedThreadShell[] = [];
- for (const connection of Object.values(savedConnectionsById)) {
- const threads = shellSnapshotStates[connection.environmentId]?.data?.threads ?? [];
- for (const thread of threads) {
- scopedThreads.push(scopeThreadShell(connection.environmentId, thread));
- }
- }
- return Arr.sort(scopedThreads, threadsSortOrder);
- }, [savedConnectionsById, shellSnapshotStates]);
-
- const serverConfigByEnvironmentId = useMemo(
- () =>
- Object.fromEntries(
- Object.entries(environmentStateById).map(([environmentId, runtime]) => [
- environmentId,
- runtime.serverConfig ?? null,
- ]),
- ),
- [environmentStateById],
- );
-
- const overallConnectionState = useMemo(
- () => deriveOverallConnectionState(connectedEnvironments),
- [connectedEnvironments],
- );
-
- const hasRemoteActivity = useMemo(
- () =>
- threads.some(
- (thread) => thread.session?.status === "running" || thread.session?.status === "starting",
- ),
- [threads],
- );
-
- const state = useMemo(() => {
- const shellSnapshots = Object.values(shellSnapshotStates);
- const cachedSnapshotReceivedAts: string[] = [];
- for (const environmentId of catalogEnvironmentIds) {
- const metadata = cachedShellSnapshotMetadata[environmentId];
- if (metadata) {
- cachedSnapshotReceivedAts.push(metadata.snapshotReceivedAt);
- }
- }
- let shellSnapshotError: string | null = null;
- for (const snapshot of shellSnapshots) {
- if (snapshot.error !== null) {
- shellSnapshotError = snapshot.error;
- break;
- }
- }
- return {
- isLoadingSavedConnections: isLoadingSavedConnection,
- hasSavedConnections: catalogEnvironmentIds.length > 0,
- hasLoadedShellSnapshot: shellSnapshots.some((snapshot) => snapshot.data !== null),
- hasPendingShellSnapshot: shellSnapshots.some((snapshot) => snapshot.isPending),
- hasReadyEnvironment: connectedEnvironments.some(
- (environment) => environment.connectionState === "ready",
- ),
- hasConnectingEnvironment: connectedEnvironments.some(
- (environment) =>
- environment.connectionState === "connecting" ||
- environment.connectionState === "reconnecting",
- ),
- connectionState: connectionState ?? overallConnectionState,
- connectionError,
- shellSnapshotError,
- isUsingCachedData: cachedSnapshotReceivedAts.length > 0,
- latestCachedSnapshotReceivedAt:
- Arr.sort(cachedSnapshotReceivedAts, Order.flip(Order.String))[0] ?? null,
- };
- }, [
- cachedShellSnapshotMetadata,
- catalogEnvironmentIds,
- connectedEnvironments,
- connectionError,
- connectionState,
- isLoadingSavedConnection,
- overallConnectionState,
- shellSnapshotStates,
- ]);
-
- return {
- projects,
- threads,
- serverConfigByEnvironmentId,
- connectionState: state.connectionState,
- connectionError: state.connectionError,
- state,
- hasRemoteActivity,
- };
-}
diff --git a/apps/mobile/src/state/use-remote-environment-registry.test.ts b/apps/mobile/src/state/use-remote-environment-registry.test.ts
deleted file mode 100644
index fc465bbfb88..00000000000
--- a/apps/mobile/src/state/use-remote-environment-registry.test.ts
+++ /dev/null
@@ -1,430 +0,0 @@
-import { describe, expect, it } from "@effect/vitest";
-import { EnvironmentId } from "@t3tools/contracts";
-import {
- createManagedRelaySession,
- ManagedRelayDpopSigner,
- setManagedRelaySession,
-} from "@t3tools/client-runtime";
-import * as Effect from "effect/Effect";
-import { beforeEach, vi } from "vite-plus/test";
-
-const mocks = vi.hoisted(() => {
- const environmentConnection = {
- ensureBootstrapped: vi.fn(() => Promise.resolve()),
- dispose: vi.fn(() => Promise.resolve()),
- };
- const sessionConnection = {
- dispose: vi.fn(() => Promise.resolve()),
- reconnect: vi.fn(() => Promise.resolve()),
- };
- const sessionClient = {
- isHeartbeatFresh: vi.fn(() => false),
- };
- return {
- environmentConnection,
- sessionConnection,
- sessionClient,
- createEnvironmentConnection: vi.fn(() => environmentConnection),
- createKnownEnvironment: vi.fn((input: unknown) => input),
- createWsRpcClient: vi.fn(() => ({ rpc: true })),
- wsTransportConstructor: vi.fn(),
- resolveRemoteWebSocketConnectionUrl: vi.fn(() => ({ _tag: "remote-ws-url-effect" })),
- resolveRemoteDpopWebSocketConnectionUrl: vi.fn(),
- remoteEndpointUrl: vi.fn((baseUrl: string, path: string) => new URL(path, baseUrl).toString()),
- createDpopProof: vi.fn(),
- refreshCloudEnvironmentConnection: vi.fn(),
- bootstrapRemoteConnection: vi.fn(),
- clearCachedShellSnapshot: vi.fn(() => Promise.resolve()),
- clearSavedConnection: vi.fn(() => Promise.resolve()),
- saveConnection: vi.fn((_connection?: unknown) => Promise.resolve()),
- saveCachedShellSnapshot: vi.fn(() => Promise.resolve()),
- mobileRunPromise: vi.fn((_effect?: unknown) =>
- Promise.resolve("wss://desktop.example/ws?wsTicket=token"),
- ),
- removeEnvironmentSession: vi.fn(() => null),
- getEnvironmentSession: vi.fn(() => null),
- setEnvironmentSession: vi.fn(),
- notifyEnvironmentConnectionListeners: vi.fn(),
- unregisterAgentAwarenessConnection: vi.fn(),
- registerAgentAwarenessConnection: vi.fn(),
- shellSnapshotInvalidate: vi.fn(),
- shellSnapshotMarkPending: vi.fn(),
- environmentRuntimeInvalidate: vi.fn(),
- environmentRuntimePatch: vi.fn(),
- clearCachedShellSnapshotMetadata: vi.fn(),
- invalidateSourceControlDiscoveryForEnvironment: vi.fn(),
- terminalSessionInvalidateEnvironment: vi.fn(),
- subscribeTerminalMetadata: vi.fn(() => vi.fn()),
- terminalDebugLog: vi.fn(),
- WsTransport: function WsTransport(...args: ReadonlyArray) {
- mocks.wsTransportConstructor(...args);
- },
- };
-});
-
-vi.mock("react-native", () => ({
- Alert: {
- alert: vi.fn(),
- },
- AppState: {
- currentState: "active",
- addEventListener: vi.fn(() => ({ remove: vi.fn() })),
- },
-}));
-
-vi.mock("@t3tools/client-runtime", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- WsTransport: mocks.WsTransport,
- createEnvironmentConnection: mocks.createEnvironmentConnection,
- createKnownEnvironment: mocks.createKnownEnvironment,
- createWsRpcClient: mocks.createWsRpcClient,
- remoteEndpointUrl: mocks.remoteEndpointUrl,
- resolveRemoteDpopWebSocketConnectionUrl: mocks.resolveRemoteDpopWebSocketConnectionUrl,
- resolveRemoteWebSocketConnectionUrl: mocks.resolveRemoteWebSocketConnectionUrl,
- };
-});
-
-vi.mock("../lib/connection", async (importOriginal) => ({
- ...(await importOriginal()),
- bootstrapRemoteConnection: mocks.bootstrapRemoteConnection,
-}));
-
-vi.mock("../features/cloud/linkEnvironment", () => ({
- refreshCloudEnvironmentConnection: mocks.refreshCloudEnvironmentConnection,
-}));
-
-vi.mock("../lib/storage", () => ({
- clearCachedShellSnapshot: mocks.clearCachedShellSnapshot,
- clearSavedConnection: mocks.clearSavedConnection,
- loadCachedShellSnapshot: vi.fn(() => Promise.resolve(null)),
- loadSavedConnections: vi.fn(() => Promise.resolve([])),
- saveCachedShellSnapshot: mocks.saveCachedShellSnapshot,
- saveConnection: mocks.saveConnection,
-}));
-
-vi.mock("../lib/runtime", () => ({
- mobileRuntime: {
- runPromise: mocks.mobileRunPromise,
- },
-}));
-
-vi.mock("./environment-session-registry", () => ({
- drainEnvironmentSessions: vi.fn(() => []),
- getEnvironmentSession: mocks.getEnvironmentSession,
- notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners,
- removeEnvironmentSession: mocks.removeEnvironmentSession,
- setEnvironmentSession: mocks.setEnvironmentSession,
-}));
-
-vi.mock("../features/agent-awareness/remoteRegistration", () => ({
- registerAgentAwarenessConnection: mocks.registerAgentAwarenessConnection,
- unregisterAgentAwarenessConnection: mocks.unregisterAgentAwarenessConnection,
- unregisterAllAgentAwarenessConnections: vi.fn(),
-}));
-
-vi.mock("../features/terminal/terminalDebugLog", () => ({
- terminalDebugLog: mocks.terminalDebugLog,
-}));
-
-vi.mock("./use-environment-runtime", () => ({
- environmentRuntimeManager: {
- invalidate: mocks.environmentRuntimeInvalidate,
- patch: mocks.environmentRuntimePatch,
- },
- useEnvironmentRuntimeStates: vi.fn(() => ({})),
-}));
-
-vi.mock("./use-shell-snapshot", () => ({
- clearCachedShellSnapshotMetadata: mocks.clearCachedShellSnapshotMetadata,
- hydrateCachedShellSnapshot: vi.fn(),
- markShellSnapshotLive: vi.fn(),
- shellSnapshotManager: {
- applyEvent: vi.fn(),
- invalidate: mocks.shellSnapshotInvalidate,
- markPending: mocks.shellSnapshotMarkPending,
- syncSnapshot: vi.fn(),
- },
-}));
-
-vi.mock("./use-source-control-discovery", () => ({
- invalidateSourceControlDiscoveryForEnvironment:
- mocks.invalidateSourceControlDiscoveryForEnvironment,
- resetSourceControlDiscoveryState: vi.fn(),
-}));
-
-vi.mock("./use-terminal-session", () => ({
- subscribeTerminalMetadata: mocks.subscribeTerminalMetadata,
- terminalSessionManager: {
- invalidate: vi.fn(),
- invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment,
- },
-}));
-
-import {
- connectSavedEnvironment,
- disconnectEnvironment,
- reconnectEnvironmentConnectionsAfterAppResume,
-} from "./use-remote-environment-registry";
-import { appAtomRegistry } from "./atom-registry";
-
-const environmentId = EnvironmentId.make("env-mobile-test");
-
-const connection = {
- environmentId,
- environmentLabel: "Mobile Test Desktop",
- pairingUrl: "https://desktop.example/",
- displayUrl: "https://desktop.example/",
- httpBaseUrl: "https://desktop.example/",
- wsBaseUrl: "wss://desktop.example/",
- bearerToken: "remote-access-token",
-} as const;
-
-describe("mobile remote environment registry effects", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mocks.createEnvironmentConnection.mockReturnValue(mocks.environmentConnection);
- mocks.environmentConnection.ensureBootstrapped.mockResolvedValue(undefined);
- mocks.environmentConnection.dispose.mockResolvedValue(undefined);
- mocks.sessionConnection.dispose.mockResolvedValue(undefined);
- mocks.sessionConnection.reconnect.mockResolvedValue(undefined);
- mocks.sessionClient.isHeartbeatFresh.mockReturnValue(false);
- mocks.removeEnvironmentSession.mockReturnValue(null);
- mocks.getEnvironmentSession.mockReturnValue(null);
- mocks.mobileRunPromise.mockResolvedValue("wss://desktop.example/ws?wsTicket=token");
- mocks.createDpopProof.mockReturnValue(Effect.succeed("dpop-proof"));
- mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.die("unexpected refresh"));
- mocks.resolveRemoteDpopWebSocketConnectionUrl.mockReturnValue(
- Effect.succeed("wss://desktop.example/ws?wsTicket=dpop-token"),
- );
- setManagedRelaySession(appAtomRegistry, null);
- });
-
- it.effect("connects a saved managed endpoint environment through Effect-wrapped APIs", () =>
- Effect.gen(function* () {
- yield* connectSavedEnvironment(connection);
-
- expect(mocks.saveConnection).toHaveBeenCalledWith(connection);
- expect(mocks.wsTransportConstructor).toHaveBeenCalledTimes(1);
- expect(mocks.createEnvironmentConnection).toHaveBeenCalledTimes(1);
- expect(mocks.setEnvironmentSession).toHaveBeenCalledWith(
- connection.environmentId,
- expect.objectContaining({
- connection: mocks.environmentConnection,
- }),
- );
- expect(mocks.subscribeTerminalMetadata).toHaveBeenCalledWith(
- expect.objectContaining({ environmentId: connection.environmentId }),
- );
- expect(mocks.registerAgentAwarenessConnection).toHaveBeenCalledWith(connection);
- expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1);
- }),
- );
-
- it.effect("uses DPoP-bound admission for a managed DPoP connection", () =>
- Effect.gen(function* () {
- const dpopConnection = {
- ...connection,
- bearerToken: null,
- authenticationMethod: "dpop",
- dpopAccessToken: "environment-dpop-token",
- } as const;
- mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) =>
- Effect.runPromise(
- (effect as Effect.Effect).pipe(
- Effect.provideService(
- ManagedRelayDpopSigner,
- ManagedRelayDpopSigner.of({
- thumbprint: Effect.succeed("mobile-key-thumbprint"),
- createProof: mocks.createDpopProof,
- }),
- ),
- ),
- ),
- );
-
- yield* connectSavedEnvironment(dpopConnection);
- const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as
- | (() => Promise)
- | undefined;
- expect(openSocket).toBeDefined();
- yield* Effect.promise(() => openSocket!());
-
- expect(mocks.createDpopProof).toHaveBeenCalledWith({
- method: "POST",
- url: "https://desktop.example/api/auth/websocket-ticket",
- accessToken: "environment-dpop-token",
- });
- expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({
- wsBaseUrl: dpopConnection.wsBaseUrl,
- httpBaseUrl: dpopConnection.httpBaseUrl,
- accessToken: "environment-dpop-token",
- dpopProof: "dpop-proof",
- });
- expect(mocks.resolveRemoteWebSocketConnectionUrl).not.toHaveBeenCalled();
- }),
- );
-
- it.effect("refreshes a persisted managed connection before reconnecting", () =>
- Effect.gen(function* () {
- const savedDpopConnection = {
- ...connection,
- bearerToken: null,
- authenticationMethod: "dpop",
- relayManaged: true,
- } as const;
- const refreshedConnection = {
- ...savedDpopConnection,
- displayUrl: "https://rotated-desktop.example/",
- httpBaseUrl: "https://rotated-desktop.example/",
- wsBaseUrl: "wss://rotated-desktop.example/",
- dpopAccessToken: "fresh-environment-dpop-token",
- } as const;
- setManagedRelaySession(
- appAtomRegistry,
- createManagedRelaySession({
- accountId: "account-1",
- readClerkToken: () => Promise.resolve("fresh-clerk-token"),
- }),
- );
- mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.succeed(refreshedConnection));
- mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) =>
- Effect.runPromise(
- (effect as Effect.Effect).pipe(
- Effect.provideService(
- ManagedRelayDpopSigner,
- ManagedRelayDpopSigner.of({
- thumbprint: Effect.succeed("mobile-key-thumbprint"),
- createProof: mocks.createDpopProof,
- }),
- ),
- ),
- ),
- );
-
- yield* connectSavedEnvironment(savedDpopConnection, { persist: false });
- const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as
- | (() => Promise)
- | undefined;
- expect(openSocket).toBeDefined();
- yield* Effect.promise(() => openSocket!());
-
- expect(mocks.refreshCloudEnvironmentConnection).toHaveBeenCalledWith({
- clerkToken: "fresh-clerk-token",
- connection: savedDpopConnection,
- });
- const persistedConnection = mocks.saveConnection.mock.calls[0]?.[0];
- expect(persistedConnection).toMatchObject({
- ...savedDpopConnection,
- displayUrl: refreshedConnection.displayUrl,
- httpBaseUrl: refreshedConnection.httpBaseUrl,
- wsBaseUrl: refreshedConnection.wsBaseUrl,
- });
- expect(persistedConnection).not.toHaveProperty("dpopAccessToken");
- expect(mocks.createDpopProof).toHaveBeenCalledWith({
- method: "POST",
- url: "https://rotated-desktop.example/api/auth/websocket-ticket",
- accessToken: "fresh-environment-dpop-token",
- });
- expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({
- wsBaseUrl: refreshedConnection.wsBaseUrl,
- httpBaseUrl: refreshedConnection.httpBaseUrl,
- accessToken: "fresh-environment-dpop-token",
- dpopProof: "dpop-proof",
- });
- }),
- );
-
- it.effect("fails interactive connects when the managed endpoint bootstrap fails", () =>
- Effect.gen(function* () {
- mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce(
- new Error("bootstrap failed"),
- );
- mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({
- connection: mocks.sessionConnection,
- } as never);
-
- const result = yield* Effect.exit(connectSavedEnvironment(connection));
-
- expect(result._tag).toBe("Failure");
- expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith(
- { environmentId: connection.environmentId },
- expect.any(Function),
- );
- expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1);
- expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled();
- expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled();
- }),
- );
-
- it.effect("can suppress bootstrap failures during best-effort startup reconnect", () =>
- Effect.gen(function* () {
- mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce(
- new Error("bootstrap failed"),
- );
- mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({
- connection: mocks.sessionConnection,
- } as never);
-
- yield* connectSavedEnvironment(connection, {
- persist: false,
- suppressBootstrapError: true,
- });
-
- expect(mocks.saveConnection).not.toHaveBeenCalled();
- expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1);
- expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1);
- expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled();
- expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled();
- expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith(
- { environmentId: connection.environmentId },
- expect.any(Function),
- );
- }),
- );
-
- it.effect("reconnects a stale saved environment session after app resume", () =>
- Effect.gen(function* () {
- yield* connectSavedEnvironment(connection);
- vi.clearAllMocks();
- mocks.getEnvironmentSession.mockReturnValue({
- client: mocks.sessionClient,
- connection: mocks.sessionConnection,
- } as never);
-
- reconnectEnvironmentConnectionsAfterAppResume("test");
-
- yield* Effect.promise(() =>
- vi.waitFor(() => {
- expect(mocks.sessionConnection.reconnect).toHaveBeenCalledTimes(1);
- }),
- );
- expect(mocks.shellSnapshotMarkPending).toHaveBeenCalledWith({
- environmentId: connection.environmentId,
- });
- expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith(
- { environmentId: connection.environmentId },
- expect.any(Function),
- );
- }),
- );
-
- it.effect("disconnects and removes persisted managed endpoint state when requested", () =>
- Effect.gen(function* () {
- mocks.removeEnvironmentSession.mockReturnValue({
- connection: mocks.sessionConnection,
- } as never);
-
- yield* disconnectEnvironment(connection.environmentId, { removeSaved: true });
-
- expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1);
- expect(mocks.unregisterAgentAwarenessConnection).toHaveBeenCalledWith(
- connection.environmentId,
- );
- expect(mocks.clearSavedConnection).toHaveBeenCalledWith(connection.environmentId);
- expect(mocks.clearCachedShellSnapshot).toHaveBeenCalledWith(connection.environmentId);
- expect(mocks.clearCachedShellSnapshotMetadata).toHaveBeenCalledWith(connection.environmentId);
- }),
- );
-});
diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts
index b7584858dc4..ea33496a40a 100644
--- a/apps/mobile/src/state/use-remote-environment-registry.ts
+++ b/apps/mobile/src/state/use-remote-environment-registry.ts
@@ -1,90 +1,22 @@
import { useAtomValue } from "@effect/atom-react";
-import { useCallback, useEffect, useMemo } from "react";
-import { Alert, AppState } from "react-native";
-
-import {
- type EnvironmentRuntimeState,
- createEnvironmentConnection,
- createEnvironmentConnectionAttemptRegistry,
- createKnownEnvironment,
- createWsRpcClient,
- EnvironmentConnectionState,
- ManagedRelayDpopSigner,
- WsTransport,
- remoteEndpointUrl,
- resolveRemoteDpopWebSocketConnectionUrl,
- resolveRemoteWebSocketConnectionUrl,
- waitForManagedRelayClerkToken,
-} from "@t3tools/client-runtime";
import type { EnvironmentId } from "@t3tools/contracts";
-import * as Arr from "effect/Array";
-import * as Duration from "effect/Duration";
-import * as Effect from "effect/Effect";
-import * as Order from "effect/Order";
-import * as Option from "effect/Option";
-import { pipe } from "effect/Function";
+import type { ServerConfig } from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";
+import { useCallback, useMemo } from "react";
+import { Alert } from "react-native";
+
+import { useEnvironmentServerConfig } from "../state/entities";
+import { useConnectionController } from "../features/connection/useConnectionController";
+import { useEnvironmentPresentation } from "./presentation";
import {
- type SavedRemoteConnection,
- bootstrapRemoteConnection,
- isRelayManagedConnection,
- toStableSavedRemoteConnection,
-} from "../lib/connection";
-import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment";
-import { terminalDebugLog } from "../features/terminal/terminalDebugLog";
-import {
- clearCachedShellSnapshot,
- clearSavedConnection,
- loadCachedShellSnapshot,
- loadSavedConnections,
- saveCachedShellSnapshot,
- saveConnection,
-} from "../lib/storage";
+ projectEnvironmentPresentation,
+ type EnvironmentPresentation,
+} from "../state/environments";
+import { useWorkspaceState } from "../state/workspace";
+import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../state/workspaceModel";
+import type { SavedRemoteConnection } from "../lib/connection";
import { appAtomRegistry } from "./atom-registry";
-import { mobileRuntime } from "../lib/runtime";
-import {
- drainEnvironmentSessions,
- getEnvironmentSession,
- notifyEnvironmentConnectionListeners,
- removeEnvironmentSession,
- setEnvironmentSession,
-} from "./environment-session-registry";
-import { type ConnectedEnvironmentSummary } from "./remote-runtime-types";
-import {
- invalidateSourceControlDiscoveryForEnvironment,
- resetSourceControlDiscoveryState,
-} from "./use-source-control-discovery";
-import {
- registerAgentAwarenessConnection,
- unregisterAgentAwarenessConnection,
- unregisterAllAgentAwarenessConnections,
-} from "../features/agent-awareness/remoteRegistration";
-import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";
-import {
- clearCachedShellSnapshotMetadata,
- hydrateCachedShellSnapshot,
- markShellSnapshotLive,
- shellSnapshotManager,
-} from "./use-shell-snapshot";
-import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session";
-
-const terminalMetadataUnsubscribers = new Map void>();
-const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry();
-const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000;
-const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
-let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY;
-
-interface RemoteEnvironmentLocalState {
- readonly isLoadingSavedConnection: boolean;
- readonly connectionPairingUrl: string;
- readonly pendingConnectionError: string | null;
- readonly savedConnectionsById: Record;
-}
-
-const isLoadingSavedConnectionAtom = Atom.make(true).pipe(
- Atom.keepAlive,
- Atom.withLabel("mobile:is-loading-saved-connection"),
-);
+import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types";
const connectionPairingUrlAtom = Atom.make("").pipe(
Atom.keepAlive,
@@ -96,679 +28,170 @@ const pendingConnectionErrorAtom = Atom.make(null).pipe(
Atom.withLabel("mobile:pending-connection-error"),
);
-const savedConnectionsByIdAtom = Atom.make>({}).pipe(
- Atom.keepAlive,
- Atom.withLabel("mobile:saved-connections"),
-);
-
-function getSavedConnectionsById(): Record {
- return appAtomRegistry.get(savedConnectionsByIdAtom);
-}
-
-function setIsLoadingSavedConnection(value: boolean): void {
- appAtomRegistry.set(isLoadingSavedConnectionAtom, value);
-}
-
-function setConnectionPairingUrl(pairingUrl: string): void {
- appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl);
-}
-
-function clearConnectionPairingUrl(): void {
- appAtomRegistry.set(connectionPairingUrlAtom, "");
-}
-
export function setPendingConnectionError(message: string | null): void {
appAtomRegistry.set(pendingConnectionErrorAtom, message);
}
-function clearPendingConnectionError(): void {
- appAtomRegistry.set(pendingConnectionErrorAtom, null);
-}
+function toSavedConnection(environment: WorkspaceEnvironment): SavedRemoteConnection {
+ const displayUrl = environment.displayUrl;
+ const wsBaseUrl = displayUrl.startsWith("https://")
+ ? displayUrl.replace(/^https:/, "wss:")
+ : displayUrl.replace(/^http:/, "ws:");
-function replaceSavedConnections(connections: Record): void {
- appAtomRegistry.set(savedConnectionsByIdAtom, connections);
-}
-
-function upsertSavedConnection(connection: SavedRemoteConnection): void {
- const current = appAtomRegistry.get(savedConnectionsByIdAtom);
- appAtomRegistry.set(savedConnectionsByIdAtom, {
- ...current,
- [connection.environmentId]: connection,
- });
+ return {
+ environmentId: environment.environmentId,
+ environmentLabel: environment.environmentLabel,
+ pairingUrl: displayUrl,
+ displayUrl,
+ httpBaseUrl: displayUrl,
+ wsBaseUrl,
+ bearerToken: null,
+ ...(environment.isRelayManaged
+ ? {
+ authenticationMethod: "dpop" as const,
+ relayManaged: true as const,
+ }
+ : { authenticationMethod: "bearer" as const }),
+ };
}
-function removeSavedConnection(environmentId: EnvironmentId): void {
- const current = appAtomRegistry.get(savedConnectionsByIdAtom);
- const next = { ...current };
- delete next[environmentId];
- appAtomRegistry.set(savedConnectionsByIdAtom, next);
+function toRuntimeState(
+ environment: EnvironmentPresentation,
+ serverConfig: ServerConfig | null,
+): EnvironmentRuntimeState {
+ return {
+ connectionState: environment.connection.phase,
+ connectionError: environment.connection.error,
+ connectionErrorTraceId: environment.connection.traceId,
+ serverConfig,
+ };
}
-function useRemoteEnvironmentLocalState(): RemoteEnvironmentLocalState {
- const isLoadingSavedConnection = useAtomValue(isLoadingSavedConnectionAtom);
- const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom);
- const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
- const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom);
-
- return useMemo(
- () => ({
- isLoadingSavedConnection,
- connectionPairingUrl,
- pendingConnectionError,
- savedConnectionsById,
- }),
- [connectionPairingUrl, isLoadingSavedConnection, pendingConnectionError, savedConnectionsById],
+export function useSavedRemoteConnections() {
+ const workspace = useWorkspaceState();
+ const savedConnectionsById = useMemo(
+ () =>
+ Object.fromEntries(
+ workspace.environments.map((environment) => [
+ environment.environmentId,
+ toSavedConnection(environment),
+ ]),
+ ) as Record,
+ [workspace.environments],
);
-}
-
-function setEnvironmentConnectionStatus(
- environmentId: EnvironmentId,
- state: ConnectedEnvironmentSummary["connectionState"],
- error?: string | null,
-) {
- environmentRuntimeManager.patch({ environmentId }, (current) => ({
- ...current,
- connectionState: state,
- connectionError: error === undefined ? current.connectionError : error,
- }));
-}
-
-function fromPromise(tryPromise: () => Promise): Effect.Effect {
- return Effect.tryPromise({
- try: tryPromise,
- catch: (cause) => cause,
- });
-}
-
-export function disconnectEnvironment(
- environmentId: EnvironmentId,
- options?: {
- readonly preserveShellSnapshot?: boolean;
- readonly removeSaved?: boolean;
- readonly preserveConnectionAttempt?: boolean;
- },
-): Effect.Effect {
- return Effect.gen(function* () {
- if (!options?.preserveConnectionAttempt) {
- environmentConnectionAttempts.cancel(environmentId);
- }
-
- const session = removeEnvironmentSession(environmentId);
- notifyEnvironmentConnectionListeners();
- if (session) {
- yield* fromPromise(() => session.connection.dispose());
- }
- terminalMetadataUnsubscribers.get(environmentId)?.();
- terminalMetadataUnsubscribers.delete(environmentId);
- unregisterAgentAwarenessConnection(environmentId);
- if (!options?.preserveShellSnapshot) {
- shellSnapshotManager.invalidate({ environmentId });
- }
- invalidateSourceControlDiscoveryForEnvironment(environmentId);
- terminalSessionManager.invalidateEnvironment(environmentId);
- environmentRuntimeManager.invalidate({ environmentId });
-
- if (options?.removeSaved) {
- yield* Effect.all(
- [
- fromPromise(() => clearSavedConnection(environmentId)),
- fromPromise(() => clearCachedShellSnapshot(environmentId)),
- ],
- { concurrency: 2 },
- );
- clearCachedShellSnapshotMetadata(environmentId);
- removeSavedConnection(environmentId);
- }
- });
-}
-
-export function connectSavedEnvironment(
- connection: SavedRemoteConnection,
- options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean },
-): Effect.Effect {
- return Effect.gen(function* () {
- const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId);
- const isCurrentAttempt = connectionAttempt.isCurrent;
- let activeConnection = connection;
- let initialDpopAccessToken =
- options?.persist === false ? undefined : connection.dpopAccessToken;
-
- yield* disconnectEnvironment(connection.environmentId, {
- preserveShellSnapshot: true,
- preserveConnectionAttempt: true,
- });
- if (!isCurrentAttempt()) {
- return;
- }
-
- if (options?.persist !== false) {
- yield* fromPromise(() => saveConnection(toStableSavedRemoteConnection(connection)));
- if (!isCurrentAttempt()) {
- return;
- }
- }
-
- upsertSavedConnection(toStableSavedRemoteConnection(connection));
- setEnvironmentConnectionStatus(connection.environmentId, "connecting", null);
- shellSnapshotManager.markPending({ environmentId: connection.environmentId });
-
- const transport = new WsTransport(
- () =>
- mobileRuntime.runPromise(
- isRelayManagedConnection(connection)
- ? Effect.gen(function* () {
- let dpopAccessToken = initialDpopAccessToken;
- initialDpopAccessToken = undefined;
- if (!dpopAccessToken) {
- const clerkToken = yield* waitForManagedRelayClerkToken(appAtomRegistry);
- const refreshedConnection = yield* refreshCloudEnvironmentConnection({
- clerkToken,
- connection: activeConnection,
- });
- const stableConnection = toStableSavedRemoteConnection(refreshedConnection);
- activeConnection = refreshedConnection;
- if (isCurrentAttempt()) {
- yield* fromPromise(() => saveConnection(stableConnection));
- upsertSavedConnection(stableConnection);
- }
- dpopAccessToken = refreshedConnection.dpopAccessToken;
- }
- if (!dpopAccessToken) {
- return yield* Effect.fail(
- new Error("Managed environment connection did not return a DPoP access token."),
- );
- }
- const signer = yield* ManagedRelayDpopSigner;
- const dpop = yield* signer.createProof({
- method: "POST",
- url: remoteEndpointUrl(
- activeConnection.httpBaseUrl,
- "/api/auth/websocket-ticket",
- ),
- accessToken: dpopAccessToken,
- });
- return yield* resolveRemoteDpopWebSocketConnectionUrl({
- wsBaseUrl: activeConnection.wsBaseUrl,
- httpBaseUrl: activeConnection.httpBaseUrl,
- accessToken: dpopAccessToken,
- dpopProof: dpop,
- });
- })
- : resolveRemoteWebSocketConnectionUrl({
- wsBaseUrl: connection.wsBaseUrl,
- httpBaseUrl: connection.httpBaseUrl,
- bearerToken: connection.bearerToken ?? "",
- }),
- ),
- {
- onAttempt: () => {
- if (!isCurrentAttempt()) {
- return;
- }
-
- environmentRuntimeManager.patch(
- { environmentId: connection.environmentId },
- (previous) => {
- const nextState =
- previous.connectionState === "ready" || previous.connectionState === "reconnecting"
- ? "reconnecting"
- : "connecting";
- const keepSettledFailure =
- previous.connectionState === "disconnected" && previous.connectionError !== null;
- return {
- ...previous,
- connectionState: keepSettledFailure ? "disconnected" : nextState,
- connectionError: keepSettledFailure ? previous.connectionError : null,
- };
- },
- );
- },
- onError: (message) => {
- if (isCurrentAttempt()) {
- setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message);
- }
- },
- onClose: (details) => {
- if (!isCurrentAttempt()) {
- return;
- }
-
- const reason =
- details.reason.trim().length > 0
- ? details.reason
- : details.code === 1000
- ? null
- : `Remote connection closed (${details.code}).`;
- setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason);
- },
- },
- );
-
- const client = createWsRpcClient(transport);
- const environmentConnection = createEnvironmentConnection({
- kind: "saved",
- knownEnvironment: {
- ...createKnownEnvironment({
- id: connection.environmentId,
- label: connection.environmentLabel,
- source: "manual",
- target: {
- httpBaseUrl: connection.httpBaseUrl,
- wsBaseUrl: connection.wsBaseUrl,
- },
- }),
- environmentId: connection.environmentId,
- },
- client,
- applyShellEvent: (event, environmentId) => {
- if (isCurrentAttempt()) {
- shellSnapshotManager.applyEvent({ environmentId }, event);
- }
- },
- syncShellSnapshot: (snapshot, environmentId) => {
- if (!isCurrentAttempt()) {
- return;
- }
-
- shellSnapshotManager.syncSnapshot({ environmentId }, snapshot);
- markShellSnapshotLive(environmentId);
- void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined);
- environmentRuntimeManager.patch({ environmentId }, (runtime) => ({
- ...runtime,
- connectionState: "ready",
- connectionError: null,
- }));
- },
- onShellResubscribe: (environmentId) => {
- if (isCurrentAttempt()) {
- shellSnapshotManager.markPending({ environmentId });
- }
- },
- onConfigSnapshot: (serverConfig) => {
- if (isCurrentAttempt()) {
- environmentRuntimeManager.patch(
- { environmentId: connection.environmentId },
- (runtime) => ({
- ...runtime,
- serverConfig,
- }),
- );
- }
- },
- });
-
- if (!isCurrentAttempt()) {
- yield* fromPromise(() => environmentConnection.dispose());
- return;
- }
- setEnvironmentSession(connection.environmentId, {
- client,
- connection: environmentConnection,
- });
-
- const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe(
- Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)),
- Effect.flatMap((result) =>
- Option.match(result, {
- onNone: () =>
- Effect.fail(new Error("Environment did not respond before the connection timeout.")),
- onSome: Effect.succeed,
- }),
- ),
- Effect.tapError((error: unknown) =>
- isCurrentAttempt()
- ? Effect.gen(function* () {
- setEnvironmentConnectionStatus(
- connection.environmentId,
- "disconnected",
- error instanceof Error ? error.message : "Failed to bootstrap remote connection.",
- );
- const pendingSession = removeEnvironmentSession(connection.environmentId);
- notifyEnvironmentConnectionListeners();
- if (pendingSession) {
- yield* fromPromise(() => pendingSession.connection.dispose());
- }
- })
- : Effect.void,
- ),
- );
- const bootstrapped = yield* options?.suppressBootstrapError
- ? bootstrap.pipe(
- Effect.as(true),
- Effect.catch(() => Effect.succeed(false)),
- )
- : bootstrap.pipe(Effect.as(true));
-
- if (!bootstrapped || !isCurrentAttempt()) {
- return;
- }
-
- terminalMetadataUnsubscribers.set(
- connection.environmentId,
- subscribeTerminalMetadata({
- environmentId: connection.environmentId,
- client,
- }),
- );
- terminalDebugLog("registry:terminal-metadata-subscribed", {
- environmentId: connection.environmentId,
- });
- registerAgentAwarenessConnection(toStableSavedRemoteConnection(activeConnection));
- notifyEnvironmentConnectionListeners();
- });
+ return {
+ isLoadingSavedConnection: workspace.state.isLoadingConnections,
+ savedConnectionsById,
+ };
}
-export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void {
- const now = Date.now();
- if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) {
- return;
+export function useSavedRemoteConnection(
+ environmentId: EnvironmentId | null,
+): SavedRemoteConnection | null {
+ const { presentation } = useEnvironmentPresentation(environmentId);
+ if (environmentId === null || presentation === null) {
+ return null;
}
-
- for (const connection of Object.values(getSavedConnectionsById())) {
- const session = getEnvironmentSession(connection.environmentId);
- if (session?.client.isHeartbeatFresh()) {
- continue;
- }
-
- lastAppResumeReconnectAt = now;
- terminalDebugLog("registry:app-resume-reconnect", {
- environmentId: connection.environmentId,
- reason,
- hasSession: session !== null,
- });
-
- if (!session) {
- void mobileRuntime
- .runPromise(
- connectSavedEnvironment(connection, {
- persist: false,
- suppressBootstrapError: true,
- }),
- )
- .catch((error: unknown) => {
- terminalDebugLog("registry:app-resume-reconnect-failed", {
- environmentId: connection.environmentId,
- reason,
- error: error instanceof Error ? error.message : String(error),
- });
- });
- continue;
- }
-
- setEnvironmentConnectionStatus(connection.environmentId, "reconnecting", null);
- shellSnapshotManager.markPending({ environmentId: connection.environmentId });
- void session.connection.reconnect().catch((error: unknown) => {
- const message =
- error instanceof Error ? error.message : "Failed to reconnect remote environment.";
- setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message);
- terminalDebugLog("registry:app-resume-reconnect-failed", {
- environmentId: connection.environmentId,
- reason,
- error: message,
- });
- });
- }
-}
-
-function subscribeAppResumeReconnects(): () => void {
- let previousAppState = AppState.currentState;
- const subscription = AppState.addEventListener("change", (nextAppState) => {
- const wasInactive = previousAppState !== "active";
- previousAppState = nextAppState;
- if (nextAppState === "active" && wasInactive) {
- reconnectEnvironmentConnectionsAfterAppResume("appstate");
- }
- });
-
- return () => subscription.remove();
-}
-
-const environmentsSortOrder = Order.mapInput(
- Order.Struct({
- environmentLabel: Order.String,
- }),
- (environment: ConnectedEnvironmentSummary) => ({
- environmentLabel: environment.environmentLabel,
- }),
-);
-
-function deriveConnectedEnvironments(
- savedConnectionsById: Record,
- environmentStateById: Record,
-): ReadonlyArray {
- return Arr.sort(
- Object.values(savedConnectionsById).map((connection) => {
- const runtime = environmentStateById[connection.environmentId];
- return {
- environmentId: connection.environmentId,
- environmentLabel: connection.environmentLabel,
- displayUrl: connection.displayUrl,
- isRelayManaged: isRelayManagedConnection(connection),
- connectionState: runtime?.connectionState ?? "idle",
- connectionError: runtime?.connectionError ?? null,
- };
- }),
- environmentsSortOrder,
+ return toSavedConnection(
+ projectWorkspaceEnvironment(projectEnvironmentPresentation(environmentId, presentation)),
);
}
-export function useRemoteEnvironmentBootstrap() {
- useEffect(() => {
- let cancelled = false;
- const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects();
-
- void (async () => {
- try {
- const connections = await loadSavedConnections();
- if (cancelled) {
- return;
- }
-
- replaceSavedConnections(
- Object.fromEntries(
- connections.map((connection) => [connection.environmentId, connection]),
- ),
- );
-
- setIsLoadingSavedConnection(false);
-
- await Promise.all(
- connections.map(async (connection) => {
- const cached = await loadCachedShellSnapshot(connection.environmentId);
- if (!cancelled && cached) {
- hydrateCachedShellSnapshot(cached);
- }
- }),
- );
-
- if (cancelled) {
- return;
- }
-
- await mobileRuntime.runPromise(
- Effect.all(
- connections.map((connection) =>
- connectSavedEnvironment(connection, {
- persist: false,
- suppressBootstrapError: true,
- }),
- ),
- { concurrency: "unbounded" },
- ),
- );
- } catch {
- if (!cancelled) {
- setIsLoadingSavedConnection(false);
- }
- }
- })();
-
- return () => {
- cancelled = true;
- unsubscribeAppResumeReconnects();
- for (const session of drainEnvironmentSessions()) {
- void session.connection.dispose();
- }
- for (const unsubscribe of terminalMetadataUnsubscribers.values()) {
- unsubscribe();
- }
- terminalMetadataUnsubscribers.clear();
- environmentConnectionAttempts.clear();
- unregisterAllAgentAwarenessConnections();
- environmentRuntimeManager.invalidate();
- shellSnapshotManager.invalidate();
- resetSourceControlDiscoveryState();
- terminalSessionManager.invalidate();
- notifyEnvironmentConnectionListeners();
- };
- }, []);
-}
-
-export function useRemoteEnvironmentState() {
- const state = useRemoteEnvironmentLocalState();
- const environmentStateById = useEnvironmentRuntimeStates(
- Object.values(state.savedConnectionsById).map((connection) => connection.environmentId),
- );
-
- return useMemo(
- () => ({
- ...state,
- environmentStateById,
- }),
- [environmentStateById, state],
- );
+export function useRemoteEnvironmentRuntime(
+ environmentId: EnvironmentId | null,
+): EnvironmentRuntimeState | null {
+ const { presentation } = useEnvironmentPresentation(environmentId);
+ const serverConfig = useEnvironmentServerConfig(environmentId);
+ if (environmentId === null || presentation === null) {
+ return null;
+ }
+ return toRuntimeState(projectEnvironmentPresentation(environmentId, presentation), serverConfig);
}
export function useRemoteConnectionStatus() {
- const { environmentStateById, pendingConnectionError, savedConnectionsById } =
- useRemoteEnvironmentState();
-
- const connectedEnvironments = useMemo(
- () => deriveConnectedEnvironments(savedConnectionsById, environmentStateById),
- [environmentStateById, savedConnectionsById],
- );
-
- const connectionState = useMemo(() => {
- if (connectedEnvironments.length === 0) {
- return "idle";
- }
- if (connectedEnvironments.some((environment) => environment.connectionState === "ready")) {
- return "ready";
- }
- if (
- connectedEnvironments.some((environment) => environment.connectionState === "reconnecting")
- ) {
- return "reconnecting";
- }
- if (connectedEnvironments.some((environment) => environment.connectionState === "connecting")) {
- return "connecting";
- }
- return "disconnected";
- }, [connectedEnvironments]);
-
- const connectionError = useMemo(
+ const workspace = useWorkspaceState();
+ const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
+ const connectedEnvironments = useMemo>(
() =>
- pipe(
- Arr.appendAll(
- [pendingConnectionError],
- Arr.map(connectedEnvironments, (environment) => environment.connectionError),
- ),
- Arr.findFirst((value) => value !== null),
- Option.getOrNull,
- ),
- [connectedEnvironments, pendingConnectionError],
+ workspace.environments.map((environment) => ({
+ environmentId: environment.environmentId,
+ environmentLabel: environment.environmentLabel,
+ displayUrl: environment.displayUrl,
+ isRelayManaged: environment.isRelayManaged,
+ connectionState: environment.connectionState,
+ connectionError: environment.connectionError,
+ connectionErrorTraceId: environment.connectionErrorTraceId,
+ })),
+ [workspace.environments],
);
return {
connectedEnvironments,
- connectionState,
- connectionError,
+ connectionState: workspace.state.connectionState,
+ connectionError: pendingConnectionError ?? workspace.state.connectionError,
};
}
export function useRemoteConnections() {
- const { connectionPairingUrl, pendingConnectionError } = useRemoteEnvironmentState();
+ const controller = useConnectionController();
+ const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom);
+ const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus();
+ const onChangeConnectionPairingUrl = useCallback((pairingUrl: string) => {
+ appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl);
+ }, []);
+
const onConnectPress = useCallback(
async (pairingUrl?: string) => {
try {
const nextPairingUrl = pairingUrl ?? connectionPairingUrl;
- const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl });
- clearPendingConnectionError();
- await mobileRuntime.runPromise(connectSavedEnvironment(connection));
- clearConnectionPairingUrl();
+ setPendingConnectionError(null);
+ await controller.connectPairingUrl(nextPairingUrl);
+ appAtomRegistry.set(connectionPairingUrlAtom, "");
} catch (error) {
- setPendingConnectionError(
- error instanceof Error ? error.message : "Failed to pair with the environment.",
- );
+ const message =
+ error instanceof Error ? error.message : "Failed to pair with the environment.";
+ setPendingConnectionError(message);
throw error;
}
},
- [connectionPairingUrl],
+ [connectionPairingUrl, controller],
);
- const onUpdateEnvironment = useCallback(
- async (
- environmentId: EnvironmentId,
- updates: { readonly label: string; readonly displayUrl: string },
- ) => {
- const connection = getSavedConnectionsById()[environmentId];
- if (!connection || isRelayManagedConnection(connection)) {
- return;
- }
-
- const updated: SavedRemoteConnection = {
- ...connection,
- environmentLabel: updates.label.trim() || connection.environmentLabel,
- displayUrl: updates.displayUrl.trim() || connection.displayUrl,
- };
-
- await saveConnection(updated);
- upsertSavedConnection(updated);
+ const onReconnectEnvironment = useCallback(
+ (environmentId: EnvironmentId) => {
+ void controller.retryEnvironment(environmentId);
},
- [],
+ [controller],
);
- const onReconnectEnvironment = useCallback((environmentId: EnvironmentId) => {
- const connection = getSavedConnectionsById()[environmentId];
- if (!connection) {
- return;
- }
- void mobileRuntime
- .runPromise(
- connectSavedEnvironment(connection, {
- persist: false,
- suppressBootstrapError: true,
- }),
- )
- .catch(() => undefined);
- }, []);
-
- const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => {
- const connection = getSavedConnectionsById()[environmentId];
- if (!connection) {
- return;
- }
-
- Alert.alert(
- "Remove environment?",
- `Disconnect and forget ${connection.environmentLabel} on this device.`,
- [
- { text: "Cancel", style: "cancel" },
- {
- text: "Remove",
- style: "destructive",
- onPress: () => {
- void mobileRuntime
- .runPromise(disconnectEnvironment(environmentId, { removeSaved: true }))
- .catch(() => undefined);
+ const onRemoveEnvironmentPress = useCallback(
+ (environmentId: EnvironmentId) => {
+ const environment = connectedEnvironments.find(
+ (candidate) => candidate.environmentId === environmentId,
+ );
+ if (!environment) {
+ return;
+ }
+ Alert.alert(
+ "Remove environment?",
+ `Disconnect and forget ${environment.environmentLabel} on this device.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Remove",
+ style: "destructive",
+ onPress: () => {
+ void controller.removeEnvironment(environmentId);
+ },
},
- },
- ],
- );
- }, []);
+ ],
+ );
+ },
+ [connectedEnvironments, controller],
+ );
return {
connectionPairingUrl,
@@ -777,10 +200,10 @@ export function useRemoteConnections() {
pairingConnectionError: pendingConnectionError,
connectedEnvironments,
connectedEnvironmentCount: connectedEnvironments.length,
- onChangeConnectionPairingUrl: setConnectionPairingUrl,
+ onChangeConnectionPairingUrl,
onConnectPress,
onReconnectEnvironment,
- onUpdateEnvironment,
+ onUpdateEnvironment: () => undefined,
onRemoveEnvironmentPress,
};
}
diff --git a/apps/mobile/src/state/use-selected-thread-commands.ts b/apps/mobile/src/state/use-selected-thread-commands.ts
index a28d33c65d1..c551a9f089f 100644
--- a/apps/mobile/src/state/use-selected-thread-commands.ts
+++ b/apps/mobile/src/state/use-selected-thread-commands.ts
@@ -1,16 +1,13 @@
+import { useAtomSet } from "@effect/atom-react";
import { useCallback } from "react";
import {
- CommandId,
type ModelSelection,
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
-import { uuidv4 } from "../lib/uuid";
-import { environmentRuntimeManager } from "./use-environment-runtime";
-import { getEnvironmentClient } from "./environment-session-registry";
-import { useRemoteEnvironmentState } from "./use-remote-environment-registry";
+import { threadEnvironment } from "../state/threads";
import { useThreadSelection } from "./use-thread-selection";
export function useSelectedThreadCommands(input: {
@@ -19,43 +16,18 @@ export function useSelectedThreadCommands(input: {
readonly cwd?: string | null;
}) => Promise;
}) {
+ const updateMetadata = useAtomSet(threadEnvironment.updateMetadata, { mode: "promise" });
+ const setRuntimeMode = useAtomSet(threadEnvironment.setRuntimeMode, { mode: "promise" });
+ const setInteractionMode = useAtomSet(threadEnvironment.setInteractionMode, { mode: "promise" });
+ const interruptTurn = useAtomSet(threadEnvironment.interruptTurn, { mode: "promise" });
const { refreshSelectedThreadGitStatus } = input;
const { selectedThread } = useThreadSelection();
- const { savedConnectionsById } = useRemoteEnvironmentState();
const onRefresh = useCallback(async () => {
- const targets = selectedThread
- ? [selectedThread.environmentId]
- : Object.values(savedConnectionsById).map((connection) => connection.environmentId);
-
- await Promise.all(
- targets.map(async (environmentId) => {
- const client = getEnvironmentClient(environmentId);
- if (!client) {
- return;
- }
-
- try {
- const serverConfig = await client.server.getConfig();
- environmentRuntimeManager.patch({ environmentId }, (current) => ({
- ...current,
- serverConfig,
- connectionError: null,
- }));
- } catch (error) {
- environmentRuntimeManager.patch({ environmentId }, (current) => ({
- ...current,
- connectionError:
- error instanceof Error ? error.message : "Failed to refresh remote data.",
- }));
- }
- }),
- );
-
if (selectedThread) {
await refreshSelectedThreadGitStatus({ quiet: true });
}
- }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]);
+ }, [refreshSelectedThreadGitStatus, selectedThread]);
const onUpdateThreadModelSelection = useCallback(
async (modelSelection: ModelSelection) => {
@@ -63,19 +35,15 @@ export function useSelectedThreadCommands(input: {
return;
}
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return;
- }
-
- await client.orchestration.dispatchCommand({
- type: "thread.meta.update",
- commandId: CommandId.make(uuidv4()),
- threadId: selectedThread.id,
- modelSelection,
+ await updateMetadata({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ modelSelection,
+ },
});
},
- [selectedThread],
+ [selectedThread, updateMetadata],
);
const onUpdateThreadRuntimeMode = useCallback(
@@ -84,20 +52,15 @@ export function useSelectedThreadCommands(input: {
return;
}
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return;
- }
-
- await client.orchestration.dispatchCommand({
- type: "thread.runtime-mode.set",
- commandId: CommandId.make(uuidv4()),
- threadId: selectedThread.id,
- runtimeMode,
- createdAt: new Date().toISOString(),
+ await setRuntimeMode({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ runtimeMode,
+ },
});
},
- [selectedThread],
+ [selectedThread, setRuntimeMode],
);
const onUpdateThreadInteractionMode = useCallback(
@@ -106,20 +69,15 @@ export function useSelectedThreadCommands(input: {
return;
}
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return;
- }
-
- await client.orchestration.dispatchCommand({
- type: "thread.interaction-mode.set",
- commandId: CommandId.make(uuidv4()),
- threadId: selectedThread.id,
- interactionMode,
- createdAt: new Date().toISOString(),
+ await setInteractionMode({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ interactionMode,
+ },
});
},
- [selectedThread],
+ [selectedThread, setInteractionMode],
);
const onStopThread = useCallback(async () => {
@@ -127,11 +85,6 @@ export function useSelectedThreadCommands(input: {
return;
}
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return;
- }
-
if (
selectedThread.session?.status !== "running" &&
selectedThread.session?.status !== "starting"
@@ -139,16 +92,16 @@ export function useSelectedThreadCommands(input: {
return;
}
- await client.orchestration.dispatchCommand({
- type: "thread.turn.interrupt",
- commandId: CommandId.make(uuidv4()),
- threadId: selectedThread.id,
- ...(selectedThread.session?.activeTurnId
- ? { turnId: selectedThread.session.activeTurnId }
- : {}),
- createdAt: new Date().toISOString(),
+ await interruptTurn({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ ...(selectedThread.session?.activeTurnId
+ ? { turnId: selectedThread.session.activeTurnId }
+ : {}),
+ },
});
- }, [selectedThread]);
+ }, [interruptTurn, selectedThread]);
const onRenameThread = useCallback(
async (title: string) => {
@@ -156,24 +109,20 @@ export function useSelectedThreadCommands(input: {
return;
}
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return;
- }
-
const trimmed = title.trim();
if (!trimmed || trimmed === selectedThread.title) {
return;
}
- await client.orchestration.dispatchCommand({
- type: "thread.meta.update",
- commandId: CommandId.make(uuidv4()),
- threadId: selectedThread.id,
- title: trimmed,
+ await updateMetadata({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ title: trimmed,
+ },
});
},
- [selectedThread],
+ [selectedThread, updateMetadata],
);
return {
diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts
index 18860935f36..d1791dcc5c1 100644
--- a/apps/mobile/src/state/use-selected-thread-git-actions.ts
+++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts
@@ -1,32 +1,54 @@
-import { useCallback, useEffect } from "react";
+import { useAtomSet } from "@effect/atom-react";
+import { useCallback, useEffect, useMemo } from "react";
+import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
- EnvironmentScopedProjectShell,
- EnvironmentScopedThreadShell,
- type VcsRef,
type GitActionRequestInput,
-} from "@t3tools/client-runtime";
-import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts";
+ type VcsActionOperation,
+ type VcsRef,
+} from "@t3tools/client-runtime/state/vcs";
+import type { GitRunStackedActionResult } from "@t3tools/contracts";
import {
dedupeRemoteBranchesWithLocalMatches,
sanitizeFeatureBranchName,
} from "@t3tools/shared/git";
+import { gitEnvironment } from "../state/git";
+import { useBranches } from "../state/queries";
+import { threadEnvironment } from "../state/threads";
+import { vcsEnvironment } from "../state/vcs";
import { uuidv4 } from "../lib/uuid";
-import { getEnvironmentClient } from "./environment-session-registry";
import { setPendingConnectionError } from "./use-remote-environment-registry";
-import { vcsActionManager, showGitActionResult } from "./use-vcs-action-state";
-import { vcsRefManager } from "./use-vcs-refs";
-import { vcsStatusManager } from "./use-vcs-status";
+import {
+ beginVcsAction,
+ completeVcsAction,
+ failVcsAction,
+ showGitActionResult,
+} from "./use-vcs-action-state";
import { useThreadSelection } from "./use-thread-selection";
import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";
export function useSelectedThreadGitActions() {
+ const runStackedAction = useAtomSet(gitEnvironment.runStackedAction, { mode: "promise" });
+ const updateThreadMetadata = useAtomSet(threadEnvironment.updateMetadata, { mode: "promise" });
+ const refreshStatus = useAtomSet(vcsEnvironment.refreshStatus, { mode: "promise" });
+ const switchRef = useAtomSet(vcsEnvironment.switchRef, { mode: "promise" });
+ const createRef = useAtomSet(vcsEnvironment.createRef, { mode: "promise" });
+ const createWorktree = useAtomSet(vcsEnvironment.createWorktree, { mode: "promise" });
+ const pull = useAtomSet(vcsEnvironment.pull, { mode: "promise" });
const { selectedThread, selectedThreadProject } = useThreadSelection();
const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null;
-
+ const branchTarget = useMemo(
+ () => ({
+ environmentId: selectedThread?.environmentId ?? null,
+ cwd: selectedThreadGitRootCwd,
+ query: null,
+ }),
+ [selectedThread?.environmentId, selectedThreadGitRootCwd],
+ );
+ const branchState = useBranches(branchTarget);
const updateThreadGitContext = useCallback(
async (
thread: NonNullable,
@@ -35,20 +57,16 @@ export function useSelectedThreadGitActions() {
readonly worktreePath?: string | null;
},
) => {
- const client = getEnvironmentClient(thread.environmentId);
- if (!client) {
- return;
- }
-
- await client.orchestration.dispatchCommand({
- type: "thread.meta.update",
- commandId: CommandId.make(uuidv4()),
- threadId: thread.id,
- ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}),
- ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}),
+ await updateThreadMetadata({
+ environmentId: thread.environmentId,
+ input: {
+ threadId: thread.id,
+ ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}),
+ ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}),
+ },
});
},
- [],
+ [updateThreadMetadata],
);
const refreshSelectedThreadGitStatus = useCallback(
@@ -62,61 +80,72 @@ export function useSelectedThreadGitActions() {
return null;
}
+ const target = { environmentId: selectedThread.environmentId, cwd };
+ if (!options?.quiet) {
+ beginVcsAction(target, {
+ operation: "refresh_status",
+ label: "Refreshing source control status",
+ });
+ }
try {
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return null;
+ const result = await refreshStatus({
+ environmentId: selectedThread.environmentId,
+ input: { cwd },
+ });
+ if (!options?.quiet) {
+ completeVcsAction(target);
}
-
- const status = await vcsActionManager.refreshStatus(
- { environmentId: selectedThread.environmentId, cwd },
- { ...client.vcs, runChangeRequest: client.git.runStackedAction },
- options,
- );
setPendingConnectionError(null);
- return status;
+ return result;
} catch (error) {
+ if (!options?.quiet) {
+ failVcsAction(target, "refresh_status", error);
+ }
const message = error instanceof Error ? error.message : "Failed to refresh git status.";
setPendingConnectionError(message);
return null;
}
},
- [selectedThread, selectedThreadCwd, selectedThreadProject],
+ [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject],
);
useEffect(() => {
if (!selectedThread || !selectedThreadProject) {
return;
}
-
void refreshSelectedThreadGitStatus({ quiet: true });
}, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]);
const runSelectedThreadGitMutation = useCallback(
async (
- operation: (input: {
- readonly thread: EnvironmentScopedThreadShell;
- readonly project: EnvironmentScopedProjectShell;
+ operation: VcsActionOperation,
+ label: string,
+ execute: (input: {
+ readonly thread: EnvironmentThreadShell;
+ readonly project: EnvironmentProject;
readonly cwd: string;
}) => Promise,
): Promise => {
- if (!selectedThread || !selectedThreadProject) {
- return null;
- }
-
- const cwd = selectedThreadCwd;
- if (!cwd) {
+ if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) {
return null;
}
+ const target = {
+ environmentId: selectedThread.environmentId,
+ cwd: selectedThreadCwd,
+ };
+ beginVcsAction(target, { operation, label });
try {
setPendingConnectionError(null);
- return await operation({
+ const result = await execute({
thread: selectedThread,
project: selectedThreadProject,
- cwd,
+ cwd: selectedThreadCwd,
});
+ completeVcsAction(target);
+ return result;
} catch (error) {
+ failVcsAction(target, operation, error);
const message = error instanceof Error ? error.message : "Git action failed.";
setPendingConnectionError(message);
showGitActionResult({ type: "error", title: "Git action failed", description: message });
@@ -127,37 +156,16 @@ export function useSelectedThreadGitActions() {
);
const refreshSelectedThreadBranches = useCallback(async (): Promise> => {
- if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) {
- return [];
- }
-
- const client = getEnvironmentClient(selectedThread.environmentId);
- if (!client) {
- return [];
- }
-
- try {
- const result = await vcsRefManager.load(
- { environmentId: selectedThread.environmentId, cwd: selectedThreadGitRootCwd, query: null },
- client.vcs,
- { limit: 100 },
- );
- return dedupeRemoteBranchesWithLocalMatches(result?.refs ?? []).filter(
- (branch) => !branch.isRemote,
- );
- } catch (error) {
- setPendingConnectionError(
- error instanceof Error ? error.message : "Failed to load branches.",
- );
- return [];
- }
- }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject]);
+ branchState.refresh();
+ return dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []).filter(
+ (branch) => !branch.isRemote,
+ );
+ }, [branchState]);
const syncSelectedThreadBranchState = useCallback(
async (input: {
- readonly thread: EnvironmentScopedThreadShell;
+ readonly thread: EnvironmentThreadShell;
readonly cwd: string;
- readonly branchRootCwd?: string | null;
readonly nextThreadState?: {
readonly branch?: string | null;
readonly worktreePath?: string | null;
@@ -166,104 +174,109 @@ export function useSelectedThreadGitActions() {
if (input.nextThreadState) {
await updateThreadGitContext(input.thread, input.nextThreadState);
}
-
- const branchRootCwd = input.branchRootCwd ?? selectedThreadProject?.workspaceRoot ?? null;
- if (branchRootCwd) {
- vcsRefManager.invalidate({
- environmentId: input.thread.environmentId,
- cwd: branchRootCwd,
- query: null,
- });
- await refreshSelectedThreadBranches();
- }
-
+ branchState.refresh();
await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd });
},
- [
- refreshSelectedThreadBranches,
- refreshSelectedThreadGitStatus,
- selectedThreadProject?.workspaceRoot,
- updateThreadGitContext,
- ],
+ [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext],
);
const onCheckoutSelectedThreadBranch = useCallback(
async (branch: string) => {
- await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
- const result = await vcsActionManager.switchRef(
- { environmentId: thread.environmentId, cwd },
- { refName: branch },
- );
- await syncSelectedThreadBranchState({
- thread,
- cwd,
- nextThreadState: {
- branch: result?.refName ?? thread.branch,
- worktreePath: selectedThreadWorktreePath,
- },
- });
- });
+ await runSelectedThreadGitMutation(
+ "switch_ref",
+ "Switching branch",
+ async ({ thread, cwd }) => {
+ const result = await switchRef({
+ environmentId: thread.environmentId,
+ input: { cwd, refName: branch },
+ });
+ await syncSelectedThreadBranchState({
+ thread,
+ cwd,
+ nextThreadState: {
+ branch: result.refName ?? thread.branch,
+ worktreePath: selectedThreadWorktreePath,
+ },
+ });
+ },
+ );
},
- [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState],
+ [
+ runSelectedThreadGitMutation,
+ selectedThreadWorktreePath,
+ syncSelectedThreadBranchState,
+ switchRef,
+ ],
);
const onCreateSelectedThreadBranch = useCallback(
async (branch: string) => {
- await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
- const result = await vcsActionManager.createRef(
- { environmentId: thread.environmentId, cwd },
- {
- refName: branch,
- switchRef: true,
- },
- );
- await syncSelectedThreadBranchState({
- thread,
- cwd,
- nextThreadState: {
- branch: result?.refName ?? thread.branch,
- worktreePath: selectedThreadWorktreePath,
- },
- });
- });
+ await runSelectedThreadGitMutation(
+ "create_ref",
+ "Creating branch",
+ async ({ thread, cwd }) => {
+ const result = await createRef({
+ environmentId: thread.environmentId,
+ input: { cwd, refName: branch, switchRef: true },
+ });
+ await syncSelectedThreadBranchState({
+ thread,
+ cwd,
+ nextThreadState: {
+ branch: result.refName ?? thread.branch,
+ worktreePath: selectedThreadWorktreePath,
+ },
+ });
+ },
+ );
},
- [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState],
+ [
+ runSelectedThreadGitMutation,
+ selectedThreadWorktreePath,
+ syncSelectedThreadBranchState,
+ createRef,
+ ],
);
const onCreateSelectedThreadWorktree = useCallback(
async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => {
- await runSelectedThreadGitMutation(async ({ thread, project }) => {
- const result = await vcsActionManager.createWorktree(
- { environmentId: thread.environmentId, cwd: project.workspaceRoot },
- {
- refName: nextWorktree.baseBranch,
- newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch),
- path: null,
- },
- );
- if (!result) {
- return;
- }
-
- await syncSelectedThreadBranchState({
- thread,
- cwd: result.worktree.path,
- branchRootCwd: project.workspaceRoot,
- nextThreadState: {
- branch: result.worktree.refName,
- worktreePath: result.worktree.path,
- },
- });
- });
+ await runSelectedThreadGitMutation(
+ "create_worktree",
+ "Creating worktree",
+ async ({ thread, project }) => {
+ const result = await createWorktree({
+ environmentId: thread.environmentId,
+ input: {
+ cwd: project.workspaceRoot,
+ refName: nextWorktree.baseBranch,
+ newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch),
+ path: null,
+ },
+ });
+ await syncSelectedThreadBranchState({
+ thread,
+ cwd: result.worktree.path,
+ nextThreadState: {
+ branch: result.worktree.refName,
+ worktreePath: result.worktree.path,
+ },
+ });
+ },
+ );
},
- [runSelectedThreadGitMutation, syncSelectedThreadBranchState],
+ [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState],
);
const onPullSelectedThreadBranch = useCallback(async () => {
- await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
- const result = await vcsActionManager.pull({ environmentId: thread.environmentId, cwd });
- await refreshSelectedThreadGitStatus({ quiet: true, cwd });
- if (result) {
+ await runSelectedThreadGitMutation(
+ "pull",
+ "Pulling latest changes",
+ async ({ thread, cwd }) => {
+ const result = await pull({
+ environmentId: thread.environmentId,
+ input: { cwd },
+ });
+ await refreshSelectedThreadGitStatus({ quiet: true, cwd });
showGitActionResult({
type: "success",
title:
@@ -271,57 +284,60 @@ export function useSelectedThreadGitActions() {
? "Already up to date"
: `Pulled latest on ${result.refName}`,
});
- }
- });
- }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]);
+ },
+ );
+ }, [pull, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]);
const onRunSelectedThreadGitAction = useCallback(
async (input: GitActionRequestInput): Promise => {
- return await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
- const result = await vcsActionManager.runChangeRequest(
- { environmentId: thread.environmentId, cwd },
- {
- actionId: uuidv4(),
- action: input.action,
- ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
- ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
- ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
- },
- {
- gitStatus: vcsStatusManager.getSnapshot({
- environmentId: thread.environmentId,
+ return await runSelectedThreadGitMutation(
+ "run_change_request",
+ "Running source control action",
+ async ({ thread, cwd }) => {
+ const event = await runStackedAction({
+ environmentId: thread.environmentId,
+ input: {
cwd,
- }).data,
- },
- );
- if (!result) {
- return null;
- }
-
- showGitActionResult({
- type: "success",
- title: result.toast.title,
- description: result.toast.description,
- prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined,
- });
-
- if (result.branch.status === "created" && result.branch.name) {
- await syncSelectedThreadBranchState({
- thread,
- cwd,
- nextThreadState: {
- branch: result.branch.name,
- worktreePath: selectedThreadWorktreePath,
+ actionId: uuidv4(),
+ action: input.action,
+ ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
+ ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
+ ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
},
});
- return result;
- }
+ if (event.kind === "action_failed") {
+ throw new Error(event.message);
+ }
+ if (event.kind !== "action_finished") {
+ throw new Error("Source control action ended without a result.");
+ }
- await refreshSelectedThreadGitStatus({ quiet: true, cwd });
- return result;
- });
+ const result = event.result;
+ showGitActionResult({
+ type: "success",
+ title: result.toast.title,
+ description: result.toast.description,
+ prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined,
+ });
+
+ if (result.branch.status === "created" && result.branch.name) {
+ await syncSelectedThreadBranchState({
+ thread,
+ cwd,
+ nextThreadState: {
+ branch: result.branch.name,
+ worktreePath: selectedThreadWorktreePath,
+ },
+ });
+ } else {
+ await refreshSelectedThreadGitStatus({ quiet: true, cwd });
+ }
+ return result;
+ },
+ );
},
[
+ runStackedAction,
refreshSelectedThreadGitStatus,
runSelectedThreadGitMutation,
selectedThreadWorktreePath,
diff --git a/apps/mobile/src/state/use-selected-thread-git-state.ts b/apps/mobile/src/state/use-selected-thread-git-state.ts
index 6c855a3ebf7..a8c037db6f7 100644
--- a/apps/mobile/src/state/use-selected-thread-git-state.ts
+++ b/apps/mobile/src/state/use-selected-thread-git-state.ts
@@ -2,9 +2,10 @@ import { useMemo } from "react";
import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
+import { useBranches } from "./queries";
+import { useEnvironmentQuery } from "./query";
+import { sourceControlEnvironment } from "./sourceControl";
import { useVcsActionState } from "./use-vcs-action-state";
-import { useVcsRefs } from "./use-vcs-refs";
-import { useSourceControlDiscovery } from "./use-source-control-discovery";
import { useThreadSelection } from "./use-thread-selection";
import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";
@@ -20,7 +21,14 @@ export function useSelectedThreadGitState() {
[selectedThread?.environmentId, selectedThreadCwd],
);
const gitActionState = useVcsActionState(selectedThreadGitTarget);
- const sourceControlDiscovery = useSourceControlDiscovery(selectedThread?.environmentId ?? null);
+ const sourceControlDiscovery = useEnvironmentQuery(
+ selectedThread === null
+ ? null
+ : sourceControlEnvironment.discovery({
+ environmentId: selectedThread.environmentId,
+ input: {},
+ }),
+ );
const selectedThreadBranchTarget = useMemo(
() => ({
@@ -30,7 +38,7 @@ export function useSelectedThreadGitState() {
}),
[selectedThread?.environmentId, selectedThreadProject?.workspaceRoot],
);
- const selectedThreadBranchState = useVcsRefs(selectedThreadBranchTarget);
+ const selectedThreadBranchState = useBranches(selectedThreadBranchTarget);
const selectedThreadBranches = useMemo(
() =>
dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.refs ?? []).filter(
diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts
index 232135b6a7e..51c0fd35515 100644
--- a/apps/mobile/src/state/use-selected-thread-requests.ts
+++ b/apps/mobile/src/state/use-selected-thread-requests.ts
@@ -1,9 +1,10 @@
-import { useAtomValue } from "@effect/atom-react";
+import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { useCallback, useMemo, useState } from "react";
-import { ApprovalRequestId, CommandId, type ProviderApprovalDecision } from "@t3tools/contracts";
+import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";
+import { threadEnvironment } from "../state/threads";
import { scopedRequestKey } from "../lib/scopedEntities";
import {
buildPendingUserInputAnswers,
@@ -12,9 +13,7 @@ import {
setPendingUserInputCustomAnswer,
type PendingUserInputDraftAnswer,
} from "../lib/threadActivity";
-import { uuidv4 } from "../lib/uuid";
import { appAtomRegistry } from "./atom-registry";
-import { getEnvironmentClient } from "./environment-session-registry";
import { useSelectedThreadDetail } from "./use-thread-detail";
import { useThreadSelection } from "./use-thread-selection";
@@ -54,6 +53,8 @@ function setUserInputDraftCustomAnswer(
}
export function useSelectedThreadRequests() {
+ const respondToApproval = useAtomSet(threadEnvironment.respondToApproval, { mode: "promise" });
+ const respondToUserInput = useAtomSet(threadEnvironment.respondToUserInput, { mode: "promise" });
const { selectedThread: selectedThreadShell } = useThreadSelection();
const selectedThread = useSelectedThreadDetail();
const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
@@ -112,26 +113,21 @@ export function useSelectedThreadRequests() {
return;
}
- const client = getEnvironmentClient(selectedThreadShell.environmentId);
- if (!client) {
- return;
- }
-
setRespondingApprovalId(requestId);
try {
- await client.orchestration.dispatchCommand({
- type: "thread.approval.respond",
- commandId: CommandId.make(uuidv4()),
- threadId: selectedThreadShell.id,
- requestId,
- decision,
- createdAt: new Date().toISOString(),
+ await respondToApproval({
+ environmentId: selectedThreadShell.environmentId,
+ input: {
+ threadId: selectedThreadShell.id,
+ requestId,
+ decision,
+ },
});
} finally {
setRespondingApprovalId((current) => (current === requestId ? null : current));
}
},
- [selectedThreadShell],
+ [respondToApproval, selectedThreadShell],
);
const onSubmitUserInput = useCallback(async () => {
@@ -139,27 +135,27 @@ export function useSelectedThreadRequests() {
return;
}
- const client = getEnvironmentClient(selectedThreadShell.environmentId);
- if (!client) {
- return;
- }
-
setRespondingUserInputId(activePendingUserInput.requestId);
try {
- await client.orchestration.dispatchCommand({
- type: "thread.user-input.respond",
- commandId: CommandId.make(uuidv4()),
- threadId: selectedThreadShell.id,
- requestId: activePendingUserInput.requestId,
- answers: activePendingUserInputAnswers,
- createdAt: new Date().toISOString(),
+ await respondToUserInput({
+ environmentId: selectedThreadShell.environmentId,
+ input: {
+ threadId: selectedThreadShell.id,
+ requestId: activePendingUserInput.requestId,
+ answers: activePendingUserInputAnswers,
+ },
});
} finally {
setRespondingUserInputId((current) =>
current === activePendingUserInput.requestId ? null : current,
);
}
- }, [activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]);
+ }, [
+ activePendingUserInput,
+ activePendingUserInputAnswers,
+ respondToUserInput,
+ selectedThreadShell,
+ ]);
return {
activePendingApproval,
diff --git a/apps/mobile/src/state/use-shell-snapshot.ts b/apps/mobile/src/state/use-shell-snapshot.ts
deleted file mode 100644
index 56d69db7bfb..00000000000
--- a/apps/mobile/src/state/use-shell-snapshot.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import * as Arr from "effect/Array";
-import * as Order from "effect/Order";
-import { useAtomValue } from "@effect/atom-react";
-import { Atom } from "effect/unstable/reactivity";
-import {
- EMPTY_SHELL_SNAPSHOT_ATOM,
- EMPTY_SHELL_SNAPSHOT_STATE,
- createShellSnapshotManager,
- getShellSnapshotTargetKey,
- shellSnapshotStateAtom,
- type ShellSnapshotState,
-} from "@t3tools/client-runtime";
-import type { EnvironmentId } from "@t3tools/contracts";
-import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
-
-import { appAtomRegistry } from "./atom-registry";
-import type { CachedShellSnapshot } from "../lib/storage";
-
-const cachedShellSnapshotMetadataAtom = Atom.make<
- Readonly>
->({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:cached-shell-snapshot-metadata"));
-
-export const shellSnapshotManager = createShellSnapshotManager({
- getRegistry: () => appAtomRegistry,
-});
-
-export function hydrateCachedShellSnapshot(cached: CachedShellSnapshot): void {
- shellSnapshotManager.syncSnapshot({ environmentId: cached.environmentId }, cached.snapshot);
- appAtomRegistry.set(cachedShellSnapshotMetadataAtom, {
- ...appAtomRegistry.get(cachedShellSnapshotMetadataAtom),
- [cached.environmentId]: {
- snapshotReceivedAt: cached.snapshotReceivedAt,
- },
- });
-}
-
-export function markShellSnapshotLive(environmentId: EnvironmentId): void {
- const current = appAtomRegistry.get(cachedShellSnapshotMetadataAtom);
- if (current[environmentId] === undefined) {
- return;
- }
-
- const next = { ...current };
- delete next[environmentId];
- appAtomRegistry.set(cachedShellSnapshotMetadataAtom, next);
-}
-
-export function clearCachedShellSnapshotMetadata(environmentId: EnvironmentId): void {
- markShellSnapshotLive(environmentId);
-}
-
-export function useCachedShellSnapshotMetadata(): Readonly<
- Record
-> {
- return useAtomValue(cachedShellSnapshotMetadataAtom);
-}
-
-export function useShellSnapshot(environmentId: EnvironmentId | null): ShellSnapshotState {
- const targetKey = getShellSnapshotTargetKey({ environmentId });
- const state = useAtomValue(
- targetKey !== null ? shellSnapshotStateAtom(targetKey) : EMPTY_SHELL_SNAPSHOT_ATOM,
- );
- return targetKey === null ? EMPTY_SHELL_SNAPSHOT_STATE : state;
-}
-
-export function useShellSnapshotStates(
- environmentIds: ReadonlyArray,
-): Readonly> {
- const stableEnvironmentIds = useMemo(
- () => Arr.sort(new Set(environmentIds), Order.String),
- [environmentIds],
- );
- const snapshotCacheRef = useRef>>({});
-
- const subscribe = useCallback(
- (onStoreChange: () => void) => {
- const unsubs = stableEnvironmentIds.map((environmentId) =>
- appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), onStoreChange),
- );
- return () => {
- for (const unsub of unsubs) {
- unsub();
- }
- };
- },
- [stableEnvironmentIds],
- );
-
- const getSnapshot = useCallback(() => {
- const previous = snapshotCacheRef.current;
- let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length;
- const next: Record = {};
-
- for (const environmentId of stableEnvironmentIds) {
- const snapshot = shellSnapshotManager.getSnapshot({ environmentId });
- next[environmentId] = snapshot;
- if (!hasChanged && previous[environmentId] !== snapshot) {
- hasChanged = true;
- }
- }
-
- if (!hasChanged) {
- return previous;
- }
-
- snapshotCacheRef.current = next;
- return next;
- }, [stableEnvironmentIds]);
-
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
-}
diff --git a/apps/mobile/src/state/use-source-control-discovery.ts b/apps/mobile/src/state/use-source-control-discovery.ts
deleted file mode 100644
index 8f206be2cee..00000000000
--- a/apps/mobile/src/state/use-source-control-discovery.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { useAtomValue } from "@effect/atom-react";
-import {
- EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
- EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
- type SourceControlDiscoveryClient,
- type SourceControlDiscoveryState,
- type SourceControlDiscoveryTarget,
- createSourceControlDiscoveryManager,
- getSourceControlDiscoveryTargetKey,
- sourceControlDiscoveryStateAtom,
-} from "@t3tools/client-runtime";
-import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts";
-import { useEffect, useMemo } from "react";
-
-import { appAtomRegistry } from "./atom-registry";
-import {
- getEnvironmentClient,
- subscribeEnvironmentConnections,
-} from "./environment-session-registry";
-
-const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({
- getRegistry: () => appAtomRegistry,
- getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null,
- subscribeClientChanges: subscribeEnvironmentConnections,
-});
-
-function sourceControlDiscoveryTargetForEnvironment(
- environmentId: EnvironmentId | null,
-): SourceControlDiscoveryTarget {
- return { key: environmentId ?? null };
-}
-
-export function refreshSourceControlDiscoveryForEnvironment(
- environmentId: EnvironmentId | null,
- client?: SourceControlDiscoveryClient | null,
-): Promise {
- return sourceControlDiscoveryManager.refresh(
- sourceControlDiscoveryTargetForEnvironment(environmentId),
- client ?? undefined,
- );
-}
-
-export function invalidateSourceControlDiscoveryForEnvironment(
- environmentId: EnvironmentId | null,
-): void {
- sourceControlDiscoveryManager.invalidate(
- sourceControlDiscoveryTargetForEnvironment(environmentId),
- );
-}
-
-export function resetSourceControlDiscoveryState(): void {
- sourceControlDiscoveryManager.reset();
-}
-
-export function resetSourceControlDiscoveryStateForTests(): void {
- resetSourceControlDiscoveryState();
-}
-
-export function useSourceControlDiscovery(
- environmentId: EnvironmentId | null,
-): SourceControlDiscoveryState {
- const target = useMemo(
- () => sourceControlDiscoveryTargetForEnvironment(environmentId),
- [environmentId],
- );
-
- useEffect(() => {
- return sourceControlDiscoveryManager.watch(target);
- }, [target]);
-
- const targetKey = getSourceControlDiscoveryTargetKey(target);
- const state = useAtomValue(
- targetKey !== null
- ? sourceControlDiscoveryStateAtom(targetKey)
- : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
- );
- return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state;
-}
diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts
index 9ea13eef3e3..328557a2005 100644
--- a/apps/mobile/src/state/use-terminal-session.ts
+++ b/apps/mobile/src/state/use-terminal-session.ts
@@ -1,84 +1,82 @@
-import { useAtomValue } from "@effect/atom-react";
import {
- createTerminalSessionManager,
- EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM,
- EMPTY_TERMINAL_SESSION_ATOM,
- getKnownTerminalSessionTarget,
- getKnownTerminalSessionListFilter,
- knownTerminalSessionsAtom,
- terminalSessionStateAtom,
- type TerminalSessionTarget,
+ combineTerminalSessionState,
+ EMPTY_TERMINAL_BUFFER_STATE,
+ EMPTY_TERMINAL_SESSION_STATE,
+ type KnownTerminalSession,
type TerminalSessionState,
-} from "@t3tools/client-runtime";
-import type {
- EnvironmentId,
- TerminalAttachInput,
- TerminalAttachStreamEvent,
- TerminalMetadataStreamEvent,
- TerminalSessionSnapshot,
-} from "@t3tools/contracts";
+} from "@t3tools/client-runtime/state/terminal";
+import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts";
import { useMemo } from "react";
-import { appAtomRegistry } from "./atom-registry";
+import { useEnvironmentQuery } from "./query";
+import { terminalEnvironment } from "./terminal";
-export const terminalSessionManager = createTerminalSessionManager({
- getRegistry: () => appAtomRegistry,
-});
-
-export function subscribeTerminalMetadata(input: {
- readonly environmentId: EnvironmentId;
- readonly client: {
- readonly terminal: {
- readonly onMetadata: (
- listener: (event: TerminalMetadataStreamEvent) => void,
- options?: { readonly onResubscribe?: () => void },
- ) => () => void;
- };
- };
-}) {
- return terminalSessionManager.subscribeMetadata(input);
-}
-
-export function attachTerminalSession(input: {
- readonly environmentId: EnvironmentId;
- readonly client: Parameters[0]["client"];
- readonly terminal: TerminalAttachInput;
- readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void;
- readonly onEvent?: (event: TerminalAttachStreamEvent) => void;
-}) {
- return terminalSessionManager.attach({
- environmentId: input.environmentId,
- client: input.client,
- terminal: input.terminal,
- ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}),
- ...(input.onEvent ? { onEvent: input.onEvent } : {}),
- });
-}
-
-export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState {
- const target = getKnownTerminalSessionTarget(input);
- return useAtomValue(
- target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM,
+export function useAttachedTerminalSession(input: {
+ readonly environmentId: EnvironmentId | null;
+ readonly terminal: TerminalAttachInput | null;
+}): TerminalSessionState {
+ const attach = useEnvironmentQuery(
+ input.environmentId !== null && input.terminal !== null
+ ? terminalEnvironment.attach({
+ environmentId: input.environmentId,
+ input: input.terminal,
+ })
+ : null,
);
-}
-
-export function useTerminalSessionTarget(input: TerminalSessionTarget) {
- return useMemo(
- () => ({
- environmentId: input.environmentId,
- threadId: input.threadId,
- terminalId: input.terminalId,
- }),
- [input.environmentId, input.threadId, input.terminalId],
+ const metadata = useEnvironmentQuery(
+ input.environmentId === null
+ ? null
+ : terminalEnvironment.metadata({
+ environmentId: input.environmentId,
+ input: null,
+ }),
);
+
+ return useMemo(() => {
+ if (input.environmentId === null || input.terminal === null) {
+ return EMPTY_TERMINAL_SESSION_STATE;
+ }
+ const summary =
+ metadata.data?.find(
+ (terminal) =>
+ terminal.threadId === input.terminal?.threadId &&
+ terminal.terminalId === input.terminal?.terminalId,
+ ) ?? null;
+ const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE);
+ return attach.error === null ? state : { ...state, error: attach.error, status: "error" };
+ }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]);
}
export function useKnownTerminalSessions(input: {
- readonly environmentId: TerminalSessionTarget["environmentId"];
- readonly threadId: TerminalSessionTarget["threadId"];
-}) {
- const filter = getKnownTerminalSessionListFilter(input);
- return useAtomValue(
- filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM,
+ readonly environmentId: EnvironmentId | null;
+ readonly threadId: ThreadId | null;
+}): ReadonlyArray {
+ const metadata = useEnvironmentQuery(
+ input.environmentId === null
+ ? null
+ : terminalEnvironment.metadata({
+ environmentId: input.environmentId,
+ input: null,
+ }),
);
+ return useMemo(() => {
+ if (input.environmentId === null) {
+ return [];
+ }
+ return (metadata.data ?? [])
+ .filter((summary) => input.threadId === null || summary.threadId === input.threadId)
+ .map((summary) => ({
+ target: {
+ environmentId: input.environmentId!,
+ threadId: ThreadId.make(summary.threadId),
+ terminalId: summary.terminalId,
+ },
+ state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE),
+ }))
+ .sort((left, right) =>
+ left.target.terminalId.localeCompare(right.target.terminalId, undefined, {
+ numeric: true,
+ }),
+ );
+ }, [input.environmentId, input.threadId, metadata.data]);
}
diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts
index 7dfdc4cd57e..76adb564867 100644
--- a/apps/mobile/src/state/use-thread-composer-state.ts
+++ b/apps/mobile/src/state/use-thread-composer-state.ts
@@ -1,11 +1,13 @@
-import { useAtomValue } from "@effect/atom-react";
-import { useCallback, useEffect, useMemo } from "react";
+import { useAtomSet, useAtomValue } from "@effect/atom-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
+import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
import { Atom } from "effect/unstable/reactivity";
+import { threadEnvironment } from "../state/threads";
+import { useThreadShells } from "../state/entities";
import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
import {
convertPastedImagesToAttachments,
@@ -14,7 +16,7 @@ import {
} from "../lib/composerImages";
import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { scopedThreadKey } from "../lib/scopedEntities";
-import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity";
+import { buildThreadFeed } from "../lib/threadActivity";
import { appAtomRegistry } from "../state/atom-registry";
import {
appendComposerDraftAttachments,
@@ -26,25 +28,27 @@ import {
setComposerDraftText,
useComposerDraft,
} from "./use-composer-drafts";
-import { getEnvironmentClient } from "./environment-session-registry";
import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types";
import {
setPendingConnectionError,
useRemoteConnectionStatus,
} from "../state/use-remote-environment-registry";
-import { useRemoteCatalog } from "../state/use-remote-catalog";
import { useSelectedThreadDetail } from "../state/use-thread-detail";
import { useThreadSelection } from "../state/use-thread-selection";
+import {
+ enqueueThreadOutboxMessage,
+ ensureThreadOutboxLoaded,
+ removeThreadOutboxMessage,
+ threadOutboxRetryDelayMs,
+ type QueuedThreadMessage,
+ useThreadOutboxMessages,
+} from "./thread-outbox";
const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe(
Atom.keepAlive,
Atom.withLabel("mobile:thread-composer:dispatching-message-id"),
);
-const queuedMessagesByThreadKeyAtom = Atom.make>>(
- {},
-).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages"));
-
export function appendReviewCommentToDraft(input: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
@@ -85,44 +89,12 @@ function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void {
appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current);
}
-function enqueueQueuedMessage(message: QueuedThreadMessage): void {
- const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom);
- const threadKey = scopedThreadKey(message.environmentId, message.threadId);
- appAtomRegistry.set(queuedMessagesByThreadKeyAtom, {
- ...current,
- [threadKey]: [...(current[threadKey] ?? []), message],
- });
-}
-
-function removeQueuedMessage(
- environmentId: EnvironmentId,
- threadId: ThreadId,
- queuedMessageId: MessageId,
-): void {
- const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom);
- const threadKey = scopedThreadKey(environmentId, threadId);
- const existing = current[threadKey];
- if (!existing) {
- return;
- }
-
- const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId);
- const next = { ...current };
- if (nextQueue.length === 0) {
- delete next[threadKey];
- } else {
- next[threadKey] = nextQueue;
- }
-
- appAtomRegistry.set(queuedMessagesByThreadKeyAtom, next);
-}
-
function useQueueDrain(input: {
readonly dispatchingQueuedMessageId: MessageId | null;
readonly queuedMessagesByThreadKey: Record>;
- readonly threads: ReadonlyArray;
+ readonly threads: ReadonlyArray;
readonly environments: ReadonlyArray;
- readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise;
+ readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise;
}) {
const {
dispatchingQueuedMessageId,
@@ -131,6 +103,20 @@ function useQueueDrain(input: {
sendQueuedMessage,
threads,
} = input;
+ const [retryTick, setRetryTick] = useState(0);
+ const retryAttemptRef = useRef(new Map());
+ const retryNotBeforeRef = useRef(new Map());
+ const retryTimersRef = useRef(new Map>());
+
+ useEffect(
+ () => () => {
+ for (const timer of retryTimersRef.current.values()) {
+ clearTimeout(timer);
+ }
+ retryTimersRef.current.clear();
+ },
+ [],
+ );
useEffect(() => {
if (dispatchingQueuedMessageId !== null) {
@@ -142,6 +128,9 @@ function useQueueDrain(input: {
if (!nextQueuedMessage) {
continue;
}
+ if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {
+ continue;
+ }
const thread = threads.find(
(candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === threadKey,
@@ -153,7 +142,7 @@ function useQueueDrain(input: {
const environment = environments.find(
(candidate) => candidate.environmentId === nextQueuedMessage.environmentId,
);
- if (!environment || environment.connectionState !== "ready") {
+ if (!environment || environment.connectionState !== "connected") {
continue;
}
@@ -162,29 +151,62 @@ function useQueueDrain(input: {
continue;
}
- void sendQueuedMessage(nextQueuedMessage);
+ beginDispatchingQueuedMessage(nextQueuedMessage.messageId);
+ void sendQueuedMessage(nextQueuedMessage)
+ .then((sent) => {
+ if (sent) {
+ retryAttemptRef.current.delete(nextQueuedMessage.messageId);
+ retryNotBeforeRef.current.delete(nextQueuedMessage.messageId);
+ const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId);
+ if (pendingTimer !== undefined) {
+ clearTimeout(pendingTimer);
+ retryTimersRef.current.delete(nextQueuedMessage.messageId);
+ }
+ return;
+ }
+
+ const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1;
+ retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt);
+ const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt);
+ retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs);
+ const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId);
+ if (pendingTimer !== undefined) {
+ clearTimeout(pendingTimer);
+ }
+ const retryTimer = setTimeout(() => {
+ retryTimersRef.current.delete(nextQueuedMessage.messageId);
+ setRetryTick((current) => current + 1);
+ }, retryDelayMs);
+ retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer);
+ })
+ .finally(() => {
+ finishDispatchingQueuedMessage(nextQueuedMessage.messageId);
+ });
return;
}
}, [
dispatchingQueuedMessageId,
environments,
queuedMessagesByThreadKey,
+ retryTick,
sendQueuedMessage,
threads,
]);
}
export function useThreadComposerState() {
+ const startTurn = useAtomSet(threadEnvironment.startTurn, { mode: "promise" });
const { connectedEnvironments } = useRemoteConnectionStatus();
- const { threads } = useRemoteCatalog();
+ const threads = useThreadShells();
const { selectedThread: selectedThreadShell } = useThreadSelection();
- const selectedThread = useSelectedThreadDetail();
+ const selectedThreadDetail = useSelectedThreadDetail();
const composerDrafts = useAtomValue(composerDraftsAtom);
const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
- const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom);
+ const queuedMessagesByThreadKey = useThreadOutboxMessages();
useEffect(() => {
ensureComposerDraftsLoaded();
+ ensureThreadOutboxLoaded();
}, []);
const selectedThreadKey = selectedThreadShell
@@ -197,10 +219,14 @@ export function useThreadComposerState() {
const selectedThreadFeed = useMemo(
() =>
- selectedThread
- ? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId)
+ selectedThreadDetail
+ ? buildThreadFeed(
+ selectedThreadDetail,
+ selectedThreadQueuedMessages,
+ dispatchingQueuedMessageId,
+ )
: [],
- [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages],
+ [dispatchingQueuedMessageId, selectedThreadDetail, selectedThreadQueuedMessages],
);
const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
@@ -209,6 +235,7 @@ export function useThreadComposerState() {
const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
const selectedThreadSessionActivity = useMemo(() => {
+ const selectedThread = selectedThreadDetail ?? selectedThreadShell;
if (!selectedThread?.session) {
return null;
}
@@ -217,10 +244,11 @@ export function useThreadComposerState() {
orchestrationStatus: selectedThread.session.status,
activeTurnId: selectedThread.session.activeTurnId ?? undefined,
};
- }, [selectedThread]);
+ }, [selectedThreadDetail, selectedThreadShell]);
const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null;
const activeWorkStartedAt = useMemo(() => {
+ const selectedThread = selectedThreadDetail ?? selectedThreadShell;
if (!selectedThread) {
return null;
}
@@ -230,60 +258,60 @@ export function useThreadComposerState() {
selectedThreadSessionActivity,
queuedSendStartedAt,
);
- }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]);
+ }, [
+ queuedSendStartedAt,
+ selectedThreadDetail,
+ selectedThreadSessionActivity,
+ selectedThreadShell,
+ ]);
+ const selectedThread = selectedThreadDetail ?? selectedThreadShell;
const activeThreadBusy =
!!selectedThread &&
(selectedThread.session?.status === "running" || selectedThread.session?.status === "starting");
const sendQueuedMessage = useCallback(
async (queuedMessage: QueuedThreadMessage) => {
- const client = getEnvironmentClient(queuedMessage.environmentId);
const thread = threads.find(
(candidate) =>
candidate.environmentId === queuedMessage.environmentId &&
candidate.id === queuedMessage.threadId,
);
- if (!client || !thread) {
- return;
+ if (!thread) {
+ return false;
}
- beginDispatchingQueuedMessage(queuedMessage.messageId);
try {
- await client.orchestration.dispatchCommand({
- type: "thread.turn.start",
- commandId: queuedMessage.commandId,
- threadId: queuedMessage.threadId,
- message: {
- messageId: queuedMessage.messageId,
- role: "user",
- text: queuedMessage.text,
- attachments: queuedMessage.attachments,
+ await startTurn({
+ environmentId: queuedMessage.environmentId,
+ input: {
+ commandId: queuedMessage.commandId,
+ threadId: queuedMessage.threadId,
+ message: {
+ messageId: queuedMessage.messageId,
+ role: "user",
+ text: queuedMessage.text,
+ attachments: queuedMessage.attachments,
+ },
+ runtimeMode: thread.runtimeMode,
+ interactionMode: thread.interactionMode,
+ createdAt: queuedMessage.createdAt,
},
- runtimeMode: thread.runtimeMode,
- interactionMode: thread.interactionMode,
- createdAt: queuedMessage.createdAt,
});
- removeQueuedMessage(
- queuedMessage.environmentId,
- queuedMessage.threadId,
- queuedMessage.messageId,
- );
+ await removeThreadOutboxMessage(queuedMessage);
+ return true;
} catch (error) {
- removeQueuedMessage(
- queuedMessage.environmentId,
- queuedMessage.threadId,
- queuedMessage.messageId,
- );
- setPendingConnectionError(
- error instanceof Error ? error.message : "Failed to send message.",
- );
- } finally {
- finishDispatchingQueuedMessage(queuedMessage.messageId);
+ console.warn("[thread-outbox] queued message delivery failed", {
+ environmentId: queuedMessage.environmentId,
+ threadId: queuedMessage.threadId,
+ messageId: queuedMessage.messageId,
+ error,
+ });
+ return false;
}
},
- [threads],
+ [startTurn, threads],
);
useQueueDrain({
@@ -294,7 +322,7 @@ export function useThreadComposerState() {
sendQueuedMessage,
});
- const onSendMessage = useCallback(() => {
+ const onSendMessage = useCallback(async () => {
if (!selectedThreadShell) {
return;
}
@@ -308,16 +336,22 @@ export function useThreadComposerState() {
}
const metadata = makeQueuedMessageMetadata();
- enqueueQueuedMessage({
- environmentId: selectedThreadShell.environmentId,
- threadId: selectedThreadShell.id,
- messageId: MessageId.make(metadata.messageId),
- commandId: CommandId.make(metadata.commandId),
- text,
- attachments,
- createdAt: metadata.createdAt,
- });
- clearComposerDraft(threadKey);
+ try {
+ await enqueueThreadOutboxMessage({
+ environmentId: selectedThreadShell.environmentId,
+ threadId: selectedThreadShell.id,
+ messageId: MessageId.make(metadata.messageId),
+ commandId: CommandId.make(metadata.commandId),
+ text,
+ attachments,
+ createdAt: metadata.createdAt,
+ });
+ clearComposerDraft(threadKey);
+ } catch (error) {
+ setPendingConnectionError(
+ error instanceof Error ? error.message : "Failed to save the queued message.",
+ );
+ }
}, [composerDrafts, selectedThreadShell]);
const onChangeDraftMessage = useCallback(
diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts
index 900dbd648b5..388b4d9afcb 100644
--- a/apps/mobile/src/state/use-thread-detail.ts
+++ b/apps/mobile/src/state/use-thread-detail.ts
@@ -1,82 +1,26 @@
-import { useAtomValue } from "@effect/atom-react";
-import {
- EMPTY_THREAD_DETAIL_ATOM,
- EMPTY_THREAD_DETAIL_STATE,
- createThreadDetailManager,
- getThreadDetailTargetKey,
- threadDetailStateAtom,
- type ThreadDetailState,
- type ThreadDetailTarget,
-} from "@t3tools/client-runtime";
-import { useEffect, useMemo } from "react";
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
-import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity";
-import { appAtomRegistry } from "./atom-registry";
-import {
- getEnvironmentClient,
- subscribeEnvironmentConnections,
-} from "./environment-session-registry";
+import { useEnvironmentThread } from "./threads";
import { useThreadSelection } from "./use-thread-selection";
-function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean {
- const thread = state.data;
- if (!thread || state.isDeleted) {
- return false;
- }
-
- if (thread.latestTurn?.sourceProposedPlan) {
- return true;
- }
-
- const sessionStatus = thread.session?.status;
- if (sessionStatus && sessionStatus !== "idle" && sessionStatus !== "stopped") {
- return true;
- }
-
- return (
- derivePendingApprovals(thread.activities).length > 0 ||
- derivePendingUserInputs(thread.activities).length > 0
- );
+export interface ThreadDetailTarget {
+ readonly environmentId: EnvironmentId | null;
+ readonly threadId: ThreadId | null;
}
-const threadDetailManager = createThreadDetailManager({
- getRegistry: () => appAtomRegistry,
- getClient: (environmentId) => {
- const client = getEnvironmentClient(environmentId);
- return client ? client.orchestration : null;
- },
- getClientIdentity: (environmentId) => {
- return getEnvironmentClient(environmentId) ? environmentId : null;
- },
- subscribeClientChanges: subscribeEnvironmentConnections,
- retention: {
- idleTtlMs: 5 * 60 * 1_000,
- maxRetainedEntries: 24,
- shouldKeepWarm: (_target, state) => shouldKeepThreadDetailWarm(state),
- },
-});
-
-export function useThreadDetail(target: ThreadDetailTarget): ThreadDetailState {
- const { environmentId, threadId } = target;
- const targetKey = getThreadDetailTargetKey(target);
-
- useEffect(
- () => threadDetailManager.watch({ environmentId, threadId }),
- [environmentId, threadId],
- );
-
- const state = useAtomValue(
- targetKey !== null ? threadDetailStateAtom(targetKey) : EMPTY_THREAD_DETAIL_ATOM,
- );
- return targetKey === null ? EMPTY_THREAD_DETAIL_STATE : state;
+export function useThreadDetail(target: ThreadDetailTarget) {
+ return useEnvironmentThread(target.environmentId, target.threadId);
}
-export function useSelectedThreadDetail() {
+export function useSelectedThreadDetailState() {
const { selectedThread } = useThreadSelection();
- const state = useThreadDetail({
+ return useThreadDetail({
environmentId: selectedThread?.environmentId ?? null,
threadId: selectedThread?.id ?? null,
});
+}
- return useMemo(() => state.data, [state.data]);
+export function useSelectedThreadDetail() {
+ return Option.getOrNull(useSelectedThreadDetailState().data);
}
diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts
index c303faed617..06175b6d237 100644
--- a/apps/mobile/src/state/use-thread-selection.ts
+++ b/apps/mobile/src/state/use-thread-selection.ts
@@ -1,11 +1,12 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
-import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts";
-import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
-import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime";
-import { useRemoteCatalog } from "./use-remote-catalog";
-import { useRemoteEnvironmentState } from "./use-remote-environment-registry";
+import { useProject, useThreadShell } from "../state/entities";
+import {
+ useRemoteEnvironmentRuntime,
+ useSavedRemoteConnection,
+} from "./use-remote-environment-registry";
function firstRouteParam(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
@@ -15,43 +16,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null {
return value ?? null;
}
-function deriveSelectedThread(
- selectedThreadRef: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null,
- threads: ReadonlyArray,
-): EnvironmentScopedThreadShell | null {
- if (!selectedThreadRef) {
- return null;
- }
-
- return (
- threads.find(
- (thread) =>
- thread.environmentId === selectedThreadRef.environmentId &&
- thread.id === selectedThreadRef.threadId,
- ) ?? null
- );
-}
-
-function deriveSelectedThreadProject(
- selectedThread: EnvironmentScopedThreadShell | null,
- projects: ReadonlyArray,
-): EnvironmentScopedProjectShell | null {
- if (!selectedThread) {
- return null;
- }
-
- return (
- projects.find(
- (project) =>
- project.environmentId === selectedThread.environmentId &&
- project.id === selectedThread.projectId,
- ) ?? null
- );
-}
-
export function useThreadSelection() {
- const { projects, threads } = useRemoteCatalog();
- const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState();
const params = useLocalSearchParams<{
environmentId?: string | string[];
threadId?: string | string[];
@@ -68,22 +33,21 @@ export function useThreadSelection() {
threadId: ThreadId.make(threadId),
};
}, [params.environmentId, params.threadId]);
- const selectedThread = useMemo(
- () => deriveSelectedThread(selectedThreadRef, threads),
- [selectedThreadRef, threads],
+ const selectedThread = useThreadShell(selectedThreadRef);
+ const selectedProjectRef = useMemo(
+ () =>
+ selectedThread === null
+ ? null
+ : {
+ environmentId: selectedThread.environmentId,
+ projectId: selectedThread.projectId,
+ },
+ [selectedThread],
);
-
- const selectedThreadProject = useMemo(
- () => deriveSelectedThreadProject(selectedThread, projects),
- [projects, selectedThread],
- );
-
- const selectedEnvironmentConnection = selectedThread
- ? (savedConnectionsById[selectedThread.environmentId] ?? null)
- : null;
- const selectedEnvironmentRuntime = selectedThread
- ? (environmentStateById[selectedThread.environmentId] ?? null)
- : null;
+ const selectedThreadProject = useProject(selectedProjectRef);
+ const selectedEnvironmentId = selectedThread?.environmentId ?? null;
+ const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId);
+ const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId);
return {
selectedThreadRef,
diff --git a/apps/mobile/src/state/use-vcs-action-state.ts b/apps/mobile/src/state/use-vcs-action-state.ts
index 64e4da958ef..a63a0c085f1 100644
--- a/apps/mobile/src/state/use-vcs-action-state.ts
+++ b/apps/mobile/src/state/use-vcs-action-state.ts
@@ -1,40 +1,85 @@
import { useAtomValue } from "@effect/atom-react";
import {
- type VcsActionState,
- type VcsActionTarget,
+ applyVcsActionProgressEvent,
EMPTY_VCS_ACTION_ATOM,
EMPTY_VCS_ACTION_STATE,
- createVcsActionManager,
getVcsActionTargetKey,
+ type VcsActionState,
+ type VcsActionTarget,
vcsActionStateAtom,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/state/vcs";
+import type { GitActionProgressEvent } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useRef, useState } from "react";
-import { uuidv4 } from "../lib/uuid";
import { appAtomRegistry } from "./atom-registry";
-import { getEnvironmentClient } from "./environment-session-registry";
+import { gitEnvironment } from "./git";
+
+function setVcsActionState(target: VcsActionTarget, state: VcsActionState): void {
+ const targetKey = getVcsActionTargetKey(target);
+ if (targetKey !== null) {
+ appAtomRegistry.set(vcsActionStateAtom(targetKey), state);
+ }
+}
-export const vcsActionManager = createVcsActionManager({
- getRegistry: () => appAtomRegistry,
- getClient: (environmentId) => {
- const client = getEnvironmentClient(environmentId);
- return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null;
+export function beginVcsAction(
+ target: VcsActionTarget,
+ input: {
+ readonly operation: VcsActionState["operation"];
+ readonly label: string;
},
- getActionId: uuidv4,
-});
+): void {
+ setVcsActionState(target, {
+ ...EMPTY_VCS_ACTION_STATE,
+ isRunning: true,
+ operation: input.operation,
+ currentLabel: input.label,
+ currentPhaseLabel: input.label,
+ phaseStartedAtMs: Date.now(),
+ });
+}
+
+export function completeVcsAction(target: VcsActionTarget): void {
+ setVcsActionState(target, EMPTY_VCS_ACTION_STATE);
+}
+
+export function failVcsAction(
+ target: VcsActionTarget,
+ operation: VcsActionState["operation"],
+ error: unknown,
+): void {
+ setVcsActionState(target, {
+ ...EMPTY_VCS_ACTION_STATE,
+ operation,
+ error: error instanceof Error ? error.message : "Source control action failed.",
+ });
+}
export function useVcsActionState(target: VcsActionTarget): VcsActionState {
const targetKey = getVcsActionTargetKey(target);
+ const runStackedActionState = useAtomValue(gitEnvironment.runStackedAction);
const state = useAtomValue(
targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM,
);
+
+ useEffect(() => {
+ const event = Option.getOrNull(AsyncResult.value(runStackedActionState));
+ if (event === null || targetKey === null || event.cwd !== target.cwd) {
+ return;
+ }
+ appAtomRegistry.set(
+ vcsActionStateAtom(targetKey),
+ applyVcsActionProgressEvent(
+ appAtomRegistry.get(vcsActionStateAtom(targetKey)),
+ event as GitActionProgressEvent,
+ ),
+ );
+ }, [runStackedActionState, target.cwd, targetKey]);
+
return targetKey === null ? EMPTY_VCS_ACTION_STATE : state;
}
-// ---------------------------------------------------------------------------
-// Git action result notification
-// ---------------------------------------------------------------------------
-
export interface GitActionResultNotification {
readonly type: "success" | "error";
readonly title: string;
@@ -84,10 +129,6 @@ export function useGitActionResultNotification(): {
return { result, dismiss: dismissGitActionResult };
}
-// ---------------------------------------------------------------------------
-// Unified git action progress (combines running state + result notification)
-// ---------------------------------------------------------------------------
-
export type GitActionProgressPhase = "idle" | "running" | "success" | "error";
export interface GitActionProgress {
diff --git a/apps/mobile/src/state/use-vcs-refs.ts b/apps/mobile/src/state/use-vcs-refs.ts
deleted file mode 100644
index 3af3a6e945e..00000000000
--- a/apps/mobile/src/state/use-vcs-refs.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { useAtomValue } from "@effect/atom-react";
-import { useEffect, useMemo } from "react";
-import {
- type VcsRefState,
- type VcsRefTarget,
- EMPTY_VCS_REF_ATOM,
- EMPTY_VCS_REF_STATE,
- createVcsRefManager,
- getVcsRefTargetKey,
- vcsRefStateAtom,
-} from "@t3tools/client-runtime";
-
-import { appAtomRegistry } from "./atom-registry";
-import {
- getEnvironmentClient,
- subscribeEnvironmentConnections,
-} from "./environment-session-registry";
-
-const VCS_REF_LIST_LIMIT = 100;
-const VCS_REF_STALE_TIME_MS = 5_000;
-
-export const vcsRefManager = createVcsRefManager({
- getRegistry: () => appAtomRegistry,
- getClient: (environmentId) => {
- const client = getEnvironmentClient(environmentId);
- return client ? client.vcs : null;
- },
- subscribeClientChanges: subscribeEnvironmentConnections,
- watchLimit: VCS_REF_LIST_LIMIT,
- staleTimeMs: VCS_REF_STALE_TIME_MS,
- onBackgroundError: (error) => {
- console.warn("[vcs-refs] background refresh failed", error);
- },
-});
-
-export function useVcsRefs(target: VcsRefTarget): VcsRefState {
- const stableTarget = useMemo(
- () => ({
- environmentId: target.environmentId,
- cwd: target.cwd,
- query: target.query ?? null,
- }),
- [target.cwd, target.environmentId, target.query],
- );
- const targetKey = getVcsRefTargetKey(stableTarget);
-
- useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]);
-
- const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM);
- return targetKey === null ? EMPTY_VCS_REF_STATE : state;
-}
diff --git a/apps/mobile/src/state/use-vcs-status.ts b/apps/mobile/src/state/use-vcs-status.ts
deleted file mode 100644
index e7d7049d332..00000000000
--- a/apps/mobile/src/state/use-vcs-status.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useAtomValue } from "@effect/atom-react";
-import {
- type VcsStatusState,
- type VcsStatusTarget,
- EMPTY_VCS_STATUS_ATOM,
- EMPTY_VCS_STATUS_STATE,
- createVcsStatusManager,
- getVcsStatusTargetKey,
- vcsStatusStateAtom,
-} from "@t3tools/client-runtime";
-import { useEffect } from "react";
-
-import { appAtomRegistry } from "./atom-registry";
-import {
- getEnvironmentClient,
- subscribeEnvironmentConnections,
-} from "./environment-session-registry";
-
-/**
- * Singleton VCS status manager for the mobile app.
- *
- * Uses ref-counted `onStatus` subscriptions (one per unique cwd)
- * rather than one-shot `refreshStatus` RPCs. Multiple threads
- * sharing the same cwd (i.e. same project, no worktree) share
- * a single WS subscription.
- *
- * `subscribeClientChanges` ensures subscriptions are established
- * even when the WS connection isn't ready at mount time, and
- * re-established on reconnection.
- */
-export const vcsStatusManager = createVcsStatusManager({
- getRegistry: () => appAtomRegistry,
- getClient: (environmentId) => {
- const client = getEnvironmentClient(environmentId);
- return client ? client.vcs : null;
- },
- getClientIdentity: (environmentId) => {
- return getEnvironmentClient(environmentId) ? environmentId : null;
- },
- subscribeClientChanges: subscribeEnvironmentConnections,
-});
-
-/**
- * Subscribe to live VCS status for a target (environmentId + cwd).
- *
- * Mirrors the web's `useVcsStatus` hook. Automatically subscribes
- * on mount, ref-counts shared cwds, and unsubscribes on unmount.
- * Returns reactive `VcsStatusState` via Effect atoms.
- */
-export function useVcsStatus(target: VcsStatusTarget): VcsStatusState {
- const targetKey = getVcsStatusTargetKey(target);
-
- useEffect(
- () => vcsStatusManager.watch({ environmentId: target.environmentId, cwd: target.cwd }),
- [target.environmentId, target.cwd],
- );
-
- const state = useAtomValue(
- targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM,
- );
- return targetKey === null ? EMPTY_VCS_STATUS_STATE : state;
-}
diff --git a/apps/mobile/src/state/vcs.ts b/apps/mobile/src/state/vcs.ts
new file mode 100644
index 00000000000..af18fe0bd91
--- /dev/null
+++ b/apps/mobile/src/state/vcs.ts
@@ -0,0 +1,5 @@
+import { createVcsEnvironmentAtoms } from "@t3tools/client-runtime/state/vcs";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/workspace.ts b/apps/mobile/src/state/workspace.ts
new file mode 100644
index 00000000000..368cd0bc468
--- /dev/null
+++ b/apps/mobile/src/state/workspace.ts
@@ -0,0 +1,30 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useMemo } from "react";
+
+import { environmentShellSummaryAtom } from "./shell";
+import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel";
+import { useEnvironments } from "./environments";
+
+export function useWorkspaceState() {
+ const { isReady, networkStatus, environments } = useEnvironments();
+ const shellSummary = useAtomValue(environmentShellSummaryAtom);
+ const projectedEnvironments = useMemo(
+ () => environments.map(projectWorkspaceEnvironment),
+ [environments],
+ );
+ const state = useMemo(
+ () =>
+ projectWorkspaceState({
+ isReady,
+ networkStatus,
+ environments: projectedEnvironments,
+ shellSummary,
+ }),
+ [isReady, networkStatus, projectedEnvironments, shellSummary],
+ );
+
+ return {
+ environments: projectedEnvironments,
+ state,
+ };
+}
diff --git a/apps/mobile/src/state/workspaceModel.test.ts b/apps/mobile/src/state/workspaceModel.test.ts
new file mode 100644
index 00000000000..e51273d57de
--- /dev/null
+++ b/apps/mobile/src/state/workspaceModel.test.ts
@@ -0,0 +1,123 @@
+import type { EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell";
+import {
+ BearerConnectionProfile,
+ BearerConnectionTarget,
+} from "@t3tools/client-runtime/connection";
+import { EnvironmentId } from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+import * as Option from "effect/Option";
+
+import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel";
+import type { EnvironmentPresentation } from "./environments";
+
+const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
+
+function environment(
+ phase: EnvironmentPresentation["connection"]["phase"],
+): EnvironmentPresentation {
+ const connectionId = `bearer:${ENVIRONMENT_ID}`;
+ return {
+ environmentId: ENVIRONMENT_ID,
+ label: "Julius's MacBook Pro",
+ displayUrl: "https://environment.example.test",
+ relayManaged: false,
+ entry: {
+ target: new BearerConnectionTarget({
+ environmentId: ENVIRONMENT_ID,
+ label: "Julius's MacBook Pro",
+ connectionId,
+ }),
+ profile: Option.some(
+ new BearerConnectionProfile({
+ connectionId,
+ environmentId: ENVIRONMENT_ID,
+ label: "Julius's MacBook Pro",
+ httpBaseUrl: "https://environment.example.test",
+ wsBaseUrl: "wss://environment.example.test",
+ }),
+ ),
+ },
+ connection: {
+ phase,
+ error: phase === "error" ? "Connection failed." : null,
+ traceId: phase === "error" ? "trace-1" : null,
+ },
+ serverConfig: null,
+ };
+}
+
+const EMPTY_SHELL_SUMMARY: EnvironmentShellSummary = {
+ hasSnapshot: false,
+ hasSynchronizingShell: false,
+ hasCachedShell: false,
+ hasLiveShell: false,
+ firstError: null,
+ latestSnapshotUpdatedAt: null,
+};
+
+const CACHED_SHELL_SUMMARY: EnvironmentShellSummary = {
+ ...EMPTY_SHELL_SUMMARY,
+ hasSnapshot: true,
+ hasSynchronizingShell: true,
+ hasCachedShell: true,
+ latestSnapshotUpdatedAt: "2026-06-07T00:00:00.000Z",
+};
+
+describe("mobile workspace projection", () => {
+ it("preserves explicit offline state without presenting it as a connection error", () => {
+ const projected = projectWorkspaceEnvironment(environment("offline"));
+
+ expect(projected.connectionState).toBe("offline");
+ expect(projected.connectionError).toBeNull();
+ });
+
+ it("reports offline before stale connected presentations", () => {
+ const environments = [projectWorkspaceEnvironment(environment("connected"))];
+ const state = projectWorkspaceState({
+ isReady: true,
+ networkStatus: "offline",
+ environments,
+ shellSummary: EMPTY_SHELL_SUMMARY,
+ });
+
+ expect(state.connectionState).toBe("offline");
+ expect(state.networkStatus).toBe("offline");
+ expect(state.hasReadyEnvironment).toBe(false);
+ });
+
+ it("projects reconnecting environments dynamically from active phases", () => {
+ const environments = [
+ projectWorkspaceEnvironment(environment("reconnecting")),
+ projectWorkspaceEnvironment({
+ ...environment("connected"),
+ environmentId: EnvironmentId.make("environment-2"),
+ }),
+ ];
+ const state = projectWorkspaceState({
+ isReady: true,
+ networkStatus: "online",
+ environments,
+ shellSummary: EMPTY_SHELL_SUMMARY,
+ });
+
+ expect(state.connectingEnvironments).toHaveLength(1);
+ expect(state.connectingEnvironments[0]?.connectionState).toBe("reconnecting");
+ expect(state.hasConnectingEnvironment).toBe(true);
+ expect(state.hasReadyEnvironment).toBe(true);
+ });
+
+ it("keeps retained snapshots visible while reconnecting without claiming readiness", () => {
+ const environments = [projectWorkspaceEnvironment(environment("reconnecting"))];
+ const state = projectWorkspaceState({
+ isReady: true,
+ networkStatus: "online",
+ environments,
+ shellSummary: CACHED_SHELL_SUMMARY,
+ });
+
+ expect(state.hasLoadedShellSnapshot).toBe(true);
+ expect(state.hasPendingShellSnapshot).toBe(true);
+ expect(state.hasReadyEnvironment).toBe(false);
+ expect(state.connectionState).toBe("reconnecting");
+ });
+});
diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts
new file mode 100644
index 00000000000..44c43d6c880
--- /dev/null
+++ b/apps/mobile/src/state/workspaceModel.ts
@@ -0,0 +1,107 @@
+import { type EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell";
+import { type NetworkStatus } from "@t3tools/client-runtime/connection";
+import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
+
+import type { EnvironmentPresentation } from "./environments";
+
+export interface WorkspaceEnvironment {
+ readonly environmentId: EnvironmentId;
+ readonly environmentLabel: string;
+ readonly displayUrl: string;
+ readonly isRelayManaged: boolean;
+ readonly connectionState: EnvironmentConnectionPhase;
+ readonly connectionError: string | null;
+ readonly connectionErrorTraceId: string | null;
+}
+
+export interface WorkspaceState {
+ readonly isLoadingConnections: boolean;
+ readonly hasConnections: boolean;
+ readonly hasLoadedShellSnapshot: boolean;
+ readonly hasPendingShellSnapshot: boolean;
+ readonly hasReadyEnvironment: boolean;
+ readonly hasConnectingEnvironment: boolean;
+ readonly connectingEnvironments: ReadonlyArray;
+ readonly connectionState: EnvironmentConnectionPhase;
+ readonly connectionError: string | null;
+ readonly shellSnapshotError: string | null;
+ readonly latestCachedSnapshotReceivedAt: string | null;
+ readonly networkStatus: NetworkStatus;
+}
+
+export function projectWorkspaceEnvironment(
+ environment: EnvironmentPresentation,
+): WorkspaceEnvironment {
+ return {
+ environmentId: environment.environmentId,
+ environmentLabel: environment.label,
+ displayUrl: environment.displayUrl ?? "",
+ isRelayManaged: environment.relayManaged,
+ connectionState: environment.connection.phase,
+ connectionError: environment.connection.error,
+ connectionErrorTraceId: environment.connection.traceId,
+ };
+}
+
+function overallConnectionState(
+ environments: ReadonlyArray,
+ networkStatus: NetworkStatus,
+): EnvironmentConnectionPhase {
+ if (environments.length === 0) {
+ return "available";
+ }
+ if (networkStatus === "offline") {
+ return "offline";
+ }
+ if (environments.some((environment) => environment.connectionState === "connected")) {
+ return "connected";
+ }
+ if (environments.some((environment) => environment.connectionState === "reconnecting")) {
+ return "reconnecting";
+ }
+ if (environments.some((environment) => environment.connectionState === "connecting")) {
+ return "connecting";
+ }
+ if (environments.some((environment) => environment.connectionState === "error")) {
+ return "error";
+ }
+ if (environments.some((environment) => environment.connectionState === "offline")) {
+ return "offline";
+ }
+ return "available";
+}
+
+export function projectWorkspaceState(input: {
+ readonly isReady: boolean;
+ readonly networkStatus: NetworkStatus;
+ readonly environments: ReadonlyArray;
+ readonly shellSummary: EnvironmentShellSummary;
+}): WorkspaceState {
+ const connectingEnvironments = input.environments.filter(
+ (environment) =>
+ environment.connectionState === "connecting" ||
+ environment.connectionState === "reconnecting",
+ );
+
+ return {
+ isLoadingConnections: !input.isReady,
+ hasConnections: input.environments.length > 0,
+ hasLoadedShellSnapshot: input.shellSummary.hasSnapshot,
+ hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell,
+ hasReadyEnvironment:
+ input.networkStatus !== "offline" &&
+ input.environments.some((environment) => environment.connectionState === "connected"),
+ hasConnectingEnvironment: connectingEnvironments.length > 0,
+ connectingEnvironments,
+ connectionState: overallConnectionState(input.environments, input.networkStatus),
+ connectionError:
+ input.environments.find((environment) => environment.connectionError !== null)
+ ?.connectionError ?? null,
+ shellSnapshotError: input.shellSummary.firstError,
+ latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt,
+ networkStatus: input.networkStatus,
+ };
+}
+
+export type ServerConfigByEnvironmentId = ReadonlyMap;
diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts
index ed640863d21..e114d1ba1a7 100644
--- a/apps/server/src/auth/http.ts
+++ b/apps/server/src/auth/http.ts
@@ -25,6 +25,7 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
+import { identity } from "effect/Function";
import * as Layer from "effect/Layer";
import * as Cookies from "effect/unstable/http/Cookies";
import * as HttpEffect from "effect/unstable/http/HttpEffect";
@@ -33,6 +34,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
import * as SessionStore from "./SessionStore.ts";
+import { traceRelayRequest } from "../cloud/traceRelayRequest.ts";
import { deriveAuthClientMetadata } from "./utils.ts";
import { verifyRequestDpopProof } from "./dpop.ts";
@@ -177,6 +179,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect(
...session,
scopes: new Set(session.scopes),
}),
+ session.subject === "cloud-connect" ? traceRelayRequest : identity,
);
}).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized));
}),
@@ -289,6 +292,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
proofKeyThumbprint ? { proofKeyThumbprint } : undefined,
);
},
+ traceRelayRequest,
Effect.catchTags({
ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason),
diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts
index a3df9a4a545..fddbea80b12 100644
--- a/apps/server/src/cloud/http.test.ts
+++ b/apps/server/src/cloud/http.test.ts
@@ -11,15 +11,12 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
-import {
- consumeCloudReplayGuards,
- reconcileDesiredCloudLink,
- traceRelayBrokerHandler,
-} from "./http.ts";
+import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";
import {
CloudManagedEndpointRuntime,
type CloudManagedEndpointRuntimeShape,
} from "./ManagedEndpointRuntime.ts";
+import { traceRelayRequest } from "./traceRelayRequest.ts";
const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
new ServerSecretStore.SecretStoreError({
@@ -94,7 +91,7 @@ describe("traceRelayBrokerHandler", () => {
}),
);
- yield* traceRelayBrokerHandler(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe(
+ yield* traceRelayRequest(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe(
Effect.provideService(HttpServerRequest.HttpServerRequest, request),
Effect.provideService(RelayClientTracer, Option.some(productTracer)),
);
diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts
index fd5b526d056..eba59103dbb 100644
--- a/apps/server/src/cloud/http.ts
+++ b/apps/server/src/cloud/http.ts
@@ -48,7 +48,7 @@ import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as HttpEffect from "effect/unstable/http/HttpEffect";
-import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/unstable/http";
+import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
@@ -77,6 +77,7 @@ import { relayUrlConfig } from "./publicConfig.ts";
import * as CliState from "./CliState.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
+import { traceRelayRequest } from "./traceRelayRequest.ts";
const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-";
const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-";
@@ -111,19 +112,6 @@ const requireRelayUrl = relayUrlConfig.pipe(
),
);
-export const traceRelayBrokerHandler = (
- effect: Effect.Effect,
-): Effect.Effect =>
- HttpServerRequest.HttpServerRequest.pipe(
- Effect.flatMap((request) =>
- Option.match(HttpTraceContext.fromHeaders(request.headers), {
- onNone: () => effect,
- onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)),
- }),
- ),
- withRelayClientTracing,
- );
-
function bytesToString(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes);
}
@@ -953,7 +941,7 @@ export const cloudHttpApiLayer = HttpApiBuilder.group(
.handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload))
.handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload))
.handle("t3MintCredential", ({ payload }) =>
- traceRelayBrokerHandler(cloudMintCredentialHandler(dependencies, payload)),
+ traceRelayRequest(cloudMintCredentialHandler(dependencies, payload)),
);
}),
);
diff --git a/apps/server/src/cloud/traceRelayRequest.ts b/apps/server/src/cloud/traceRelayRequest.ts
new file mode 100644
index 00000000000..f2a5538f59c
--- /dev/null
+++ b/apps/server/src/cloud/traceRelayRequest.ts
@@ -0,0 +1,17 @@
+import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import { HttpServerRequest, HttpTraceContext } from "effect/unstable/http";
+
+export const traceRelayRequest = (
+ effect: Effect.Effect,
+): Effect.Effect =>
+ HttpServerRequest.HttpServerRequest.pipe(
+ Effect.flatMap((request) =>
+ Option.match(HttpTraceContext.fromHeaders(request.headers), {
+ onNone: () => effect,
+ onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)),
+ }),
+ ),
+ withRelayClientTracing,
+ );
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
index 517d57168c3..3934211630f 100644
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -34,6 +34,7 @@ import { resolveStaticDir, ServerConfig } from "./config.ts";
import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
+import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
import {
annotateEnvironmentRequest,
failEnvironmentScopeRequired,
@@ -104,7 +105,7 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(
Effect.fn("environment.metadata.descriptor")(function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
return yield* serverEnvironment.getDescriptor;
- }),
+ }, traceRelayRequest),
);
}),
);
diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts
index 754930d0ced..75951db1baf 100644
--- a/apps/web/src/cloud/dpop.test.ts
+++ b/apps/web/src/cloud/dpop.test.ts
@@ -1,32 +1,35 @@
import { verifyDpopProof } from "@t3tools/shared/dpop";
+import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
-import { describe, expect, it, vi } from "vite-plus/test";
+import { decodeJwt } from "jose";
+import { vi } from "vite-plus/test";
import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop";
describe("browser DPoP proofs", () => {
- it("signs relay resource proofs with an access-token hash", async () => {
- vi.stubGlobal("indexedDB", undefined);
- const issuedAt = Math.floor(Date.now() / 1_000);
- const proofKey = await Effect.runPromise(generateBrowserDpopKey);
- const proof = await Effect.runPromise(
- createBrowserDpopProof({
+ it.effect("signs relay resource proofs with an access-token hash", () =>
+ Effect.gen(function* () {
+ vi.stubGlobal("indexedDB", undefined);
+ const proofKey = yield* generateBrowserDpopKey;
+ const proof = yield* createBrowserDpopProof({
method: "POST",
url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true",
accessToken: "relay-access-token",
proofKey,
- }).pipe(Effect.provide(browserCryptoLayer)),
- );
+ }).pipe(Effect.provide(browserCryptoLayer));
+ const issuedAt = decodeJwt(proof.proof).iat;
+ expect(issuedAt).toBeTypeOf("number");
- expect(
- verifyDpopProof({
- proof: proof.proof,
- method: "POST",
- url: "https://relay.example.test/v1/environments/env-1/connect",
- expectedThumbprint: proof.thumbprint,
- expectedAccessToken: "relay-access-token",
- nowEpochSeconds: issuedAt,
- }),
- ).toMatchObject({ ok: true });
- });
+ expect(
+ verifyDpopProof({
+ proof: proof.proof,
+ method: "POST",
+ url: "https://relay.example.test/v1/environments/env-1/connect",
+ expectedThumbprint: proof.thumbprint,
+ expectedAccessToken: "relay-access-token",
+ nowEpochSeconds: issuedAt!,
+ }),
+ ).toMatchObject({ ok: true });
+ }),
+ );
});
diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts
index 3e9863e263e..216aef3890f 100644
--- a/apps/web/src/cloud/linkEnvironment.test.ts
+++ b/apps/web/src/cloud/linkEnvironment.test.ts
@@ -1,911 +1,323 @@
-import { EnvironmentId } from "@t3tools/contracts";
+import {
+ EnvironmentId,
+ type RelayClientInstallProgressEvent,
+ WS_METHODS,
+} from "@t3tools/contracts";
import { RelayWebClientId } from "@t3tools/contracts/relay";
-import { afterEach, beforeEach, vi } from "vite-plus/test";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
import { HttpClient } from "effect/unstable/http";
+import { afterEach, beforeEach, vi } from "vite-plus/test";
+import {
+ AVAILABLE_CONNECTION_STATE,
+ type EnvironmentRegistryService,
+ EnvironmentSupervisor,
+ type EnvironmentSupervisorService,
+ type PreparedConnection,
+ PrimaryConnectionTarget,
+} from "@t3tools/client-runtime/connection";
+import { type RpcSession } from "@t3tools/client-runtime/rpc";
+import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
import {
managedRelayClientLayer,
ManagedRelayClient,
ManagedRelayDpopSigner,
- remoteHttpClientLayer,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/relay";
+import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
-import type { SavedEnvironmentRecord } from "../environments/runtime";
import {
- connectManagedCloudEnvironment,
- linkEnvironmentToCloud,
+ collectCloudLinkTargets,
linkPrimaryEnvironmentToCloud,
listManagedCloudEnvironments,
normalizeRelayBaseUrl,
readPrimaryCloudLinkState,
+ type CloudLinkTarget,
unlinkPrimaryEnvironmentFromCloud,
} from "./linkEnvironment";
-import {
- readPrimaryEnvironmentDescriptor,
- readPrimaryEnvironmentTarget,
- resolvePrimaryEnvironmentHttpUrl,
-} from "../environments/primary";
-const getSavedEnvironmentSecretMock = vi.fn();
-const relayClientInstallDialogHarness = vi.hoisted(() => ({
+const TARGET: CloudLinkTarget = {
+ environmentId: "environment-1",
+ label: "Desktop",
+ httpBaseUrl: "http://127.0.0.1:3000",
+ wsBaseUrl: "ws://127.0.0.1:3000",
+};
+
+const relayClientInstallDialog = vi.hoisted(() => ({
requestConfirmation: vi.fn(),
reportProgress: vi.fn(),
finish: vi.fn(),
}));
-const getRelayClientStatusMock = vi.fn();
-const installRelayClientMock = vi.fn();
-const environmentConnectionMock = {
- client: {
- cloud: {
- getRelayClientStatus: getRelayClientStatusMock,
- installRelayClient: installRelayClientMock,
- },
- },
-};
-const createProofMock = vi.fn(
- (_input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
- Effect.succeed("web-dpop-proof"),
-);
-const testDpopSignerLayer = Layer.succeed(
+vi.mock("./relayClientInstallDialog", () => ({
+ requestRelayClientInstallConfirmation: relayClientInstallDialog.requestConfirmation,
+ reportRelayClientInstallProgress: relayClientInstallDialog.reportProgress,
+ finishRelayClientInstall: relayClientInstallDialog.finish,
+}));
+
+const createProof = vi.fn(() => Effect.succeed("dpop-proof"));
+const dpopSignerLayer = Layer.succeed(
ManagedRelayDpopSigner,
ManagedRelayDpopSigner.of({
- thumbprint: Effect.succeed("web-thumbprint"),
- createProof: (input) => createProofMock(input),
+ thumbprint: Effect.succeed("thumbprint"),
+ createProof,
}),
);
-function cloudClientLayer() {
- const httpClientLayer = remoteHttpClientLayer(globalThis.fetch);
+function relayLayer() {
+ const http = remoteHttpClientLayer(globalThis.fetch);
return Layer.mergeAll(
- httpClientLayer,
+ http,
managedRelayClientLayer({
relayUrl: "https://relay.example.test",
clientId: RelayWebClientId,
- }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)),
+ }).pipe(Layer.provideMerge(dpopSignerLayer), Layer.provide(http)),
);
}
-const withCloudServices = (
- effect: Effect.Effect,
-) => effect.pipe(Effect.provide(cloudClientLayer()));
-
-vi.mock("../localApi", () => ({
- ensureLocalApi: () => ({
- persistence: {
- getSavedEnvironmentSecret: getSavedEnvironmentSecretMock,
- },
- }),
-}));
-
-vi.mock("./relayClientInstallDialog", () => ({
- requestRelayClientInstallConfirmation: relayClientInstallDialogHarness.requestConfirmation,
- reportRelayClientInstallProgress: relayClientInstallDialogHarness.reportProgress,
- finishRelayClientInstall: relayClientInstallDialogHarness.finish,
-}));
-
-vi.mock("../environments/primary", () => ({
- readPrimaryEnvironmentDescriptor: vi.fn(() => null),
- readPrimaryEnvironmentTarget: vi.fn(() => null),
- resolvePrimaryEnvironmentHttpUrl: vi.fn((path: string) => `http://127.0.0.1:3000${path}`),
-}));
-
-vi.mock("../environments/runtime", () => ({
- getPrimaryEnvironmentConnection: () => environmentConnectionMock,
- readEnvironmentConnection: () => environmentConnectionMock,
-}));
-
-const savedEnvironment: SavedEnvironmentRecord = {
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- httpBaseUrl: "http://127.0.0.1:3000",
- wsBaseUrl: "ws://127.0.0.1:3000",
- createdAt: "2026-05-25T00:00:00.000Z",
- lastConnectedAt: null,
-};
-
-function validProof() {
- return "signed-environment-link-jwt";
+function registryLayer(options?: {
+ readonly status?: { readonly status: "available"; readonly version: string };
+ readonly installEvents?: ReadonlyArray;
+}) {
+ return Layer.effect(
+ EnvironmentRegistry,
+ Effect.gen(function* () {
+ const client = {
+ [WS_METHODS.cloudGetRelayClientStatus]: () =>
+ Effect.succeed(options?.status ?? { status: "available", version: "2026.6.0" }),
+ [WS_METHODS.cloudInstallRelayClient]: () =>
+ Stream.fromIterable(options?.installEvents ?? []),
+ } as unknown as RpcSession["client"];
+ const session: RpcSession = {
+ client,
+ initialConfig: Effect.never,
+ ready: Effect.void,
+ probe: Effect.void,
+ closed: Effect.never,
+ };
+ const target = new PrimaryConnectionTarget({
+ environmentId: EnvironmentId.make(TARGET.environmentId),
+ label: TARGET.label,
+ httpBaseUrl: TARGET.httpBaseUrl,
+ wsBaseUrl: TARGET.wsBaseUrl,
+ });
+ const supervisor = EnvironmentSupervisor.of({
+ target,
+ state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
+ session: yield* SubscriptionRef.make(Option.some(session)),
+ prepared: yield* SubscriptionRef.make(Option.none()),
+ connect: Effect.void,
+ disconnect: Effect.void,
+ retryNow: Effect.void,
+ } satisfies EnvironmentSupervisorService);
+ const registry = {
+ run: (_environmentId: EnvironmentId, effect: Effect.Effect) =>
+ Effect.provideService(effect, EnvironmentSupervisor, supervisor),
+ runStream: (_environmentId: EnvironmentId, stream: Stream.Stream) =>
+ Stream.provideService(stream, EnvironmentSupervisor, supervisor),
+ } as unknown as EnvironmentRegistryService;
+ return EnvironmentRegistry.of(registry);
+ }),
+ );
}
-function validChallenge() {
- return {
- challenge: "link-challenge",
- expiresAt: "2026-05-25T00:05:00.000Z",
- };
+function services(options?: Parameters[0]) {
+ return Layer.mergeAll(relayLayer(), registryLayer(options));
}
-function availableRelayClient() {
- return {
- status: "available",
- executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared",
- source: "managed",
- version: "2026.5.2",
- };
+function withServices(
+ effect: Effect.Effect,
+ options?: Parameters[0],
+) {
+ return effect.pipe(Effect.provide(services(options)));
}
-function requestBodyText(body: BodyInit | null | undefined): string {
+function bodyText(body: BodyInit | null | undefined): string {
return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
}
-describe("web cloud link environment client", () => {
- afterEach(() => {
- if ("window" in globalThis) {
- Reflect.deleteProperty(window, "desktopBridge");
- }
- vi.unstubAllGlobals();
- });
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");
+ relayClientInstallDialog.requestConfirmation.mockResolvedValue(true);
+});
- beforeEach(() => {
- vi.restoreAllMocks();
- vi.clearAllMocks();
- createProofMock.mockClear();
- vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");
- getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer");
- relayClientInstallDialogHarness.requestConfirmation.mockResolvedValue(true);
- getRelayClientStatusMock.mockResolvedValue(availableRelayClient());
- installRelayClientMock.mockResolvedValue(availableRelayClient());
- vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null);
- vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue(null);
- vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation(
- (path: string) => `http://127.0.0.1:3000${path}`,
- );
- });
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+});
- it("normalizes configured relay base URLs before building relay requests", () => {
+describe("web cloud link environment client", () => {
+ it("normalizes relay URLs and de-duplicates cloud link targets", () => {
expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe(
"https://relay.example.test",
);
- expect(normalizeRelayBaseUrl(" ")).toBeNull();
+ expect(normalizeRelayBaseUrl(" ")).toBeNull();
+ expect(
+ collectCloudLinkTargets({
+ primary: TARGET,
+ saved: [TARGET, { ...TARGET, environmentId: "environment-2" }],
+ }).map((target) => target.environmentId),
+ ).toEqual(["environment-1", "environment-2"]);
});
- it.effect(
- "installs the relay client over environment RPC before requesting a cloud challenge",
- () =>
- Effect.gen(function* () {
- getRelayClientStatusMock.mockResolvedValue({
- status: "missing",
- version: "2026.5.2",
- });
- vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- });
- vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({
- source: "desktop-managed",
- target: {
- httpBaseUrl: "http://127.0.0.1:3000",
- wsBaseUrl: "ws://127.0.0.1:3000",
- },
- });
- const fetchMock = vi
- .fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(Response.json({ malformed: true }));
- vi.stubGlobal("fetch", fetchMock);
- installRelayClientMock.mockImplementationOnce(async (onProgress) => {
- onProgress({ type: "progress", stage: "downloading" });
- return availableRelayClient();
- });
-
- yield* withCloudServices(
- linkPrimaryEnvironmentToCloud({
- clerkToken: "clerk-token",
- }),
- ).pipe(Effect.flip);
-
- expect(relayClientInstallDialogHarness.requestConfirmation).toHaveBeenCalledWith(
- "2026.5.2",
- );
- expect(getRelayClientStatusMock).toHaveBeenCalledOnce();
- expect(installRelayClientMock).toHaveBeenCalledOnce();
- expect(relayClientInstallDialogHarness.reportProgress).toHaveBeenCalledWith({
- type: "progress",
- stage: "downloading",
- });
- expect(relayClientInstallDialogHarness.finish).toHaveBeenCalledOnce();
- expect(installRelayClientMock.mock.invocationCallOrder[0]).toBeLessThan(
- fetchMock.mock.invocationCallOrder[0]!,
- );
- expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
- "https://relay.example.test/v1/client/environment-link-challenges",
- );
- }),
- );
-
- it.effect("lists relay-managed environments for hosted and served web clients", () =>
+ it.effect("lists relay-managed environments through the typed relay client", () =>
Effect.gen(function* () {
- const fetchMock = vi.fn().mockResolvedValueOnce(
+ const fetchMock = vi.fn().mockResolvedValue(
Response.json({
environments: [
{
- environmentId: "env-1",
- label: "Managed desktop",
+ environmentId: "environment-1",
+ label: "Desktop",
endpoint: {
- httpBaseUrl: "https://managed.example.test",
- wsBaseUrl: "wss://managed.example.test",
+ httpBaseUrl: "https://desktop.example.test",
+ wsBaseUrl: "wss://desktop.example.test",
providerKind: "cloudflare_tunnel",
},
- linkedAt: "2026-05-25T00:00:00.000Z",
+ linkedAt: "2026-06-06T00:00:00.000Z",
},
],
}),
);
vi.stubGlobal("fetch", fetchMock);
- const environments = yield* withCloudServices(
+ const environments = yield* withServices(
listManagedCloudEnvironments({ clerkToken: "clerk-token" }),
);
+
expect(environments).toHaveLength(1);
- expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
- "https://relay.example.test/v1/environments",
- );
expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token");
- expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include");
- }),
- );
-
- it.effect("connects web clients to managed environments with a tunnel-only DPoP token", () =>
- Effect.gen(function* () {
- const environment = {
- environmentId: EnvironmentId.make("env-1"),
- label: "Managed desktop",
- endpoint: {
- httpBaseUrl: "https://managed.example.test",
- wsBaseUrl: "wss://managed.example.test",
- providerKind: "cloudflare_tunnel" as const,
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- };
- const fetchMock = vi
- .fn()
- .mockResolvedValueOnce(
- Response.json({
- access_token: "relay-access-token",
- issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
- token_type: "DPoP",
- expires_in: 300,
- scope: "environment:connect",
- }),
- )
- .mockResolvedValueOnce(
- Response.json({
- environmentId: "env-1",
- endpoint: environment.endpoint,
- credential: "environment-bootstrap",
- expiresAt: "2026-05-25T00:05:00.000Z",
- }),
- )
- .mockResolvedValueOnce(
- Response.json({
- environmentId: "env-1",
- label: "Managed desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- }),
- )
- .mockResolvedValueOnce(
- Response.json({
- access_token: "environment-access-token",
- issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
- token_type: "DPoP",
- expires_in: 3600,
- scope: "orchestration:read orchestration:operate terminal:operate review:write",
- }),
- );
- vi.stubGlobal("fetch", fetchMock);
-
- const connection = yield* withCloudServices(
- connectManagedCloudEnvironment({ clerkToken: "clerk-token", environment }),
- );
- expect(connection).toMatchObject({
- environmentId: "env-1",
- accessToken: "environment-access-token",
- });
-
- const tokenBody = requestBodyText(fetchMock.mock.calls[0]?.[1]?.body);
- expect(new URLSearchParams(tokenBody).get("client_id")).toBe("t3-web");
- expect(new URLSearchParams(tokenBody).get("scope")).toBe("environment:connect");
- expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("DPoP relay-access-token");
- expect(fetchMock.mock.calls[1]?.[1]?.headers.dpop).toBe("web-dpop-proof");
- expect(createProofMock).toHaveBeenCalledWith({
- method: "POST",
- url: "https://managed.example.test/oauth/token",
- });
- const traceparents = fetchMock.mock.calls.map(
- (call) => call[1]?.headers.traceparent as string | undefined,
- );
- expect(traceparents.every((traceparent) => typeof traceparent === "string")).toBe(true);
- expect(new Set(traceparents.map((traceparent) => traceparent?.split("-")[1])).size).toBe(1);
- expect(connection.relayTraceHeaders.traceparent?.split("-")[1]).toBe(
- traceparents[0]?.split("-")[1],
- );
- }),
- );
-
- it.effect("rejects a stored managed connection for another relay origin", () =>
- Effect.gen(function* () {
- const environment = {
- environmentId: EnvironmentId.make("env-1"),
- label: "Managed desktop",
- endpoint: {
- httpBaseUrl: "https://managed.example.test",
- wsBaseUrl: "wss://managed.example.test",
- providerKind: "cloudflare_tunnel" as const,
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- };
-
- const error = yield* withCloudServices(
- connectManagedCloudEnvironment({
- clerkToken: "clerk-token",
- environment,
- relayUrl: "https://old-relay.example.test",
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- message: "The saved environment is linked through a different configured relay.",
- });
- }),
- );
-
- it.effect("rejects malformed local environment link proofs", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi
- .fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(
- Response.json({
- payload: {
- environmentId: "env-1",
- },
- signature: "signature-1",
- }),
- ),
- );
-
- const error = yield* withCloudServices(
- linkEnvironmentToCloud({
- environment: savedEnvironment,
- clerkToken: "clerk-token",
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "Could not obtain environment link proof.",
- });
}),
);
- it.effect("preserves typed local environment failures while obtaining a link proof", () =>
+ it.effect("reads primary cloud link state from the explicit target", () =>
Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi
- .fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(
- Response.json(
- {
- _tag: "EnvironmentHttpUnauthorizedError",
- message: "Invalid environment bearer session.",
- },
- { status: 401 },
- ),
- ),
- );
-
- const error = yield* withCloudServices(
- linkEnvironmentToCloud({
- environment: savedEnvironment,
- clerkToken: "clerk-token",
- }),
- ).pipe(Effect.flip);
- expect(error._tag).toBe("CloudEnvironmentLinkError");
- expect(error.message).toBe(
- "Could not obtain environment link proof: Invalid environment bearer session.",
- );
- }),
- );
-
- it.effect("rejects malformed relay environment link responses", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi
- .fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(Response.json(validProof()))
- .mockResolvedValueOnce(
- Response.json({
- ok: true,
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test",
- wsBaseUrl: "wss://desktop.example.test",
- providerKind: "cloudflare_tunnel",
- },
- endpointRuntime: null,
- relayIssuer: "https://issuer.example.test",
- cloudUserId: "user_123",
- environmentCredential: "",
- cloudMintPublicKey: "cloud-mint-public-key",
- }),
- ),
- );
-
- const error = yield* withCloudServices(
- linkEnvironmentToCloud({
- environment: savedEnvironment,
- clerkToken: "clerk-token",
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "https://relay.example.test/v1/client/environment-links failed",
- });
- }),
- );
-
- it.effect(
- "links the primary local environment through the relay using the owner cookie session",
- () =>
- Effect.gen(function* () {
- vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- });
- vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({
- source: "desktop-managed",
- target: {
- httpBaseUrl: "http://127.0.0.1:3000",
- wsBaseUrl: "ws://127.0.0.1:3000",
- },
- });
- vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation(
- (path: string) => `http://127.0.0.1:3000${path}`,
- );
-
- const fetchMock = vi
- .fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(Response.json(validProof()))
- .mockResolvedValueOnce(
- Response.json({
- ok: true,
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test",
- wsBaseUrl: "wss://desktop.example.test",
- providerKind: "cloudflare_tunnel",
- },
- endpointRuntime: {
- providerKind: "cloudflare_tunnel",
- connectorToken: "connector-token",
- tunnelId: "tunnel-id",
- tunnelName: "tunnel-name",
- },
- relayIssuer: "https://issuer.example.test",
- cloudUserId: "user_123",
- environmentCredential: "t3env_test_credential",
- cloudMintPublicKey: "cloud-mint-public-key",
- }),
- )
- .mockResolvedValueOnce(
- Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }),
- );
- vi.stubGlobal("fetch", fetchMock);
-
- yield* withCloudServices(
- linkPrimaryEnvironmentToCloud({
- clerkToken: "clerk-token",
- }),
- );
-
- expect(getRelayClientStatusMock).toHaveBeenCalledOnce();
- expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
- "https://relay.example.test/v1/client/environment-link-challenges",
- );
- expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST");
- expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token");
- expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include");
-
- expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
- "http://127.0.0.1:3000/api/cloud/link-proof",
- );
- expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
- method: "POST",
- credentials: "include",
- headers: expect.objectContaining({
- "content-type": "application/json",
- }),
- });
- // @effect-diagnostics-next-line preferSchemaOverJson:off
- expect(JSON.parse(requestBodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
- challenge: "link-challenge",
- endpoint: {
- httpBaseUrl: "http://127.0.0.1:3000",
- wsBaseUrl: "ws://127.0.0.1:3000",
- providerKind: "cloudflare_tunnel",
- },
- origin: {
- localHttpHost: "127.0.0.1",
- localHttpPort: 3000,
- },
- });
-
- expect(String(fetchMock.mock.calls[2]?.[0])).toBe(
- "https://relay.example.test/v1/client/environment-links",
- );
- expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST");
- expect(fetchMock.mock.calls[2]?.[1]?.headers.authorization).toBe("Bearer clerk-token");
- expect(fetchMock.mock.calls[2]?.[1]?.credentials).not.toBe("include");
- expect(fetchMock.mock.calls[2]?.[1]?.headers["content-type"]).toBe("application/json");
- // @effect-diagnostics-next-line preferSchemaOverJson:off
- expect(JSON.parse(requestBodyText(fetchMock.mock.calls[2]?.[1]?.body))).toMatchObject({
- proof: validProof(),
- notificationsEnabled: true,
- liveActivitiesEnabled: true,
- managedTunnelsEnabled: true,
- });
-
- expect(String(fetchMock.mock.calls[3]?.[0])).toBe(
- "http://127.0.0.1:3000/api/cloud/relay-config",
- );
- expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({
- method: "POST",
- credentials: "include",
- headers: expect.objectContaining({
- "content-type": "application/json",
- }),
- });
- // @effect-diagnostics-next-line preferSchemaOverJson:off
- expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({
- relayUrl: "https://relay.example.test",
- relayIssuer: "https://issuer.example.test",
- cloudUserId: "user_123",
- environmentCredential: "t3env_test_credential",
- cloudMintPublicKey: "cloud-mint-public-key",
- endpointRuntime: {
- providerKind: "cloudflare_tunnel",
- connectorToken: "connector-token",
- tunnelId: "tunnel-id",
- tunnelName: "tunnel-name",
- },
- });
- }),
- );
-
- it.effect("reads the primary local cloud link state with the owner cookie session", () =>
- Effect.gen(function* () {
- vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- });
- vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({
- source: "desktop-managed",
- target: {
- httpBaseUrl: "http://127.0.0.1:3000",
- wsBaseUrl: "ws://127.0.0.1:3000",
- },
- });
- const fetchMock = vi.fn().mockResolvedValueOnce(
+ const fetchMock = vi.fn().mockResolvedValue(
Response.json({
linked: true,
- cloudUserId: "user_123",
+ cloudUserId: "user-1",
relayUrl: "https://relay.example.test",
- relayIssuer: "https://issuer.example.test",
+ relayIssuer: "https://relay.example.test",
publishAgentActivity: false,
}),
);
vi.stubGlobal("fetch", fetchMock);
- const state = yield* withCloudServices(readPrimaryCloudLinkState());
- expect(state).toEqual({
- linked: true,
- cloudUserId: "user_123",
- relayUrl: "https://relay.example.test",
- relayIssuer: "https://issuer.example.test",
- publishAgentActivity: false,
- });
- expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
- "http://127.0.0.1:3000/api/cloud/link-state",
- );
- expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
- method: "GET",
- credentials: "include",
- });
- }),
- );
-
- it.effect("clears local relay credentials before revoking the primary cloud link", () =>
- Effect.gen(function* () {
- vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- });
- vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({
- source: "desktop-managed",
- target: {
- httpBaseUrl: "http://127.0.0.1:3000",
- wsBaseUrl: "ws://127.0.0.1:3000",
- },
- });
- const fetchMock = vi
- .fn()
- .mockResolvedValueOnce(
- Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }),
- )
- .mockResolvedValueOnce(Response.json({ ok: true }));
- vi.stubGlobal("fetch", fetchMock);
+ const state = yield* withServices(readPrimaryCloudLinkState({ target: TARGET }));
- yield* withCloudServices(
- unlinkPrimaryEnvironmentFromCloud({
- clerkToken: "clerk-token",
+ expect(Option.fromNullishOr(state)).toEqual(
+ Option.some({
+ linked: true,
+ cloudUserId: "user-1",
+ relayUrl: "https://relay.example.test",
+ relayIssuer: "https://relay.example.test",
+ publishAgentActivity: false,
}),
);
-
- expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink");
- expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
- method: "POST",
- credentials: "include",
- });
- expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
- "https://relay.example.test/v1/client/environment-links/env-1",
+ expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
+ "http://127.0.0.1:3000/api/cloud/link-state",
);
- expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE");
- expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("Bearer clerk-token");
}),
);
- it.effect("still clears local relay credentials when relay revocation fails", () =>
+ it.effect("links an available primary environment without invoking installation", () =>
Effect.gen(function* () {
- vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- });
- vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({
- source: "desktop-managed",
- target: {
- httpBaseUrl: "http://127.0.0.1:3000",
- wsBaseUrl: "ws://127.0.0.1:3000",
- },
- });
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
- Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }),
+ Response.json({
+ challenge: "challenge",
+ expiresAt: "2026-06-06T00:05:00.000Z",
+ }),
)
- .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 }));
- vi.stubGlobal("fetch", fetchMock);
-
- yield* withCloudServices(
- unlinkPrimaryEnvironmentFromCloud({
- clerkToken: "clerk-token",
- }),
- );
-
- expect(fetchMock).toHaveBeenCalledTimes(2);
- expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink");
- expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
- method: "POST",
- credentials: "include",
- });
- }),
- );
-
- it.effect("rejects primary environment linking when the local environment is not ready", () =>
- Effect.gen(function* () {
- vi.stubGlobal("fetch", vi.fn());
-
- const error = yield* withCloudServices(
- linkPrimaryEnvironmentToCloud({
- clerkToken: "clerk-token",
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "Local environment is not ready yet.",
- });
- expect(fetch).not.toHaveBeenCalled();
- }),
- );
-
- it.effect("preserves relay transport failures while linking environments", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi
- .fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(Response.json(validProof()))
- .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })),
- );
-
- const error = yield* withCloudServices(
- linkEnvironmentToCloud({
- environment: savedEnvironment,
- clerkToken: "clerk-token",
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "https://relay.example.test/v1/client/environment-links failed",
- });
- }),
- );
-
- it.effect("preserves typed relay error bodies while linking environments", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi
- .fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(Response.json(validProof()))
- .mockResolvedValueOnce(
- Response.json(
- {
- _tag: "RelayEnvironmentLinkProofInvalidError",
- code: "environment_link_proof_invalid",
- reason: "origin_not_allowed",
- traceId: "trace-test",
- },
- { status: 400 },
- ),
- ),
- );
-
- const error = yield* withCloudServices(
- linkEnvironmentToCloud({
- environment: savedEnvironment,
- clerkToken: "clerk-token",
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message:
- "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).",
- });
- }),
- );
-
- it.effect("rejects relay credentials for a different environment", () =>
- Effect.gen(function* () {
- const fetchMock = vi
- .fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(Response.json(validProof()))
+ .mockResolvedValueOnce(Response.json("signed-proof"))
.mockResolvedValueOnce(
Response.json({
ok: true,
- environmentId: "env-2",
+ environmentId: TARGET.environmentId,
endpoint: {
httpBaseUrl: "https://desktop.example.test",
wsBaseUrl: "wss://desktop.example.test",
providerKind: "cloudflare_tunnel",
},
endpointRuntime: null,
- relayIssuer: "https://issuer.example.test",
- cloudUserId: "user_123",
- environmentCredential: "t3env_test_credential",
- cloudMintPublicKey: "cloud-mint-public-key",
+ relayIssuer: "https://relay.example.test",
+ cloudUserId: "user-1",
+ environmentCredential: "environment-credential",
+ cloudMintPublicKey: "public-key",
}),
+ )
+ .mockResolvedValueOnce(
+ Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }),
);
vi.stubGlobal("fetch", fetchMock);
- const error = yield* withCloudServices(
- linkEnvironmentToCloud({
- environment: savedEnvironment,
+ yield* withServices(
+ linkPrimaryEnvironmentToCloud({
+ target: TARGET,
clerkToken: "clerk-token",
}),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "Relay returned credentials for a different environment.",
+ );
+
+ expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled();
+ expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
+ "http://127.0.0.1:3000/api/cloud/link-proof",
+ );
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
+ challenge: "challenge",
+ endpoint: {
+ httpBaseUrl: TARGET.httpBaseUrl,
+ wsBaseUrl: TARGET.wsBaseUrl,
+ },
});
- expect(fetchMock).toHaveBeenCalledTimes(3);
}),
);
- it.effect("rejects relay credentials for a different managed endpoint provider", () =>
+ it.effect("installs a missing relay client before linking", () =>
Effect.gen(function* () {
- const fetchMock = vi
- .fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(Response.json(validProof()))
- .mockResolvedValueOnce(
- Response.json({
- ok: true,
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test",
- wsBaseUrl: "wss://desktop.example.test",
- providerKind: "manual",
- },
- endpointRuntime: null,
- relayIssuer: "https://issuer.example.test",
- cloudUserId: "user_123",
- environmentCredential: "t3env_test_credential",
- cloudMintPublicKey: "cloud-mint-public-key",
- }),
- );
- vi.stubGlobal("fetch", fetchMock);
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true })));
- const error = yield* withCloudServices(
- linkEnvironmentToCloud({
- environment: savedEnvironment,
+ yield* withServices(
+ linkPrimaryEnvironmentToCloud({
+ target: TARGET,
clerkToken: "clerk-token",
}),
+ {
+ status: { status: "available", version: "2026.6.0" },
+ installEvents: [],
+ },
).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "Relay returned credentials for a different endpoint provider.",
- });
- expect(fetchMock).toHaveBeenCalledTimes(3);
+
+ expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled();
}),
);
- it.effect("passes the relay issuer from the link response into local relay config", () =>
+ it.effect("unlinks locally before revoking the relay record", () =>
Effect.gen(function* () {
const fetchMock = vi
.fn()
- .mockResolvedValueOnce(Response.json(validChallenge()))
- .mockResolvedValueOnce(Response.json(validProof()))
- .mockResolvedValueOnce(
- Response.json({
- ok: true,
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test",
- wsBaseUrl: "wss://desktop.example.test",
- providerKind: "cloudflare_tunnel",
- },
- endpointRuntime: null,
- relayIssuer: "https://issuer.example.test",
- cloudUserId: "user_123",
- environmentCredential: "t3env_test_credential",
- cloudMintPublicKey: "cloud-mint-public-key",
- }),
- )
.mockResolvedValueOnce(
Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }),
- );
+ )
+ .mockResolvedValueOnce(Response.json({ ok: true }));
vi.stubGlobal("fetch", fetchMock);
- yield* withCloudServices(
- linkEnvironmentToCloud({
- environment: savedEnvironment,
+ yield* withServices(
+ unlinkPrimaryEnvironmentFromCloud({
+ target: TARGET,
clerkToken: "clerk-token",
}),
);
- // @effect-diagnostics-next-line preferSchemaOverJson:off
- expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({
- relayUrl: "https://relay.example.test",
- relayIssuer: "https://issuer.example.test",
- cloudUserId: "user_123",
- });
+ expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink");
+ expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
+ `/v1/client/environment-links/${TARGET.environmentId}`,
+ );
}),
);
});
diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts
index 7db5c8e11c9..5d1d14b4ecd 100644
--- a/apps/web/src/cloud/linkEnvironment.ts
+++ b/apps/web/src/cloud/linkEnvironment.ts
@@ -1,7 +1,9 @@
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
-import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http";
+import * as Stream from "effect/Stream";
+import { HttpClient } from "effect/unstable/http";
import {
EnvironmentCloudEndpointUnavailableError,
type EnvironmentCloudLinkStateResult,
@@ -11,36 +13,23 @@ import {
EnvironmentHttpInternalServerError,
EnvironmentHttpUnauthorizedError,
EnvironmentId,
+ WS_METHODS,
} from "@t3tools/contracts";
import {
- RelayEnvironmentConnectScope,
type RelayClientDeviceRecord,
- type RelayEnvironmentLinkResponse,
- RelayProtectedError,
type RelayClientEnvironmentRecord,
+ type RelayEnvironmentLinkResponse,
type RelayProtectedError as RelayProtectedErrorType,
type RelayManagedEndpointProviderKind,
} from "@t3tools/contracts/relay";
-import {
- exchangeRemoteDpopAccessToken,
- fetchRemoteEnvironmentDescriptor,
- makeEnvironmentHttpApiClient,
- ManagedRelayClient,
- ManagedRelayDpopSigner,
- type WsRpcClient,
-} from "@t3tools/client-runtime";
-import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
+import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
+import { request, runStream } from "@t3tools/client-runtime/rpc";
+import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
+import { ManagedRelayClient, type ManagedRelayClientError } from "@t3tools/client-runtime/relay";
-import { ensureLocalApi } from "../localApi";
-import {
- getPrimaryEnvironmentConnection,
- readEnvironmentConnection,
- type SavedEnvironmentRecord,
-} from "../environments/runtime";
import {
readPrimaryEnvironmentDescriptor,
readPrimaryEnvironmentTarget,
- resolvePrimaryEnvironmentHttpUrl,
} from "../environments/primary";
import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit";
import { resolveCloudPublicConfig } from "./publicConfig";
@@ -65,6 +54,7 @@ function relayUrl(): string | null {
export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
readonly message: string;
readonly cause?: unknown;
+ readonly traceId?: string;
}> {}
const relayClientRpcError = (message: string) => (cause: unknown) =>
@@ -74,13 +64,13 @@ const relayClientRpcError = (message: string) => (cause: unknown) =>
});
function ensureRelayClientAvailable(
- client: WsRpcClient,
-): Effect.Effect {
+ environmentId: EnvironmentId,
+): Effect.Effect {
return Effect.gen(function* () {
- const status = yield* Effect.tryPromise({
- try: () => client.cloud.getRelayClientStatus(),
- catch: relayClientRpcError("Could not check relay client availability."),
- });
+ const registry = yield* EnvironmentRegistry;
+ const status = yield* registry
+ .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {}))
+ .pipe(Effect.mapError(relayClientRpcError("Could not check relay client availability.")));
if (status.status === "available") return;
if (status.status === "unsupported") {
return yield* new CloudEnvironmentLinkError({
@@ -98,22 +88,35 @@ function ensureRelayClientAvailable(
});
}
- const installed = yield* Effect.tryPromise({
- try: () => client.cloud.installRelayClient(reportRelayClientInstallProgress),
- catch: relayClientRpcError("Could not install the relay client."),
- }).pipe(Effect.ensuring(Effect.sync(finishRelayClientInstall)));
- if (installed.status !== "available") {
+ const installed = yield* registry
+ .runStream(
+ environmentId,
+ runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe(
+ Stream.tap((event) => Effect.sync(() => reportRelayClientInstallProgress(event))),
+ ),
+ )
+ .pipe(
+ Stream.runLast,
+ Effect.mapError(relayClientRpcError("Could not install the relay client.")),
+ Effect.ensuring(Effect.sync(finishRelayClientInstall)),
+ );
+ if (Option.isNone(installed) || installed.value.type !== "complete") {
+ return yield* new CloudEnvironmentLinkError({
+ message: "The relay client install completed without a final status.",
+ });
+ }
+ const installedStatus = installed.value.status;
+ if (installedStatus.status !== "available") {
return yield* new CloudEnvironmentLinkError({
message:
- installed.status === "unsupported"
- ? `T3 Code cannot install the relay client automatically on ${installed.platform}-${installed.arch}.`
+ installedStatus.status === "unsupported"
+ ? `T3 Code cannot install the relay client automatically on ${installedStatus.platform}-${installedStatus.arch}.`
: "The relay client is still unavailable after installation.",
});
}
});
}
-const isRelayProtectedError = Schema.is(RelayProtectedError);
const isEnvironmentCloudApiError = Schema.is(
Schema.Union([
EnvironmentHttpBadRequestError,
@@ -156,31 +159,22 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
case "RelayAgentActivityPublishProofInvalidError":
return `Relay rejected the agent activity publish proof (${error.reason}).`;
case "RelayInternalError":
- return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`;
+ return `Relay encountered an internal error (${error.reason}).`;
}
}
function decodedRelayClientError(message: string) {
- return (cause: unknown) => {
- const relayError = findRelayProtectedError(cause);
+ return (cause: ManagedRelayClientError) => {
+ const relayError = cause.relayError;
const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
return new CloudEnvironmentLinkError({
message: detail ? `${message}: ${detail}` : message,
cause,
+ ...(cause.traceId ? { traceId: cause.traceId } : {}),
});
};
}
-function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null {
- if (isRelayProtectedError(cause)) {
- return cause;
- }
- if (typeof cause !== "object" || cause === null) {
- return null;
- }
- return "cause" in cause ? findRelayProtectedError(cause.cause) : null;
-}
-
function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {
if (isEnvironmentCloudApiError(cause)) {
return cause;
@@ -239,16 +233,6 @@ export interface CloudLinkTarget {
export type CloudLinkState = EnvironmentCloudLinkStateResult;
-export interface CloudManagedConnection {
- readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
- readonly label: string;
- readonly httpBaseUrl: string;
- readonly wsBaseUrl: string;
- readonly relayUrl: string;
- readonly accessToken: string;
- readonly relayTraceHeaders: Headers.Headers;
-}
-
export function collectCloudLinkTargets(input: {
readonly primary: CloudLinkTarget | null;
readonly saved: ReadonlyArray;
@@ -336,130 +320,11 @@ export function listCloudDevices(input: {
});
}
-export function connectManagedCloudEnvironment(input: {
- readonly clerkToken: string;
- readonly environment: RelayClientEnvironmentRecord;
- readonly relayUrl?: string;
-}): Effect.Effect<
- CloudManagedConnection,
- CloudEnvironmentLinkError,
- HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner
-> {
- return Effect.gen(function* () {
- const configuredRelayUrl = relayUrl();
- if (!configuredRelayUrl) {
- return yield* new CloudEnvironmentLinkError({
- message: "T3CODE_RELAY_URL is not configured.",
- });
- }
- const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl);
- if (persistedRelayUrl && persistedRelayUrl !== configuredRelayUrl) {
- return yield* new CloudEnvironmentLinkError({
- message: "The saved environment is linked through a different configured relay.",
- });
- }
- const relayClient = yield* ManagedRelayClient;
- const connected = yield* relayClient
- .connectEnvironment({
- clerkToken: input.clerkToken,
- scopes: [RelayEnvironmentConnectScope],
- environmentId: input.environment.environmentId,
- })
- .pipe(
- Effect.mapError(
- (cause) =>
- new CloudEnvironmentLinkError({
- message: "Could not connect to relay-managed environment.",
- cause,
- }),
- ),
- );
- if (connected.environmentId !== input.environment.environmentId) {
- return yield* new CloudEnvironmentLinkError({
- message: "Relay returned credentials for a different environment.",
- });
- }
- if (
- connected.endpoint.httpBaseUrl !== input.environment.endpoint.httpBaseUrl ||
- connected.endpoint.wsBaseUrl !== input.environment.endpoint.wsBaseUrl ||
- connected.endpoint.providerKind !== input.environment.endpoint.providerKind
- ) {
- return yield* new CloudEnvironmentLinkError({
- message: "Relay returned credentials for a different endpoint.",
- });
- }
- const descriptor = yield* fetchRemoteEnvironmentDescriptor({
- httpBaseUrl: connected.endpoint.httpBaseUrl,
- }).pipe(
- Effect.mapError(
- (cause) =>
- new CloudEnvironmentLinkError({
- message: "Could not read connected environment descriptor.",
- cause,
- }),
- ),
- );
- if (descriptor.environmentId !== connected.environmentId) {
- return yield* new CloudEnvironmentLinkError({
- message: "Connected endpoint does not match the selected environment.",
- });
- }
- const signer = yield* ManagedRelayDpopSigner;
- const bootstrapProof = yield* signer
- .createProof({
- method: "POST",
- url: new URL("/oauth/token", connected.endpoint.httpBaseUrl).toString(),
- })
- .pipe(
- Effect.mapError(
- (cause) =>
- new CloudEnvironmentLinkError({
- message: "Could not create environment DPoP proof.",
- cause,
- }),
- ),
- );
- const session = yield* exchangeRemoteDpopAccessToken({
- httpBaseUrl: connected.endpoint.httpBaseUrl,
- credential: connected.credential,
- dpopProof: bootstrapProof,
- }).pipe(
- Effect.mapError(
- (cause) =>
- new CloudEnvironmentLinkError({
- message: "Could not authorize managed environment.",
- cause,
- }),
- ),
- );
- return {
- environmentId: descriptor.environmentId,
- label: descriptor.label,
- httpBaseUrl: connected.endpoint.httpBaseUrl,
- wsBaseUrl: connected.endpoint.wsBaseUrl,
- relayUrl: configuredRelayUrl,
- accessToken: session.access_token,
- relayTraceHeaders: HttpTraceContext.toHeaders(yield* Effect.currentSpan.pipe(Effect.orDie)),
- };
- }).pipe(
- Effect.withSpan("relay.environment.connect", {
- root: true,
- attributes: { "relay.environment_id": input.environment.environmentId },
- }),
- withRelayClientTracing,
- );
-}
-
-export function readPrimaryCloudLinkState(): Effect.Effect<
- CloudLinkState | null,
- CloudEnvironmentLinkError,
- HttpClient.HttpClient
-> {
+export function readPrimaryCloudLinkState(input: {
+ readonly target: CloudLinkTarget;
+}): Effect.Effect {
return Effect.gen(function* () {
- if (!readPrimaryCloudLinkTarget()) {
- return null;
- }
- const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
+ const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
return yield* client.cloud
.linkState({ headers: {} })
.pipe(
@@ -470,10 +335,11 @@ export function readPrimaryCloudLinkState(): Effect.Effect<
}
export function updatePrimaryCloudPreferences(input: {
+ readonly target: CloudLinkTarget;
readonly publishAgentActivity: boolean;
}): Effect.Effect {
return Effect.gen(function* () {
- const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
+ const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
return yield* client.cloud
.preferences({
headers: {},
@@ -487,16 +353,11 @@ export function updatePrimaryCloudPreferences(input: {
}
export function unlinkPrimaryEnvironmentFromCloud(input: {
+ readonly target: CloudLinkTarget;
readonly clerkToken: string | null;
}): Effect.Effect {
return Effect.gen(function* () {
- const target = readPrimaryCloudLinkTarget();
- if (!target) {
- return yield* new CloudEnvironmentLinkError({
- message: "Local environment is not ready yet.",
- });
- }
- const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
+ const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
yield* client.cloud
.unlink({ headers: {} })
.pipe(
@@ -510,7 +371,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {
yield* relayClient
.unlinkEnvironment({
clerkToken: input.clerkToken,
- environmentId: EnvironmentId.make(target.environmentId),
+ environmentId: EnvironmentId.make(input.target.environmentId),
})
.pipe(
Effect.catch((cause) =>
@@ -523,115 +384,14 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {
});
}
-export function linkEnvironmentToCloud(input: {
- readonly environment: SavedEnvironmentRecord;
- readonly clerkToken: string;
-}): Effect.Effect {
- return Effect.gen(function* () {
- const configuredRelayUrl = relayUrl();
- if (!configuredRelayUrl) {
- return yield* new CloudEnvironmentLinkError({
- message: "T3CODE_RELAY_URL is not configured.",
- });
- }
- const relayClient = yield* ManagedRelayClient;
- const bearerToken = yield* Effect.tryPromise({
- try: () =>
- ensureLocalApi().persistence.getSavedEnvironmentSecret(input.environment.environmentId),
- catch: (cause) =>
- new CloudEnvironmentLinkError({
- message: `Could not read saved bearer token for ${input.environment.label}.`,
- cause,
- }),
- });
- if (!bearerToken) {
- return yield* new CloudEnvironmentLinkError({
- message: `No saved bearer token for ${input.environment.label}.`,
- });
- }
-
- const connection = readEnvironmentConnection(input.environment.environmentId);
- if (!connection) {
- return yield* new CloudEnvironmentLinkError({
- message: `${input.environment.label} is not connected.`,
- });
- }
- yield* ensureRelayClientAvailable(connection.client);
-
- const environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl);
- const headers = { authorization: `Bearer ${bearerToken}` };
-
- const challenge = yield* relayClient
- .createEnvironmentLinkChallenge({
- clerkToken: input.clerkToken,
- payload: {
- notificationsEnabled: true,
- liveActivitiesEnabled: true,
- managedTunnelsEnabled: true,
- },
- })
- .pipe(
- Effect.mapError(
- decodedRelayClientError(
- `${configuredRelayUrl}/v1/client/environment-link-challenges failed`,
- ),
- ),
- );
- const proof = yield* environmentClient.cloud
- .linkProof({
- headers,
- payload: {
- challenge: challenge.challenge,
- relayIssuer: configuredRelayUrl,
- endpoint: {
- httpBaseUrl: input.environment.httpBaseUrl,
- wsBaseUrl: input.environment.wsBaseUrl,
- providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
- },
- origin: endpointOrigin(input.environment.httpBaseUrl),
- },
- })
- .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof.")));
- const link = yield* relayClient
- .linkEnvironment({
- clerkToken: input.clerkToken,
- payload: {
- proof,
- notificationsEnabled: true,
- liveActivitiesEnabled: true,
- managedTunnelsEnabled: true,
- },
- })
- .pipe(
- Effect.mapError(
- decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`),
- ),
- );
- yield* ensureLinkedEnvironmentMatches({
- expectedEnvironmentId: input.environment.environmentId,
- expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND,
- link,
- });
-
- yield* environmentClient.cloud
- .relayConfig({
- headers,
- payload: {
- relayUrl: configuredRelayUrl,
- relayIssuer: link.relayIssuer,
- cloudUserId: link.cloudUserId,
- environmentCredential: link.environmentCredential,
- cloudMintPublicKey: link.cloudMintPublicKey,
- endpointRuntime: link.endpointRuntime,
- },
- })
- .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access.")));
- });
-}
-
export function linkPrimaryEnvironmentToCloud(input: {
+ readonly target: CloudLinkTarget;
readonly clerkToken: string;
-}): Effect.Effect {
+}): Effect.Effect<
+ void,
+ CloudEnvironmentLinkError,
+ EnvironmentRegistry | HttpClient.HttpClient | ManagedRelayClient
+> {
return Effect.gen(function* () {
const configuredRelayUrl = relayUrl();
if (!configuredRelayUrl) {
@@ -640,14 +400,8 @@ export function linkPrimaryEnvironmentToCloud(input: {
});
}
const relayClient = yield* ManagedRelayClient;
- const target = readPrimaryCloudLinkTarget();
- if (!target) {
- return yield* new CloudEnvironmentLinkError({
- message: "Local environment is not ready yet.",
- });
- }
- const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl);
- yield* ensureRelayClientAvailable(getPrimaryEnvironmentConnection().client);
+ const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
+ yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));
const challenge = yield* relayClient
.createEnvironmentLinkChallenge({
@@ -672,11 +426,11 @@ export function linkPrimaryEnvironmentToCloud(input: {
challenge: challenge.challenge,
relayIssuer: configuredRelayUrl,
endpoint: {
- httpBaseUrl: target.httpBaseUrl,
- wsBaseUrl: target.wsBaseUrl,
+ httpBaseUrl: input.target.httpBaseUrl,
+ wsBaseUrl: input.target.wsBaseUrl,
providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
},
- origin: endpointOrigin(target.httpBaseUrl),
+ origin: endpointOrigin(input.target.httpBaseUrl),
},
})
.pipe(
@@ -699,7 +453,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
),
);
yield* ensureLinkedEnvironmentMatches({
- expectedEnvironmentId: target.environmentId,
+ expectedEnvironmentId: input.target.environmentId,
expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND,
link,
});
diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts
new file mode 100644
index 00000000000..9f28ccdc077
--- /dev/null
+++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts
@@ -0,0 +1,22 @@
+import { Atom } from "effect/unstable/reactivity";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+import {
+ linkPrimaryEnvironmentToCloud,
+ type CloudLinkTarget,
+ unlinkPrimaryEnvironmentFromCloud,
+} from "./linkEnvironment";
+
+export const linkPrimaryEnvironment = connectionAtomRuntime
+ .fn<{
+ readonly target: CloudLinkTarget;
+ readonly clerkToken: string;
+ }>()(linkPrimaryEnvironmentToCloud)
+ .pipe(Atom.withLabel("web:cloud:link-primary-environment"));
+
+export const unlinkPrimaryEnvironment = connectionAtomRuntime
+ .fn<{
+ readonly target: CloudLinkTarget;
+ readonly clerkToken: string | null;
+ }>()(unlinkPrimaryEnvironmentFromCloud)
+ .pipe(Atom.withLabel("web:cloud:unlink-primary-environment"));
diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx
index b00c445f08d..52e7a067e67 100644
--- a/apps/web/src/cloud/managedAuth.tsx
+++ b/apps/web/src/cloud/managedAuth.tsx
@@ -1,7 +1,14 @@
import { useAuth } from "@clerk/react";
-import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";
-import { useEffect, type ReactNode } from "react";
+import {
+ createManagedRelaySession,
+ ManagedRelayClient,
+ setManagedRelaySession,
+} from "@t3tools/client-runtime/relay";
+import * as Effect from "effect/Effect";
+import { useEffect, useRef, type ReactNode } from "react";
+import { useEnvironmentConnectionActions } from "../state/environments";
+import { runtime } from "../lib/runtime";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { resolveRelayClerkTokenOptions } from "./publicConfig";
@@ -12,24 +19,80 @@ export async function readManagedRelayClerkToken(): Promise {
}
export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) {
- const { getToken, isSignedIn, userId } = useAuth();
+ const { getToken, isLoaded, isSignedIn, userId } = useAuth({
+ treatPendingAsSignedOut: false,
+ });
+ const { removeRelayEnvironments } = useEnvironmentConnectionActions();
+ const observedAccountRef = useRef(undefined);
+ const accountTransitionRef = useRef(Promise.resolve());
useEffect(() => {
+ if (!isLoaded) {
+ return;
+ }
+
+ let cancelled = false;
+ const previousAccount = observedAccountRef.current;
+ const nextAccount = isSignedIn && userId ? userId : null;
+ observedAccountRef.current = nextAccount;
+
+ const queueAccountCleanup = () => {
+ accountTransitionRef.current = accountTransitionRef.current.then(async () => {
+ const results = await Promise.allSettled([
+ removeRelayEnvironments(),
+ runtime.runPromise(
+ ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
+ ),
+ ]);
+ for (const result of results) {
+ if (result.status === "rejected") {
+ console.warn("[t3-cloud] cloud account cleanup failed", result.reason);
+ }
+ }
+ });
+ return accountTransitionRef.current;
+ };
+
relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null;
- setManagedRelaySession(
- appAtomRegistry,
- isSignedIn && userId
- ? createManagedRelaySession({
- accountId: userId,
- readClerkToken: () => getToken(resolveRelayClerkTokenOptions()),
- })
- : null,
- );
+ if (!isSignedIn || !userId) {
+ setManagedRelaySession(appAtomRegistry, null);
+ if (previousAccount !== null) {
+ void queueAccountCleanup();
+ }
+ } else {
+ if (previousAccount !== undefined && previousAccount !== null && previousAccount !== userId) {
+ setManagedRelaySession(appAtomRegistry, null);
+ void queueAccountCleanup().then(() => {
+ if (!cancelled) {
+ setManagedRelaySession(
+ appAtomRegistry,
+ createManagedRelaySession({
+ accountId: userId,
+ readClerkToken: () => getToken(resolveRelayClerkTokenOptions()),
+ }),
+ );
+ }
+ });
+ } else {
+ void accountTransitionRef.current.then(() => {
+ if (!cancelled) {
+ setManagedRelaySession(
+ appAtomRegistry,
+ createManagedRelaySession({
+ accountId: userId,
+ readClerkToken: () => getToken(resolveRelayClerkTokenOptions()),
+ }),
+ );
+ }
+ });
+ }
+ }
return () => {
+ cancelled = true;
relayTokenProvider = null;
setManagedRelaySession(appAtomRegistry, null);
};
- }, [getToken, isSignedIn, userId]);
+ }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]);
return children;
}
diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts
index f34ad2f9c99..53a3e24c6d8 100644
--- a/apps/web/src/cloud/managedRelayLayer.ts
+++ b/apps/web/src/cloud/managedRelayLayer.ts
@@ -1,8 +1,8 @@
import {
- managedRelayClientLayer,
+ managedRelayClientLayer as makeManagedRelayClientLayer,
ManagedRelayDpopSigner,
ManagedRelayDpopSignerError,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/relay";
import { RelayWebClientId } from "@t3tools/contracts/relay";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
@@ -17,7 +17,7 @@ import {
type BrowserDpopKey,
} from "./dpop";
-export const webRelayDpopSignerLayer = Layer.effect(
+export const relayDpopSignerLayer = Layer.effect(
ManagedRelayDpopSigner,
Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
@@ -39,24 +39,28 @@ export const webRelayDpopSignerLayer = Layer.effect(
return generated;
}),
);
- const signerError = (cause: unknown) => new ManagedRelayDpopSignerError({ cause });
+
return ManagedRelayDpopSigner.of({
thumbprint: loadOrCreateBrowserDpopKey.pipe(
Effect.map((proofKey) => proofKey.thumbprint),
- Effect.mapError(signerError),
+ Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
+ Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"),
+ ),
+ createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(
+ function* (input) {
+ const proofKey = yield* loadOrCreateBrowserDpopKey;
+ return yield* createBrowserDpopProof({ ...input, proofKey }).pipe(
+ Effect.provideService(Crypto.Crypto, crypto),
+ Effect.map((proof) => proof.proof),
+ );
+ },
+ Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
),
- createProof: (input) =>
- loadOrCreateBrowserDpopKey.pipe(
- Effect.flatMap((proofKey) => createBrowserDpopProof({ ...input, proofKey })),
- Effect.provideService(Crypto.Crypto, crypto),
- Effect.map((proof) => proof.proof),
- Effect.mapError(signerError),
- ),
});
}),
);
-export const webManagedRelayClientLayer = (relayUrl: string) =>
- managedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe(
- Layer.provideMerge(webRelayDpopSignerLayer),
+export const managedRelayClientLayer = (relayUrl: string) =>
+ makeManagedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe(
+ Layer.provideMerge(relayDpopSignerLayer),
);
diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts
index a31ee9e16f3..0a1ec61a3cc 100644
--- a/apps/web/src/cloud/managedRelayState.ts
+++ b/apps/web/src/cloud/managedRelayState.ts
@@ -4,7 +4,7 @@ import {
ManagedRelayClient,
managedRelaySessionAtom,
readManagedRelaySnapshotState,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/relay";
import type {
RelayClientDeviceRecord,
RelayClientEnvironmentRecord,
@@ -13,17 +13,15 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
-import { webRuntime } from "../lib/runtime";
+import { runtime } from "../lib/runtime";
import { appAtomRegistry } from "../rpc/atomRegistry";
const managedRelayAtomRuntime = Atom.runtime(
Layer.effect(
ManagedRelayClient,
- webRuntime.contextEffect.pipe(
- Effect.map((context) => Context.get(context, ManagedRelayClient)),
- ),
+ runtime.contextEffect.pipe(Effect.map((context) => Context.get(context, ManagedRelayClient))),
),
);
@@ -44,6 +42,15 @@ export function useManagedRelayEnvironments() {
? managedRelayQueryManager.environmentsAtom(accountId)
: EMPTY_ENVIRONMENTS_ATOM;
const result = useAtomValue(atom);
+ const snapshot = readManagedRelaySnapshotState(result);
+ useEffect(() => {
+ if (snapshot.error) {
+ console.error("[t3-cloud] Relay environment listing failed", {
+ message: snapshot.error,
+ traceId: snapshot.errorTraceId,
+ });
+ }
+ }, [snapshot.error, snapshot.errorTraceId]);
const refresh = useCallback(() => {
if (accountId) {
managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId);
@@ -51,7 +58,7 @@ export function useManagedRelayEnvironments() {
}, [accountId]);
return {
- ...readManagedRelaySnapshotState(result),
+ ...snapshot,
accountId,
refresh,
};
@@ -62,6 +69,15 @@ export function useManagedRelayDevices() {
const accountId = session?.accountId ?? null;
const atom = accountId ? managedRelayQueryManager.devicesAtom(accountId) : EMPTY_DEVICES_ATOM;
const result = useAtomValue(atom);
+ const snapshot = readManagedRelaySnapshotState(result);
+ useEffect(() => {
+ if (snapshot.error) {
+ console.error("[t3-cloud] Relay device listing failed", {
+ message: snapshot.error,
+ traceId: snapshot.errorTraceId,
+ });
+ }
+ }, [snapshot.error, snapshot.errorTraceId]);
const refresh = useCallback(() => {
if (accountId) {
managedRelayQueryManager.refreshDevices(appAtomRegistry, accountId);
@@ -69,7 +85,7 @@ export function useManagedRelayDevices() {
}, [accountId]);
return {
- ...readManagedRelaySnapshotState(result),
+ ...snapshot,
accountId,
refresh,
};
diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts
index ecc2297595d..70424963dc3 100644
--- a/apps/web/src/cloud/primaryCloudLinkState.ts
+++ b/apps/web/src/cloud/primaryCloudLinkState.ts
@@ -1,5 +1,5 @@
import { useAtomValue } from "@effect/atom-react";
-import type { EnvironmentCloudLinkStateResult, EnvironmentId } from "@t3tools/contracts";
+import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
@@ -7,51 +7,68 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { HttpClient } from "effect/unstable/http";
-import { useCallback } from "react";
+import { useCallback, useMemo } from "react";
-import { usePrimaryEnvironmentId } from "../environments/primary";
-import { webRuntime } from "../lib/runtime";
+import { usePrimaryEnvironment } from "../state/environments";
+import { runtime } from "../lib/runtime";
import { appAtomRegistry } from "../rpc/atomRegistry";
-import { readPrimaryCloudLinkState } from "./linkEnvironment";
+import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment";
const primaryCloudLinkAtomRuntime = Atom.runtime(
Layer.effect(
HttpClient.HttpClient,
- webRuntime.contextEffect.pipe(
+ runtime.contextEffect.pipe(
Effect.map((context) => Context.get(context, HttpClient.HttpClient)),
),
),
);
-const primaryCloudLinkStateAtom = Atom.family((environmentId: EnvironmentId) =>
- primaryCloudLinkAtomRuntime
- .atom(readPrimaryCloudLinkState())
+const primaryCloudLinkStateAtom = Atom.family((key: string) => {
+ const target = JSON.parse(key) as CloudLinkTarget;
+ return primaryCloudLinkAtomRuntime
+ .atom(readPrimaryCloudLinkState({ target }))
.pipe(
Atom.swr({ staleTime: 5_000, revalidateOnMount: true }),
Atom.setIdleTTL(5 * 60_000),
- Atom.withLabel(`primary-cloud-link:${environmentId}`),
- ),
-);
+ Atom.withLabel(`primary-cloud-link:${target.environmentId}`),
+ );
+});
const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make(
AsyncResult.success(null),
).pipe(Atom.keepAlive, Atom.withLabel("primary-cloud-link:null"));
-export function refreshPrimaryCloudLinkState(environmentId: EnvironmentId | null): void {
- if (environmentId) {
- appAtomRegistry.refresh(primaryCloudLinkStateAtom(environmentId));
+function targetKey(target: CloudLinkTarget): string {
+ return JSON.stringify(target);
+}
+
+export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void {
+ if (target) {
+ appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target)));
}
}
export function usePrimaryCloudLinkState() {
- const environmentId = usePrimaryEnvironmentId();
- const atom = environmentId
- ? primaryCloudLinkStateAtom(environmentId)
+ const primary = usePrimaryEnvironment();
+ const target = useMemo(
+ () =>
+ primary?.entry.target._tag === "PrimaryConnectionTarget"
+ ? {
+ environmentId: primary.environmentId,
+ label: primary.label,
+ httpBaseUrl: primary.entry.target.httpBaseUrl,
+ wsBaseUrl: primary.entry.target.wsBaseUrl,
+ }
+ : null,
+ [primary],
+ );
+ const atom = target
+ ? primaryCloudLinkStateAtom(targetKey(target))
: EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM;
const result = useAtomValue(atom);
const refresh = useCallback(() => {
- refreshPrimaryCloudLinkState(environmentId);
- }, [environmentId]);
+ refreshPrimaryCloudLinkState(target);
+ }, [target]);
let error: string | null = null;
if (result._tag === "Failure") {
const cause = Cause.squash(result.cause);
@@ -63,5 +80,6 @@ export function usePrimaryCloudLinkState() {
error,
isPending: result.waiting,
refresh,
+ target,
};
}
diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx
index 27c5c311c60..e135d0813b8 100644
--- a/apps/web/src/components/BranchToolbar.tsx
+++ b/apps/web/src/components/BranchToolbar.tsx
@@ -1,4 +1,4 @@
-import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import {
ChevronDownIcon,
@@ -11,9 +11,8 @@ import {
import { memo, useMemo } from "react";
import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
+import { useProject, useThreadDetail } from "../state/entities";
import { useIsMobile } from "../hooks/useMediaQuery";
-import { useStore } from "../store";
-import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
import {
type EnvMode,
type EnvironmentOption,
@@ -207,8 +206,7 @@ export const BranchToolbar = memo(function BranchToolbar({
() => scopeThreadRef(environmentId, threadId),
[environmentId, threadId],
);
- const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]);
- const serverThread = useStore(serverThreadSelector);
+ const serverThread = useThreadDetail(threadRef);
const draftThread = useComposerDraftStore((store) =>
draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
);
@@ -217,21 +215,17 @@ export const BranchToolbar = memo(function BranchToolbar({
: draftThread
? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
: null;
- const activeProjectSelector = useMemo(
- () => createProjectSelectorByRef(activeProjectRef),
- [activeProjectRef],
- );
- const activeProject = useStore(activeProjectSelector);
- const hasActiveThread = serverThread !== undefined || draftThread !== null;
+ const activeProject = useProject(activeProjectRef);
+ const hasActiveThread = serverThread !== null || draftThread !== null;
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
const effectiveEnvMode =
effectiveEnvModeOverride ??
resolveEffectiveEnvMode({
activeWorktreePath,
- hasServerThread: serverThread !== undefined,
+ hasServerThread: serverThread !== null,
draftThreadEnvMode: draftThread?.envMode,
});
- const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null);
+ const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null);
const showEnvironmentPicker = Boolean(
availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx
index 152df1bf3e5..e227b88bcfc 100644
--- a/apps/web/src/components/BranchToolbarBranchSelector.tsx
+++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx
@@ -1,5 +1,6 @@
-import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
+import { useAtomSet } from "@effect/atom-react";
import { LegendList, type LegendListRef } from "@legendapp/list/react";
import { ChevronDownIcon } from "lucide-react";
import {
@@ -14,15 +15,14 @@ import {
} from "react";
import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
-import { readEnvironmentApi } from "../environmentApi";
-import { useVcsStatus } from "../lib/vcsStatusState";
-import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState";
-import { newCommandId } from "../lib/utils";
+import { usePaginatedBranches } from "../state/queries";
+import { useProject, useThreadDetail } from "../state/entities";
+import { useEnvironmentQuery } from "../state/query";
+import { threadEnvironment } from "../state/threads";
+import { vcsEnvironment } from "../state/vcs";
import { cn } from "../lib/utils";
import { parsePullRequestReference } from "../pullRequestReference";
import { getSourceControlPresentation } from "../sourceControlPresentation";
-import { useStore } from "../store";
-import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
import {
deriveLocalBranchNameFromRemoteRef,
resolveBranchSelectionTarget,
@@ -58,8 +58,6 @@ interface BranchToolbarBranchSelectorProps {
onComposerFocusRequest?: () => void;
}
-const EMPTY_REFS: ReadonlyArray = [];
-
function toBranchActionErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "An error occurred.";
}
@@ -91,6 +89,12 @@ export function BranchToolbarBranchSelector({
onCheckoutPullRequestRequest,
onComposerFocusRequest,
}: BranchToolbarBranchSelectorProps) {
+ const stopThreadSession = useAtomSet(threadEnvironment.stopSession, { mode: "promise" });
+ const updateThreadMetadata = useAtomSet(threadEnvironment.updateMetadata, {
+ mode: "promise",
+ });
+ const switchRef = useAtomSet(vcsEnvironment.switchRef, { mode: "promise" });
+ const createRefMutation = useAtomSet(vcsEnvironment.createRef, { mode: "promise" });
// ---------------------------------------------------------------------------
// Thread / project state (pushed down from parent to colocate with mutation)
// ---------------------------------------------------------------------------
@@ -98,10 +102,8 @@ export function BranchToolbarBranchSelector({
() => scopeThreadRef(environmentId, threadId),
[environmentId, threadId],
);
- const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]);
- const serverThread = useStore(serverThreadSelector);
+ const serverThread = useThreadDetail(threadRef);
const serverSession = serverThread?.session ?? null;
- const setThreadBranchAction = useStore((store) => store.setThreadBranch);
const draftThread = useComposerDraftStore((store) =>
draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
);
@@ -112,11 +114,7 @@ export function BranchToolbarBranchSelector({
: draftThread
? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
: null;
- const activeProjectSelector = useMemo(
- () => createProjectSelectorByRef(activeProjectRef),
- [activeProjectRef],
- );
- const activeProject = useStore(activeProjectSelector);
+ const activeProject = useProject(activeProjectRef);
const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
const activeThreadBranch =
@@ -124,9 +122,9 @@ export function BranchToolbarBranchSelector({
? activeThreadBranchOverride
: (serverThread?.branch ?? draftThread?.branch ?? null);
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
- const activeProjectCwd = activeProject?.cwd ?? null;
+ const activeProjectCwd = activeProject?.workspaceRoot ?? null;
const branchCwd = activeWorktreePath ?? activeProjectCwd;
- const hasServerThread = serverThread !== undefined;
+ const hasServerThread = serverThread !== null;
const effectiveEnvMode =
effectiveEnvModeOverride ??
resolveEffectiveEnvMode({
@@ -141,29 +139,24 @@ export function BranchToolbarBranchSelector({
const setThreadBranch = useCallback(
(branch: string | null, worktreePath: string | null) => {
if (!activeThreadId || !activeProject) return;
- const api = readEnvironmentApi(environmentId);
- if (serverSession && worktreePath !== activeWorktreePath && api) {
- void api.orchestration
- .dispatchCommand({
- type: "thread.session.stop",
- commandId: newCommandId(),
- threadId: activeThreadId,
- createdAt: new Date().toISOString(),
- })
- .catch(() => undefined);
+ if (serverSession && worktreePath !== activeWorktreePath) {
+ void stopThreadSession({
+ environmentId,
+ input: { threadId: activeThreadId },
+ }).catch(() => undefined);
}
- if (api && hasServerThread) {
- void api.orchestration.dispatchCommand({
- type: "thread.meta.update",
- commandId: newCommandId(),
- threadId: activeThreadId,
- branch,
- worktreePath,
+ if (hasServerThread) {
+ void updateThreadMetadata({
+ environmentId,
+ input: {
+ threadId: activeThreadId,
+ branch,
+ worktreePath,
+ },
});
}
if (hasServerThread) {
onActiveThreadBranchOverrideChange?.(branch);
- setThreadBranchAction(threadRef, branch, worktreePath);
return;
}
const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({
@@ -185,12 +178,13 @@ export function BranchToolbarBranchSelector({
activeWorktreePath,
hasServerThread,
onActiveThreadBranchOverrideChange,
- setThreadBranchAction,
setDraftThreadContext,
draftId,
threadRef,
environmentId,
effectiveEnvMode,
+ stopThreadSession,
+ updateThreadMetadata,
],
);
@@ -201,7 +195,14 @@ export function BranchToolbarBranchSelector({
const [branchQuery, setBranchQuery] = useState("");
const deferredBranchQuery = useDeferredValue(branchQuery);
- const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd });
+ const branchStatusQuery = useEnvironmentQuery(
+ branchCwd === null
+ ? null
+ : vcsEnvironment.status({
+ environmentId,
+ input: { cwd: branchCwd },
+ }),
+ );
const trimmedBranchQuery = branchQuery.trim();
const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
const branchRefTarget = useMemo(
@@ -212,11 +213,11 @@ export function BranchToolbarBranchSelector({
}),
[branchCwd, deferredTrimmedBranchQuery, environmentId],
);
- const branchRefState = useVcsRefs(branchRefTarget);
- const refs = branchRefState.data?.refs ?? EMPTY_REFS;
+ const branchRefState = usePaginatedBranches(branchRefTarget);
+ const refs = branchRefState.refs;
const hasNextPage =
branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined;
- const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
+ const isFetchingNextPage = branchRefState.isPending && branchRefState.data !== null;
const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null;
const currentGitBranch =
branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null;
@@ -297,15 +298,13 @@ export function BranchToolbarBranchSelector({
const runBranchAction = (action: () => Promise) => {
startBranchActionTransition(async () => {
await action().catch(() => undefined);
- await vcsRefManager
- .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
- .catch(() => undefined);
+ branchRefState.refresh();
+ branchStatusQuery.refresh();
});
};
const selectBranch = (refName: VcsRef) => {
- const api = readEnvironmentApi(environmentId);
- if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return;
+ if (!branchCwd || !activeProjectCwd || isBranchActionPending) return;
if (isSelectingWorktreeBase) {
setThreadBranch(refName.name, null);
@@ -338,9 +337,12 @@ export function BranchToolbarBranchSelector({
const previousBranch = resolvedActiveBranch;
setOptimisticBranch(selectedBranchName);
try {
- const checkoutResult = await api.vcs.switchRef({
- cwd: selectionTarget.checkoutCwd,
- refName: refName.name,
+ const checkoutResult = await switchRef({
+ environmentId,
+ input: {
+ cwd: selectionTarget.checkoutCwd,
+ refName: refName.name,
+ },
});
const nextBranchName = refName.isRemote
? (checkoutResult.refName ?? selectedBranchName)
@@ -362,8 +364,7 @@ export function BranchToolbarBranchSelector({
const createRef = (rawName: string) => {
const name = rawName.trim();
- const api = readEnvironmentApi(environmentId);
- if (!api || !branchCwd || !name || isBranchActionPending) return;
+ if (!branchCwd || !name || isBranchActionPending) return;
setIsBranchMenuOpen(false);
onComposerFocusRequest?.();
@@ -372,10 +373,13 @@ export function BranchToolbarBranchSelector({
const previousBranch = resolvedActiveBranch;
setOptimisticBranch(name);
try {
- const createBranchResult = await api.vcs.createRef({
- cwd: branchCwd,
- refName: name,
- switchRef: true,
+ const createBranchResult = await createRefMutation({
+ environmentId,
+ input: {
+ cwd: branchCwd,
+ refName: name,
+ switchRef: true,
+ },
});
setOptimisticBranch(createBranchResult.refName);
setThreadBranch(createBranchResult.refName, activeWorktreePath);
@@ -414,11 +418,9 @@ export function BranchToolbarBranchSelector({
setBranchQuery("");
return;
}
- void vcsRefManager
- .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
- .catch(() => undefined);
+ branchRefState.refresh();
},
- [branchRefTarget],
+ [branchRefState.refresh],
);
const branchListScrollElementRef = useRef(null);
@@ -427,12 +429,8 @@ export function BranchToolbarBranchSelector({
return;
}
- setIsFetchingNextPage(true);
- void vcsRefManager
- .loadNext(branchRefTarget, undefined, { limit: 100 })
- .catch(() => undefined)
- .finally(() => setIsFetchingNextPage(false));
- }, [branchRefTarget, hasNextPage, isFetchingNextPage]);
+ branchRefState.loadNext();
+ }, [branchRefState.loadNext, hasNextPage, isFetchingNextPage]);
const maybeFetchNextBranchPage = useCallback(() => {
if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) {
return;
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 6a85ccee96a..3409b19f2b3 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -23,7 +23,7 @@ import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";
import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { stackedThreadToast, toastManager } from "./ui/toast";
-import { openInPreferredEditor } from "../editorPreferences";
+import { useOpenInPreferredEditor } from "../editorPreferences";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
@@ -35,6 +35,9 @@ import {
} from "../markdown-links";
import { readLocalApi } from "../localApi";
import { cn } from "../lib/utils";
+import { useActiveEnvironmentId } from "../state/entities";
+import { useEnvironmentQuery } from "../state/query";
+import { serverEnvironment } from "../state/server";
class CodeHighlightErrorBoundary extends React.Component<
{ fallback: ReactNode; children: ReactNode },
@@ -283,6 +286,7 @@ interface MarkdownFileLinkProps {
filePath: string;
label: string;
theme: "light" | "dark";
+ onOpen: (targetPath: string) => Promise;
className?: string | undefined;
}
@@ -374,19 +378,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
filePath,
label,
theme,
+ onOpen,
className,
}: MarkdownFileLinkProps) {
const handleOpen = useCallback(() => {
- const api = readLocalApi();
- if (!api) {
- toastManager.add({
- type: "error",
- title: "Open in editor is unavailable",
- });
- return;
- }
-
- void openInPreferredEditor(api, targetPath).catch((error) => {
+ void onOpen(targetPath).catch((error) => {
toastManager.add(
stackedThreadToast({
type: "error",
@@ -395,7 +391,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
}),
);
});
- }, [targetPath]);
+ }, [onOpen, targetPath]);
const handleCopy = useCallback((value: string, title: string) => {
if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
@@ -508,6 +504,7 @@ function areMarkdownFileLinkPropsEqual(
previous.filePath === next.filePath &&
previous.label === next.label &&
previous.theme === next.theme &&
+ previous.onOpen === next.onOpen &&
previous.className === next.className
);
}
@@ -519,6 +516,14 @@ function ChatMarkdown({
skills = EMPTY_MARKDOWN_SKILLS,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
+ const environmentId = useActiveEnvironmentId();
+ const serverConfig = useEnvironmentQuery(
+ environmentId === null ? null : serverEnvironment.config({ environmentId, input: {} }),
+ );
+ const openInPreferredEditor = useOpenInPreferredEditor(
+ environmentId,
+ serverConfig.data?.availableEditors ?? [],
+ );
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const markdownFileLinkMetaByHref = useMemo(() => {
const metaByHref = new Map<
@@ -576,6 +581,7 @@ function ChatMarkdown({
filePath={fileLinkMeta.filePath}
label={labelParts.join(" · ")}
theme={resolvedTheme}
+ onOpen={openInPreferredEditor}
className={props.className}
/>
);
@@ -607,6 +613,7 @@ function ChatMarkdown({
fileLinkParentSuffixByPath,
isStreaming,
markdownFileLinkMetaByHref,
+ openInPreferredEditor,
resolvedTheme,
skills,
],
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 69e0e514061..6fd9b3210b2 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -5,7 +5,6 @@ import {
EventId,
ORCHESTRATION_WS_METHODS,
EnvironmentId,
- type EnvironmentApi,
type MessageId,
type OrchestrationReadModel,
type ProjectId,
@@ -22,7 +21,7 @@ import {
DEFAULT_TERMINAL_ID,
ServerConfig as ServerConfigSchema,
} from "@t3tools/contracts";
-import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import * as Option from "effect/Option";
@@ -44,16 +43,6 @@ import { render } from "vitest-browser-react";
import { useCommandPaletteStore } from "../commandPaletteStore";
import { useComposerDraftStore, DraftId } from "../composerDraftStore";
-import {
- __resetEnvironmentApiOverridesForTests,
- __setEnvironmentApiOverrideForTests,
-} from "../environmentApi";
-import {
- resetSavedEnvironmentRegistryStoreForTests,
- resetSavedEnvironmentRuntimeStoreForTests,
- useSavedEnvironmentRegistryStore,
- useSavedEnvironmentRuntimeStore,
-} from "../environments/runtime";
import {
INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
removeInlineTerminalContextPlaceholder,
@@ -61,12 +50,9 @@ import {
} from "../lib/terminalContext";
import { isMacPlatform } from "../lib/utils";
import { __resetLocalApiForTests } from "../localApi";
-import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
import { getServerConfig } from "../rpc/serverState";
import { getRouter } from "../router";
import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
-import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
-import { terminalSessionManager } from "../terminalSessionState";
import { useTerminalUiStateStore } from "../terminalUiStateStore";
import { useUiStateStore } from "../uiStateStore";
import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";
@@ -74,46 +60,12 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
-vi.mock("../lib/vcsStatusState", () => {
- const status = {
- data: {
- isRepo: true,
- sourceControlProvider: {
- kind: "github",
- name: "GitHub",
- baseUrl: "https://github.com",
- },
- hasPrimaryRemote: true,
- isDefaultRef: true,
- refName: "main",
- hasWorkingTreeChanges: false,
- workingTree: { files: [], insertions: 0, deletions: 0 },
- hasUpstream: true,
- aheadCount: 0,
- behindCount: 0,
- pr: null,
- },
- error: null,
- cause: null,
- isPending: false,
- };
-
- return {
- getVcsStatusSnapshot: () => status,
- useVcsStatus: () => status,
- useVcsStatuses: () => new Map(),
- refreshVcsStatus: () => Promise.resolve(null),
- resetVcsStatusStateForTests: () => undefined,
- };
-});
-
const THREAD_ID = "thread-browser-test" as ThreadId;
const THREAD_TITLE = "Browser test thread";
const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId;
const PROJECT_ID = "project-1" as ProjectId;
const SECOND_PROJECT_ID = "project-2" as ProjectId;
const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
-const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");
const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
const THREAD_KEY = scopedThreadKey(THREAD_REF);
const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@@ -122,7 +74,7 @@ const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings(
{
environmentId: LOCAL_ENVIRONMENT_ID,
id: PROJECT_ID,
- cwd: "/repo/project",
+ workspaceRoot: "/repo/project",
repositoryIdentity: null,
},
{
@@ -134,6 +86,28 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z";
const BASE_TIME_MS = Date.parse(NOW_ISO);
const ATTACHMENT_SVG = "";
const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)";
+const INITIAL_VCS_STATUS_EVENT = {
+ _tag: "snapshot" as const,
+ local: {
+ isRepo: true,
+ sourceControlProvider: {
+ kind: "github" as const,
+ name: "GitHub",
+ baseUrl: "https://github.com",
+ },
+ hasPrimaryRemote: true,
+ isDefaultRef: true,
+ refName: "main",
+ hasWorkingTreeChanges: false,
+ workingTree: { files: [], insertions: 0, deletions: 0 },
+ },
+ remote: {
+ hasUpstream: true,
+ aheadCount: 0,
+ behindCount: 0,
+ pr: null,
+ },
+};
interface TestFixture {
snapshot: OrchestrationReadModel;
@@ -239,38 +213,6 @@ function createBaseServerConfig(): ServerConfig {
};
}
-function createMockEnvironmentApi(input: {
- browse: EnvironmentApi["filesystem"]["browse"];
- dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"];
-}): EnvironmentApi {
- return {
- terminal: {} as EnvironmentApi["terminal"],
- projects: {} as EnvironmentApi["projects"],
- filesystem: {
- browse: input.browse,
- },
- sourceControl: {} as EnvironmentApi["sourceControl"],
- vcs: {} as EnvironmentApi["vcs"],
- git: {} as EnvironmentApi["git"],
- review: {} as EnvironmentApi["review"],
- orchestration: {
- dispatchCommand: input.dispatchCommand,
- getTurnDiff: (() => {
- throw new Error("Not implemented in browser test.");
- }) as EnvironmentApi["orchestration"]["getTurnDiff"],
- getFullThreadDiff: (() => {
- throw new Error("Not implemented in browser test.");
- }) as EnvironmentApi["orchestration"]["getFullThreadDiff"],
- getArchivedShellSnapshot: (() => {
- throw new Error("Not implemented in browser test.");
- }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"],
- subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"],
- subscribeThread: (() => () =>
- undefined) as EnvironmentApi["orchestration"]["subscribeThread"],
- },
- };
-}
-
function createUserMessage(options: {
id: MessageId;
text: string;
@@ -569,11 +511,20 @@ function sendShellThreadUpsert(
});
}
-async function waitForWsClient(): Promise {
+async function waitForWsClient(router?: ReturnType): Promise {
await vi.waitFor(
() => {
+ const receivedRequestTags = wsRequests.map((request) => request._tag).join(", ");
+ const diagnostics = [
+ `requests=${receivedRequestTags}`,
+ `pathname=${router?.state.location.pathname ?? "unknown"}`,
+ `routerStatus=${router?.state.status ?? "unknown"}`,
+ `matches=${router?.state.matches.map((match) => match.routeId).join(",") ?? "unknown"}`,
+ `body=${document.body.textContent?.trim().slice(0, 200) || ""}`,
+ ].join("; ");
expect(
wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell),
+ `Expected shell subscription. ${diagnostics}`,
).toBe(true);
expect(
wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle),
@@ -624,7 +575,6 @@ async function waitForAppBootstrap(): Promise {
await vi.waitFor(
() => {
expect(getServerConfig()).not.toBeNull();
- expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true);
},
{ timeout: 8_000, interval: 16 },
);
@@ -1630,16 +1580,11 @@ async function mountChatView(options: {
}),
);
- const screen = await render(
-
-
- ,
- {
- container: host,
- },
- );
+ const screen = await render(, {
+ container: host,
+ });
- await waitForWsClient();
+ await waitForWsClient(router);
await waitForAppBootstrap();
await waitForLayout();
@@ -1736,6 +1681,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
if (request._tag === WS_METHODS.subscribeTerminalMetadata) {
return fixture.terminalMetadataEvents;
}
+ if (request._tag === WS_METHODS.subscribeVcsStatus) {
+ return [INITIAL_VCS_STATUS_EVENT];
+ }
return [];
},
});
@@ -1745,9 +1693,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
document.body.innerHTML = "";
wsRequests.length = 0;
customWsRpcResolver = null;
- __resetEnvironmentApiOverridesForTests();
- resetSavedEnvironmentRegistryStoreForTests();
- resetSavedEnvironmentRuntimeStoreForTests();
Reflect.deleteProperty(window, "desktopBridge");
useComposerDraftStore.setState({
draftsByThreadKey: {},
@@ -1760,10 +1705,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
open: false,
openIntent: null,
});
- useStore.setState({
- activeEnvironmentId: null,
- environmentStateById: {},
- });
useUiStateStore.setState({
projectExpandedById: {},
projectOrder: [],
@@ -3932,24 +3873,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
});
try {
- await vi.waitFor(
- () => {
- expect(
- terminalSessionManager.listSessions({
- environmentId: LOCAL_ENVIRONMENT_ID,
- threadId: THREAD_ID,
- }),
- ).toMatchObject([
- {
- state: {
- hasRunningSubprocess: true,
- },
- },
- ]);
- },
- { timeout: 8_000, interval: 16 },
- );
-
await vi.waitFor(
() => {
const terminalIndicator = document.querySelector(
@@ -5135,132 +5058,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});
- it("selects an environment before browsing when multiple environments are available", async () => {
- const remoteBrowseMock = vi.fn(async ({ partialPath }: { partialPath: string }) => {
- if (partialPath === "~/workspaces/") {
- return {
- parentPath: "~/workspaces/",
- entries: [{ name: "codething", fullPath: "~/workspaces/codething" }],
- };
- }
-
- return {
- parentPath: "~/",
- entries: [{ name: "workspaces", fullPath: "~/workspaces" }],
- };
- });
- const remoteDispatchMock = vi.fn(async () => ({
- sequence: fixture.snapshot.snapshotSequence + 1,
- }));
-
- __setEnvironmentApiOverrideForTests(
- REMOTE_ENVIRONMENT_ID,
- createMockEnvironmentApi({
- browse: remoteBrowseMock,
- dispatchCommand: remoteDispatchMock,
- }),
- );
-
- const mounted = await mountChatView({
- viewport: DEFAULT_VIEWPORT,
- snapshot: createSnapshotForTargetUser({
- targetMessageId: "msg-user-command-palette-add-project-multi-env" as MessageId,
- targetText: "command palette add project multi env",
- }),
- });
-
- try {
- await waitForServerConfigToApply();
- useSavedEnvironmentRegistryStore.getState().upsert({
- environmentId: REMOTE_ENVIRONMENT_ID,
- label: "Staging",
- httpBaseUrl: "https://staging.example.test",
- wsBaseUrl: "wss://staging.example.test/ws",
- createdAt: NOW_ISO,
- lastConnectedAt: NOW_ISO,
- });
- useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, {
- connectionState: "connected",
- authState: "authenticated",
- descriptor: {
- ...fixture.serverConfig.environment,
- environmentId: REMOTE_ENVIRONMENT_ID,
- label: "Staging",
- },
- serverConfig: {
- ...fixture.serverConfig,
- environment: {
- ...fixture.serverConfig.environment,
- environmentId: REMOTE_ENVIRONMENT_ID,
- label: "Staging",
- },
- settings: {
- ...fixture.serverConfig.settings,
- addProjectBaseDirectory: "~/workspaces",
- },
- },
- connectedAt: NOW_ISO,
- });
-
- const palette = page.getByTestId("command-palette");
- await openCommandPaletteFromTrigger();
-
- await expect.element(palette).toBeInTheDocument();
- await palette.getByText("Add project", { exact: true }).click();
- await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument();
- await expect
- .element(palette.getByText("This device", { exact: true }).first())
- .toBeInTheDocument();
- await palette.getByText("Staging", { exact: true }).click();
- await palette.getByText("Local folder", { exact: true }).click();
-
- const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
- await expect.element(browseInput).toHaveValue("~/workspaces/");
-
- await vi.waitFor(
- () => {
- expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" });
- },
- { timeout: 8_000, interval: 16 },
- );
-
- await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/workspaces/");
- await vi.waitFor(
- () => {
- expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" });
- },
- { timeout: 8_000, interval: 16 },
- );
- await expect.element(palette.getByText("codething", { exact: true })).toBeInTheDocument();
- await expect
- .element(palette.getByRole("button", { name: "Add (Enter)" }))
- .toBeInTheDocument();
-
- await dispatchInputKey(browseInput, { key: "Enter" });
-
- await vi.waitFor(
- () => {
- expect(remoteDispatchMock).toHaveBeenCalledWith(
- expect.objectContaining({
- type: "project.create",
- workspaceRoot: "~/workspaces",
- title: "workspaces",
- }),
- );
- },
- { timeout: 8_000, interval: 16 },
- );
-
- await waitForURL(
- mounted.router,
- (path) => UUID_ROUTE_RE.test(path),
- "Route should have changed to a new draft thread after adding a remote project.",
- );
- } finally {
- await mounted.cleanup();
- }
- });
-
it("picks a local project from the native file manager", async () => {
const pickFolder = vi.fn().mockResolvedValue("/Users/julius/Projects/finder-picked");
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 19c800ef139..8b0d7044876 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -1,16 +1,7 @@
-import { scopeThreadRef } from "@t3tools/client-runtime";
-import {
- EnvironmentId,
- ProjectId,
- ProviderDriverKind,
- ProviderInstanceId,
- ThreadId,
- TurnId,
-} from "@t3tools/contracts";
-import { afterEach, describe, expect, it, vi } from "vite-plus/test";
-import { type EnvironmentState, useStore } from "../store";
-import { type Thread } from "../types";
+import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+import type { Thread } from "../types";
import {
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
buildExpiredTerminalContextToastCopy,
@@ -20,10 +11,60 @@ import {
reconcileMountedTerminalThreadIds,
resolveSendEnvMode,
shouldWriteThreadErrorToCurrentServerThread,
- waitForStartedServerThread,
} from "./ChatView.logic";
-const localEnvironmentId = EnvironmentId.make("environment-local");
+const environmentId = EnvironmentId.make("environment-local");
+const projectId = ProjectId.make("project-1");
+const threadId = ThreadId.make("thread-1");
+const now = "2026-03-29T00:00:00.000Z";
+
+function makeThread(overrides: Partial = {}): Thread {
+ return {
+ id: threadId,
+ environmentId,
+ projectId,
+ title: "Thread",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.4",
+ },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ session: null,
+ messages: [],
+ proposedPlans: [],
+ activities: [],
+ checkpoints: [],
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ deletedAt: null,
+ latestTurn: null,
+ branch: null,
+ worktreePath: null,
+ ...overrides,
+ };
+}
+
+const completedTurn = {
+ turnId: TurnId.make("turn-1"),
+ state: "completed" as const,
+ requestedAt: now,
+ startedAt: "2026-03-29T00:00:01.000Z",
+ completedAt: "2026-03-29T00:00:10.000Z",
+ assistantMessageId: null,
+};
+
+const readySession = {
+ threadId,
+ status: "ready" as const,
+ providerName: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ runtimeMode: "full-access" as const,
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: "2026-03-29T00:00:10.000Z",
+};
describe("deriveComposerSendState", () => {
it("treats expired terminal pills as non-sendable content", () => {
@@ -33,13 +74,13 @@ describe("deriveComposerSendState", () => {
terminalContexts: [
{
id: "ctx-expired",
- threadId: ThreadId.make("thread-1"),
+ threadId,
terminalId: "default",
terminalLabel: "Terminal 1",
lineStart: 4,
lineEnd: 4,
text: "",
- createdAt: "2026-03-17T12:52:29.000Z",
+ createdAt: now,
},
],
});
@@ -57,13 +98,13 @@ describe("deriveComposerSendState", () => {
terminalContexts: [
{
id: "ctx-expired",
- threadId: ThreadId.make("thread-1"),
+ threadId,
terminalId: "default",
terminalLabel: "Terminal 1",
lineStart: 4,
lineEnd: 4,
text: "",
- createdAt: "2026-03-17T12:52:29.000Z",
+ createdAt: now,
},
],
});
@@ -75,14 +116,11 @@ describe("deriveComposerSendState", () => {
});
describe("buildExpiredTerminalContextToastCopy", () => {
- it("formats clear empty-state guidance", () => {
+ it("formats empty and omission guidance", () => {
expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({
title: "Expired terminal context won't be sent",
description: "Remove it or re-add it to include terminal output.",
});
- });
-
- it("formats omission guidance for sent messages", () => {
expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({
title: "Expired terminal contexts omitted from message",
description: "Re-add it if you want that terminal output included.",
@@ -91,411 +129,74 @@ describe("buildExpiredTerminalContextToastCopy", () => {
});
describe("resolveSendEnvMode", () => {
- it("keeps worktree mode for git repositories", () => {
+ it("keeps worktree mode only for git repositories", () => {
expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");
- });
-
- it("forces local mode for non-git repositories", () => {
expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local");
- expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local");
});
});
describe("reconcileMountedTerminalThreadIds", () => {
- it("keeps previously mounted open threads and adds the active open thread", () => {
+ it("keeps open threads and makes the active thread most recent", () => {
expect(
reconcileMountedTerminalThreadIds({
- currentThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-stale")],
- openThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-active")],
- activeThreadId: ThreadId.make("thread-active"),
- activeThreadTerminalOpen: true,
- }),
- ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]);
- });
-
- it("drops mounted threads once their terminal drawer is no longer open", () => {
- expect(
- reconcileMountedTerminalThreadIds({
- currentThreadIds: [ThreadId.make("thread-closed")],
- openThreadIds: [],
- activeThreadId: ThreadId.make("thread-closed"),
- activeThreadTerminalOpen: false,
- }),
- ).toEqual([]);
- });
-
- it("keeps only the most recently active hidden terminal threads", () => {
- expect(
- reconcileMountedTerminalThreadIds({
- currentThreadIds: [
- ThreadId.make("thread-1"),
- ThreadId.make("thread-2"),
- ThreadId.make("thread-3"),
- ],
- openThreadIds: [
- ThreadId.make("thread-1"),
- ThreadId.make("thread-2"),
- ThreadId.make("thread-3"),
- ThreadId.make("thread-4"),
- ],
- activeThreadId: ThreadId.make("thread-4"),
+ currentThreadIds: ["thread-a", "thread-b", "thread-c"],
+ openThreadIds: ["thread-a", "thread-b", "thread-c"],
+ activeThreadId: "thread-a",
activeThreadTerminalOpen: true,
maxHiddenThreadCount: 2,
}),
- ).toEqual([ThreadId.make("thread-2"), ThreadId.make("thread-3"), ThreadId.make("thread-4")]);
+ ).toEqual(["thread-b", "thread-c", "thread-a"]);
});
- it("moves the active thread to the end so it is treated as most recently used", () => {
- expect(
- reconcileMountedTerminalThreadIds({
- currentThreadIds: [
- ThreadId.make("thread-a"),
- ThreadId.make("thread-b"),
- ThreadId.make("thread-c"),
- ],
- openThreadIds: [
- ThreadId.make("thread-a"),
- ThreadId.make("thread-b"),
- ThreadId.make("thread-c"),
- ],
- activeThreadId: ThreadId.make("thread-a"),
- activeThreadTerminalOpen: true,
- maxHiddenThreadCount: 2,
- }),
- ).toEqual([ThreadId.make("thread-b"), ThreadId.make("thread-c"), ThreadId.make("thread-a")]);
- });
-
- it("defaults to the hidden mounted terminal cap", () => {
- const currentThreadIds = Array.from(
+ it("drops closed threads and enforces the hidden mounted cap", () => {
+ const ids = Array.from(
{ length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 },
- (_, index) => ThreadId.make(`thread-${index + 1}`),
+ (_, index) => `thread-${index}`,
);
-
expect(
reconcileMountedTerminalThreadIds({
- currentThreadIds,
- openThreadIds: currentThreadIds,
+ currentThreadIds: ids,
+ openThreadIds: ids.slice(1),
activeThreadId: null,
activeThreadTerminalOpen: false,
}),
- ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS));
+ ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS));
});
});
describe("shouldWriteThreadErrorToCurrentServerThread", () => {
- it("routes errors to the active server thread when route and target match", () => {
- const threadId = ThreadId.make("thread-1");
- const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId);
+ it("requires the environment, route thread, and target thread to match", () => {
+ const routeThreadRef = { environmentId, threadId };
expect(
shouldWriteThreadErrorToCurrentServerThread({
- serverThread: {
- environmentId: localEnvironmentId,
- id: threadId,
- },
+ serverThread: { environmentId, id: threadId },
routeThreadRef,
targetThreadId: threadId,
}),
).toBe(true);
- });
-
- it("does not route draft-thread errors into server-backed state", () => {
- const threadId = ThreadId.make("thread-1");
-
expect(
shouldWriteThreadErrorToCurrentServerThread({
- serverThread: undefined,
- routeThreadRef: scopeThreadRef(localEnvironmentId, threadId),
+ serverThread: null,
+ routeThreadRef,
targetThreadId: threadId,
}),
).toBe(false);
});
});
-const makeThread = (input?: {
- id?: ThreadId;
- latestTurn?: {
- turnId: TurnId;
- state: "running" | "completed";
- requestedAt: string;
- startedAt: string | null;
- completedAt: string | null;
- } | null;
-}): Thread => ({
- id: input?.id ?? ThreadId.make("thread-1"),
- environmentId: localEnvironmentId,
- codexThreadId: null,
- projectId: ProjectId.make("project-1"),
- title: "Thread",
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access" as const,
- interactionMode: "default" as const,
- session: null,
- messages: [],
- proposedPlans: [],
- error: null,
- createdAt: "2026-03-29T00:00:00.000Z",
- archivedAt: null,
- updatedAt: "2026-03-29T00:00:00.000Z",
- latestTurn: input?.latestTurn
- ? {
- ...input.latestTurn,
- assistantMessageId: null,
- }
- : null,
- branch: null,
- worktreePath: null,
- turnDiffSummaries: [],
- activities: [],
-});
-
-function setStoreThreads(threads: ReadonlyArray>) {
- const projectId = ProjectId.make("project-1");
- const environmentState: EnvironmentState = {
- projectIds: [projectId],
- projectById: {
- [projectId]: {
- id: projectId,
- environmentId: localEnvironmentId,
- name: "Project",
- cwd: "/tmp/project",
- defaultModelSelection: {
- instanceId: ProviderInstanceId.make("codex"),
- model: "gpt-5.4",
- },
- createdAt: "2026-03-29T00:00:00.000Z",
- updatedAt: "2026-03-29T00:00:00.000Z",
- scripts: [],
- },
- },
- threadIds: threads.map((thread) => thread.id),
- threadIdsByProjectId: {
- [projectId]: threads.map((thread) => thread.id),
- },
- threadShellById: Object.fromEntries(
- threads.map((thread) => [
- thread.id,
- {
- id: thread.id,
- environmentId: thread.environmentId,
- codexThreadId: thread.codexThreadId,
- projectId: thread.projectId,
- title: thread.title,
- modelSelection: thread.modelSelection,
- runtimeMode: thread.runtimeMode,
- interactionMode: thread.interactionMode,
- error: thread.error,
- createdAt: thread.createdAt,
- archivedAt: thread.archivedAt,
- updatedAt: thread.updatedAt,
- branch: thread.branch,
- worktreePath: thread.worktreePath,
- },
- ]),
- ),
- threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])),
- threadTurnStateById: Object.fromEntries(
- threads.map((thread) => [
- thread.id,
- {
- latestTurn: thread.latestTurn,
- ...(thread.pendingSourceProposedPlan
- ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan }
- : {}),
- },
- ]),
- ),
- messageIdsByThreadId: Object.fromEntries(
- threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]),
- ),
- messageByThreadId: Object.fromEntries(
- threads.map((thread) => [
- thread.id,
- Object.fromEntries(thread.messages.map((message) => [message.id, message])),
- ]),
- ),
- activityIdsByThreadId: Object.fromEntries(
- threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]),
- ),
- activityByThreadId: Object.fromEntries(
- threads.map((thread) => [
- thread.id,
- Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])),
- ]),
- ),
- proposedPlanIdsByThreadId: Object.fromEntries(
- threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]),
- ),
- proposedPlanByThreadId: Object.fromEntries(
- threads.map((thread) => [
- thread.id,
- Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])),
- ]),
- ),
- turnDiffIdsByThreadId: Object.fromEntries(
- threads.map((thread) => [
- thread.id,
- thread.turnDiffSummaries.map((summary) => summary.turnId),
- ]),
- ),
- turnDiffSummaryByThreadId: Object.fromEntries(
- threads.map((thread) => [
- thread.id,
- Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])),
- ]),
- ),
- sidebarThreadSummaryById: {},
- bootstrapComplete: true,
- };
- useStore.setState({
- activeEnvironmentId: localEnvironmentId,
- environmentStateById: {
- [localEnvironmentId]: environmentState,
- },
- });
-}
-
-afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
- setStoreThreads([]);
-});
-
-describe("waitForStartedServerThread", () => {
- it("resolves immediately when the thread is already started", async () => {
- const threadId = ThreadId.make("thread-started");
- setStoreThreads([
- makeThread({
- id: threadId,
- latestTurn: {
- turnId: TurnId.make("turn-started"),
- state: "running",
- requestedAt: "2026-03-29T00:00:01.000Z",
- startedAt: "2026-03-29T00:00:01.000Z",
- completedAt: null,
- },
- }),
- ]);
-
- await expect(
- waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)),
- ).resolves.toBe(true);
- });
-
- it("waits for the thread to start via subscription updates", async () => {
- const threadId = ThreadId.make("thread-wait");
- setStoreThreads([makeThread({ id: threadId })]);
-
- const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);
-
- setStoreThreads([
- makeThread({
- id: threadId,
- latestTurn: {
- turnId: TurnId.make("turn-started"),
- state: "running",
- requestedAt: "2026-03-29T00:00:01.000Z",
- startedAt: "2026-03-29T00:00:01.000Z",
- completedAt: null,
- },
- }),
- ]);
-
- await expect(promise).resolves.toBe(true);
- });
-
- it("handles the thread starting between the initial read and subscription setup", async () => {
- const threadId = ThreadId.make("thread-race");
- setStoreThreads([makeThread({ id: threadId })]);
-
- const originalSubscribe = useStore.subscribe.bind(useStore);
- let raced = false;
- vi.spyOn(useStore, "subscribe").mockImplementation((listener) => {
- if (!raced) {
- raced = true;
- setStoreThreads([
- makeThread({
- id: threadId,
- latestTurn: {
- turnId: TurnId.make("turn-race"),
- state: "running",
- requestedAt: "2026-03-29T00:00:01.000Z",
- startedAt: "2026-03-29T00:00:01.000Z",
- completedAt: null,
- },
- }),
- ]);
- }
- return originalSubscribe(listener);
- });
-
- await expect(
- waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500),
- ).resolves.toBe(true);
- });
-
- it("returns false after the timeout when the thread never starts", async () => {
- vi.useFakeTimers();
-
- const threadId = ThreadId.make("thread-timeout");
- setStoreThreads([makeThread({ id: threadId })]);
- const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);
-
- await vi.advanceTimersByTimeAsync(500);
-
- await expect(promise).resolves.toBe(false);
- });
-});
-
describe("hasServerAcknowledgedLocalDispatch", () => {
- const projectId = ProjectId.make("project-1");
- const previousLatestTurn = {
- turnId: TurnId.make("turn-1"),
- state: "completed" as const,
- requestedAt: "2026-03-29T00:00:00.000Z",
- startedAt: "2026-03-29T00:00:01.000Z",
- completedAt: "2026-03-29T00:00:10.000Z",
- assistantMessageId: null,
- };
-
- const previousSession = {
- provider: ProviderDriverKind.make("codex"),
- status: "ready" as const,
- createdAt: "2026-03-29T00:00:00.000Z",
- updatedAt: "2026-03-29T00:00:10.000Z",
- orchestrationStatus: "idle" as const,
- };
-
- it("does not clear local dispatch before server state changes", () => {
- const localDispatch = createLocalDispatchSnapshot({
- id: ThreadId.make("thread-1"),
- environmentId: localEnvironmentId,
- codexThreadId: null,
- projectId,
- title: "Thread",
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- session: previousSession,
- messages: [],
- proposedPlans: [],
- error: null,
- createdAt: "2026-03-29T00:00:00.000Z",
- archivedAt: null,
- updatedAt: "2026-03-29T00:00:10.000Z",
- latestTurn: previousLatestTurn,
- branch: null,
- worktreePath: null,
- turnDiffSummaries: [],
- activities: [],
- });
+ it("does not acknowledge unchanged server state", () => {
+ const localDispatch = createLocalDispatchSnapshot(
+ makeThread({ latestTurn: completedTurn, session: readySession }),
+ );
expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "ready",
- latestTurn: previousLatestTurn,
- session: previousSession,
+ latestTurn: completedTurn,
+ session: readySession,
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
@@ -503,45 +204,24 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(false);
});
- it("clears local dispatch when a new turn is already settled", () => {
- const localDispatch = createLocalDispatchSnapshot({
- id: ThreadId.make("thread-1"),
- environmentId: localEnvironmentId,
- codexThreadId: null,
- projectId,
- title: "Thread",
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- session: previousSession,
- messages: [],
- proposedPlans: [],
- error: null,
- createdAt: "2026-03-29T00:00:00.000Z",
- archivedAt: null,
- updatedAt: "2026-03-29T00:00:10.000Z",
- latestTurn: previousLatestTurn,
- branch: null,
- worktreePath: null,
- turnDiffSummaries: [],
- activities: [],
- });
+ it("acknowledges a settled newer turn", () => {
+ const localDispatch = createLocalDispatchSnapshot(
+ makeThread({ latestTurn: completedTurn, session: readySession }),
+ );
+ const newerTurn = {
+ ...completedTurn,
+ turnId: TurnId.make("turn-2"),
+ requestedAt: "2026-03-29T00:01:00.000Z",
+ startedAt: "2026-03-29T00:01:01.000Z",
+ completedAt: "2026-03-29T00:01:30.000Z",
+ };
expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "ready",
- latestTurn: {
- ...previousLatestTurn,
- turnId: TurnId.make("turn-2"),
- requestedAt: "2026-03-29T00:01:00.000Z",
- startedAt: "2026-03-29T00:01:01.000Z",
- completedAt: "2026-03-29T00:01:30.000Z",
- },
- session: {
- ...previousSession,
- updatedAt: "2026-03-29T00:01:30.000Z",
- },
+ latestTurn: newerTurn,
+ session: { ...readySession, updatedAt: newerTurn.completedAt },
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
@@ -549,134 +229,43 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(true);
});
- it("does not clear local dispatch while the session is running a newer turn than latestTurn", () => {
- const localDispatch = createLocalDispatchSnapshot({
- id: ThreadId.make("thread-1"),
- environmentId: localEnvironmentId,
- codexThreadId: null,
- projectId,
- title: "Thread",
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- session: previousSession,
- messages: [],
- proposedPlans: [],
- error: null,
- createdAt: "2026-03-29T00:00:00.000Z",
- archivedAt: null,
- updatedAt: "2026-03-29T00:00:10.000Z",
- latestTurn: previousLatestTurn,
- branch: null,
- worktreePath: null,
- turnDiffSummaries: [],
- activities: [],
- });
-
- expect(
- hasServerAcknowledgedLocalDispatch({
- localDispatch,
- phase: "running",
- latestTurn: previousLatestTurn,
- session: {
- ...previousSession,
- status: "running",
- orchestrationStatus: "running",
- activeTurnId: TurnId.make("turn-2"),
- updatedAt: "2026-03-29T00:01:00.000Z",
- },
- hasPendingApproval: false,
- hasPendingUserInput: false,
- threadError: null,
- }),
- ).toBe(false);
- });
-
- it("does not clear local dispatch while the session is running but latestTurn has not advanced yet", () => {
- const localDispatch = createLocalDispatchSnapshot({
- id: ThreadId.make("thread-1"),
- environmentId: localEnvironmentId,
- codexThreadId: null,
- projectId,
- title: "Thread",
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- session: previousSession,
- messages: [],
- proposedPlans: [],
- error: null,
- createdAt: "2026-03-29T00:00:00.000Z",
- archivedAt: null,
- updatedAt: "2026-03-29T00:00:10.000Z",
- latestTurn: previousLatestTurn,
- branch: null,
- worktreePath: null,
- turnDiffSummaries: [],
- activities: [],
- });
+ it("waits for the matching running turn before acknowledging", () => {
+ const localDispatch = createLocalDispatchSnapshot(
+ makeThread({ latestTurn: completedTurn, session: readySession }),
+ );
+ const runningTurn = {
+ ...completedTurn,
+ turnId: TurnId.make("turn-2"),
+ state: "running" as const,
+ requestedAt: "2026-03-29T00:01:00.000Z",
+ startedAt: "2026-03-29T00:01:01.000Z",
+ completedAt: null,
+ };
expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "running",
- latestTurn: previousLatestTurn,
+ latestTurn: runningTurn,
session: {
- ...previousSession,
+ ...readySession,
status: "running",
- orchestrationStatus: "running",
- activeTurnId: undefined,
- updatedAt: "2026-03-29T00:01:00.000Z",
+ activeTurnId: TurnId.make("turn-other"),
},
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
}),
).toBe(false);
- });
-
- it("clears local dispatch once the running latestTurn matches the active session turn", () => {
- const localDispatch = createLocalDispatchSnapshot({
- id: ThreadId.make("thread-1"),
- environmentId: localEnvironmentId,
- codexThreadId: null,
- projectId,
- title: "Thread",
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- session: previousSession,
- messages: [],
- proposedPlans: [],
- error: null,
- createdAt: "2026-03-29T00:00:00.000Z",
- archivedAt: null,
- updatedAt: "2026-03-29T00:00:10.000Z",
- latestTurn: previousLatestTurn,
- branch: null,
- worktreePath: null,
- turnDiffSummaries: [],
- activities: [],
- });
-
expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "running",
- latestTurn: {
- ...previousLatestTurn,
- turnId: TurnId.make("turn-2"),
- state: "running",
- requestedAt: "2026-03-29T00:01:00.000Z",
- startedAt: "2026-03-29T00:01:01.000Z",
- completedAt: null,
- },
+ latestTurn: runningTurn,
session: {
- ...previousSession,
+ ...readySession,
status: "running",
- orchestrationStatus: "running",
- activeTurnId: TurnId.make("turn-2"),
- updatedAt: "2026-03-29T00:01:01.000Z",
+ activeTurnId: runningTurn.turnId,
},
hasPendingApproval: false,
hasPendingUserInput: false,
@@ -685,43 +274,20 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(true);
});
- it("clears local dispatch when the session changes without an observed running phase", () => {
- const localDispatch = createLocalDispatchSnapshot({
- id: ThreadId.make("thread-1"),
- environmentId: localEnvironmentId,
- codexThreadId: null,
- projectId,
- title: "Thread",
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- session: previousSession,
- messages: [],
- proposedPlans: [],
- error: null,
- createdAt: "2026-03-29T00:00:00.000Z",
- archivedAt: null,
- updatedAt: "2026-03-29T00:00:10.000Z",
- latestTurn: previousLatestTurn,
- branch: null,
- worktreePath: null,
- turnDiffSummaries: [],
- activities: [],
- });
-
- expect(
- hasServerAcknowledgedLocalDispatch({
- localDispatch,
- phase: "ready",
- latestTurn: previousLatestTurn,
- session: {
- ...previousSession,
- updatedAt: "2026-03-29T00:00:11.000Z",
- },
- hasPendingApproval: false,
- hasPendingUserInput: false,
- threadError: null,
- }),
- ).toBe(true);
+ it("acknowledges pending user interaction and errors immediately", () => {
+ const localDispatch = createLocalDispatchSnapshot(makeThread());
+ const common = {
+ localDispatch,
+ phase: "ready" as const,
+ latestTurn: null,
+ session: null,
+ hasPendingApproval: false,
+ hasPendingUserInput: false,
+ threadError: null,
+ };
+
+ expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true);
+ expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true);
+ expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true);
});
});
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index bf87add28d9..b1d0317df74 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -8,10 +8,11 @@ import {
type ThreadId,
type TurnId,
} from "@t3tools/contracts";
-import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
+import { type ChatMessage, type SessionPhase, type Thread } from "../types";
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
import * as Schema from "effect/Schema";
-import { selectThreadByRef, useStore } from "../store";
+import { appAtomRegistry } from "../rpc/atomRegistry";
+import { environmentThreadDetails } from "../state/threads";
import {
filterTerminalContextsWithText,
stripInlineTerminalContextPlaceholders,
@@ -28,12 +29,10 @@ export function buildLocalDraftThread(
threadId: ThreadId,
draftThread: DraftThreadState,
fallbackModelSelection: ModelSelection,
- error: string | null,
): Thread {
return {
id: threadId,
environmentId: draftThread.environmentId,
- codexThreadId: null,
projectId: draftThread.projectId,
title: "New thread",
modelSelection: fallbackModelSelection,
@@ -41,13 +40,14 @@ export function buildLocalDraftThread(
interactionMode: draftThread.interactionMode,
session: null,
messages: [],
- error,
createdAt: draftThread.createdAt,
+ updatedAt: draftThread.createdAt,
archivedAt: null,
+ deletedAt: null,
latestTurn: null,
branch: draftThread.branch,
worktreePath: draftThread.worktreePath,
- turnDiffSummaries: [],
+ checkpoints: [],
activities: [],
proposedPlans: [],
};
@@ -247,8 +247,8 @@ export function deriveLockedProvider(input: {
if (!threadHasStarted(input.thread)) {
return null;
}
- const sessionProvider = input.thread?.session?.provider ?? null;
- if (sessionProvider) {
+ const sessionProvider = input.thread?.session?.providerName ?? null;
+ if (sessionProvider && isProviderDriverKind(sessionProvider)) {
return sessionProvider;
}
const narrowedThreadProvider =
@@ -266,7 +266,8 @@ export async function waitForStartedServerThread(
threadRef: ScopedThreadRef,
timeoutMs = 1_000,
): Promise {
- const getThread = () => selectThreadByRef(useStore.getState(), threadRef);
+ const threadAtom = environmentThreadDetails.detailAtom(threadRef);
+ const getThread = () => appAtomRegistry.get(threadAtom);
const thread = getThread();
if (threadHasStarted(thread)) {
@@ -288,8 +289,8 @@ export async function waitForStartedServerThread(
resolve(result);
};
- const unsubscribe = useStore.subscribe((state) => {
- if (!threadHasStarted(selectThreadByRef(state, threadRef))) {
+ const unsubscribe = appAtomRegistry.subscribe(threadAtom, (thread) => {
+ if (!threadHasStarted(thread)) {
return;
}
finish(true);
@@ -313,7 +314,7 @@ export interface LocalDispatchSnapshot {
latestTurnRequestedAt: string | null;
latestTurnStartedAt: string | null;
latestTurnCompletedAt: string | null;
- sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null;
+ sessionStatus: NonNullable["status"] | null;
sessionUpdatedAt: string | null;
}
@@ -330,7 +331,7 @@ export function createLocalDispatchSnapshot(
latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
latestTurnStartedAt: latestTurn?.startedAt ?? null,
latestTurnCompletedAt: latestTurn?.completedAt ?? null,
- sessionOrchestrationStatus: session?.orchestrationStatus ?? null,
+ sessionStatus: session?.status ?? null,
sessionUpdatedAt: session?.updatedAt ?? null,
};
}
@@ -367,8 +368,8 @@ export function hasServerAcknowledgedLocalDispatch(input: {
return false;
}
if (
+ session?.activeTurnId !== null &&
session?.activeTurnId !== undefined &&
- session.activeTurnId !== null &&
latestTurn?.turnId !== session.activeTurnId
) {
return false;
@@ -378,7 +379,7 @@ export function hasServerAcknowledgedLocalDispatch(input: {
return (
latestTurnChanged ||
- input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) ||
+ input.localDispatch.sessionStatus !== (session?.status ?? null) ||
input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
);
}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 7fc7669fe60..e535b5d5e12 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -21,12 +21,16 @@ import {
RuntimeMode,
TerminalOpenInput,
} from "@t3tools/contracts";
+import {
+ connectionStatusText,
+ type EnvironmentConnectionPresentation,
+} from "@t3tools/client-runtime/connection";
import {
parseScopedThreadKey,
scopedThreadKey,
scopeProjectRef,
scopeThreadRef,
-} from "@t3tools/client-runtime";
+} from "@t3tools/client-runtime/environment";
import {
applyClaudePromptEffortPrefix,
createModelSelection,
@@ -36,12 +40,10 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje
import { truncate } from "@t3tools/shared/String";
import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
import { Debouncer } from "@tanstack/react-pacer";
+import { useAtomSet } from "@effect/atom-react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
-import { useVcsStatus } from "~/lib/vcsStatusState";
-import { usePrimaryEnvironmentId } from "../environments/primary";
-import { readEnvironmentApi } from "../environmentApi";
import { isElectron } from "../env";
import { readLocalApi } from "../localApi";
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
@@ -73,12 +75,6 @@ import {
togglePendingUserInputOptionSelection,
type PendingUserInputDraftAnswer,
} from "../pendingUserInput";
-import {
- selectProjectsAcrossEnvironments,
- selectThreadsAcrossEnvironments,
- useStore,
-} from "../store";
-import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
import { useUiStateStore } from "../uiStateStore";
import {
buildPlanImplementationThreadTitle,
@@ -115,7 +111,7 @@ import {
nextProjectScriptId,
projectScriptIdFromCommand,
} from "~/projectScripts";
-import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
+import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
import { useSettings } from "../hooks/useSettings";
import { resolveAppModelSelectionForInstance } from "../modelSelection";
@@ -124,11 +120,6 @@ import {
deriveLogicalProjectKeyFromSettings,
selectProjectGroupingSettings,
} from "../logicalProject";
-import {
- reconnectSavedEnvironment,
- useSavedEnvironmentRegistryStore,
- useSavedEnvironmentRuntimeStore,
-} from "../environments/runtime";
import { buildDraftThreadRouteParams } from "../threadRoutes";
import {
type ComposerImageAttachment,
@@ -143,7 +134,26 @@ import {
type TerminalContextSelection,
} from "../lib/terminalContext";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
-import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState";
+import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
+import { projectEnvironment } from "../state/projects";
+import { useEnvironmentQuery } from "../state/query";
+import { serverEnvironment } from "../state/server";
+import { terminalEnvironment } from "../state/terminal";
+import { threadEnvironment } from "../state/threads";
+import { vcsEnvironment } from "../state/vcs";
+import {
+ useEnvironmentActions,
+ useEnvironmentHttpBaseUrl,
+ useEnvironments,
+ usePrimaryEnvironment,
+} from "../state/environments";
+import {
+ useProject,
+ useProjects,
+ useThreadDetail,
+ useThreadProposedPlans,
+ useThreadRefs,
+} from "../state/entities";
import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
@@ -151,7 +161,7 @@ import { MessagesTimeline } from "./chat/MessagesTimeline";
import { ChatHeader } from "./chat/ChatHeader";
import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { NoActiveThreadState } from "./NoActiveThreadState";
-import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
+import { resolveEffectiveEnvMode } from "./BranchToolbar.logic";
import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
@@ -174,18 +184,12 @@ import {
resolveSendEnvMode,
revokeBlobPreviewUrl,
revokeUserMessagePreviewUrls,
- shouldWriteThreadErrorToCurrentServerThread,
waitForStartedServerThread,
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useComposerHandleContext } from "../composerHandleContext";
-import {
- useServerAvailableEditors,
- useServerConfig,
- useServerKeybindings,
-} from "~/rpc/serverState";
+import { useServerAvailableEditors, useServerKeybindings } from "~/rpc/serverState";
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
-import { retainThreadDetailSubscription } from "../environments/runtime/service";
import { RightPanelSheet } from "./RightPanelSheet";
import { Button } from "./ui/button";
import {
@@ -198,131 +202,17 @@ import {
const IMAGE_ONLY_BOOTSTRAP_PROMPT =
"[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
-const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
const EMPTY_PROVIDERS: ServerProvider[] = [];
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {};
type EnvironmentUnavailableState = {
readonly environmentId: EnvironmentId;
readonly label: string;
- readonly connectionState: "connecting" | "disconnected" | "error";
+ readonly connection: EnvironmentConnectionPresentation;
};
type ThreadPlanCatalogEntry = Pick;
-function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {
- return useStore(
- useMemo(() => {
- let previousThreadIds: readonly ThreadId[] = [];
- let previousResult: ThreadPlanCatalogEntry[] = [];
- let previousEntries = new Map<
- ThreadId,
- {
- shell: object | null;
- proposedPlanIds: readonly string[] | undefined;
- proposedPlansById: Record | undefined;
- entry: ThreadPlanCatalogEntry;
- }
- >();
-
- return (state) => {
- const sameThreadIds =
- previousThreadIds.length === threadIds.length &&
- previousThreadIds.every((id, index) => id === threadIds[index]);
- const nextEntries = new Map<
- ThreadId,
- {
- shell: object | null;
- proposedPlanIds: readonly string[] | undefined;
- proposedPlansById: Record | undefined;
- entry: ThreadPlanCatalogEntry;
- }
- >();
- const nextResult: ThreadPlanCatalogEntry[] = [];
- let changed = !sameThreadIds;
-
- for (const threadId of threadIds) {
- let shell: object | undefined;
- let proposedPlanIds: readonly string[] | undefined;
- let proposedPlansById: Record | undefined;
-
- for (const environmentState of Object.values(state.environmentStateById)) {
- const matchedShell = environmentState.threadShellById[threadId];
- if (!matchedShell) {
- continue;
- }
- shell = matchedShell;
- proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId];
- proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as
- | Record
- | undefined;
- break;
- }
-
- if (!shell) {
- const previous = previousEntries.get(threadId);
- if (
- previous &&
- previous.shell === null &&
- previous.proposedPlanIds === undefined &&
- previous.proposedPlansById === undefined
- ) {
- nextEntries.set(threadId, previous);
- continue;
- }
- changed = true;
- nextEntries.set(threadId, {
- shell: null,
- proposedPlanIds: undefined,
- proposedPlansById: undefined,
- entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS },
- });
- continue;
- }
-
- const previous = previousEntries.get(threadId);
- if (
- previous &&
- previous.shell === shell &&
- previous.proposedPlanIds === proposedPlanIds &&
- previous.proposedPlansById === proposedPlansById
- ) {
- nextEntries.set(threadId, previous);
- nextResult.push(previous.entry);
- continue;
- }
-
- changed = true;
- const proposedPlans =
- proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById
- ? proposedPlanIds.flatMap((planId) => {
- const proposedPlan = proposedPlansById?.[planId];
- return proposedPlan ? [proposedPlan] : [];
- })
- : EMPTY_PROPOSED_PLANS;
- const entry = { id: threadId, proposedPlans };
- nextEntries.set(threadId, {
- shell,
- proposedPlanIds,
- proposedPlansById,
- entry,
- });
- nextResult.push(entry);
- }
-
- if (!changed && previousResult.length === nextResult.length) {
- return previousResult;
- }
-
- previousThreadIds = threadIds;
- previousEntries = nextEntries;
- previousResult = nextResult;
- return nextResult;
- };
- }, [threadIds]),
- );
-}
-
function formatOutgoingPrompt(params: {
provider: ProviderDriverKind;
model: string | null;
@@ -494,14 +384,18 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
keybindings,
onAddTerminalContext,
}: PersistentThreadTerminalDrawerProps) {
- const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
+ const openTerminal = useAtomSet(terminalEnvironment.open, { mode: "promise" });
+ const writeTerminal = useAtomSet(terminalEnvironment.write, { mode: "promise" });
+ const clearTerminal = useAtomSet(terminalEnvironment.clear, { mode: "promise" });
+ const closeTerminalMutation = useAtomSet(terminalEnvironment.close, { mode: "promise" });
+ const serverThread = useThreadDetail(threadRef);
const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
const projectRef = serverThread
? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
: draftThread
? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
: null;
- const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef]));
+ const project = useProject(projectRef);
const terminalUiState = useTerminalUiStateStore((state) =>
selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),
);
@@ -543,7 +437,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
cwd: launchContext?.cwd ?? summary.cwd,
worktreePath: worktreePathForLaunch,
runtimeEnv: projectScriptRuntimeEnv({
- project: { cwd: project.cwd },
+ project: { cwd: project.workspaceRoot },
worktreePath: worktreePathForLaunch,
}),
});
@@ -586,7 +480,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
launchContext?.cwd ??
(project
? projectScriptCwd({
- project: { cwd: project.cwd },
+ project: { cwd: project.workspaceRoot },
worktreePath: effectiveWorktreePath,
})
: null),
@@ -596,7 +490,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
() =>
project
? projectScriptRuntimeEnv({
- project: { cwd: project.cwd },
+ project: { cwd: project.workspaceRoot },
worktreePath: effectiveWorktreePath,
})
: {},
@@ -618,8 +512,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
);
const splitTerminal = useCallback(() => {
- const api = readEnvironmentApi(threadRef.environmentId);
- if (!api || !cwd) {
+ if (!cwd) {
return;
}
const terminalId = nextTerminalId(serverOrderedTerminalIds);
@@ -627,12 +520,15 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
bumpFocusRequestId();
void (async () => {
try {
- await api.terminal.open({
- threadId,
- terminalId,
- cwd,
- ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
- env: runtimeEnv,
+ await openTerminal({
+ environmentId: threadRef.environmentId,
+ input: {
+ threadId,
+ terminalId,
+ cwd,
+ ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
+ env: runtimeEnv,
+ },
});
} catch {
// Opening failed; the tab is already in the store — user can retry or close it.
@@ -647,11 +543,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
storeSplitTerminal,
threadId,
threadRef,
+ openTerminal,
]);
const createNewTerminal = useCallback(() => {
- const api = readEnvironmentApi(threadRef.environmentId);
- if (!api || !cwd) {
+ if (!cwd) {
return;
}
const terminalId = nextTerminalId(serverOrderedTerminalIds);
@@ -659,12 +555,15 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
bumpFocusRequestId();
void (async () => {
try {
- await api.terminal.open({
- threadId,
- terminalId,
- cwd,
- ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
- env: runtimeEnv,
+ await openTerminal({
+ environmentId: threadRef.environmentId,
+ input: {
+ threadId,
+ terminalId,
+ cwd,
+ ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
+ env: runtimeEnv,
+ },
});
} catch {
// Opening failed; the tab is already in the store — user can retry or close it.
@@ -679,6 +578,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
storeNewTerminal,
threadId,
threadRef,
+ openTerminal,
]);
const activateTerminal = useCallback(
@@ -691,31 +591,43 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
const closeTerminal = useCallback(
(terminalId: string) => {
- const api = readEnvironmentApi(threadRef.environmentId);
- if (!api) return;
const isFinalTerminal = terminalUiState.terminalIds.length <= 1;
const fallbackExitWrite = () =>
- api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined);
-
- if ("close" in api.terminal && typeof api.terminal.close === "function") {
- void (async () => {
- if (isFinalTerminal) {
- await api.terminal.clear({ threadId, terminalId }).catch(() => undefined);
- }
- await api.terminal.close({
+ writeTerminal({
+ environmentId: threadRef.environmentId,
+ input: { threadId, terminalId, data: "exit\n" },
+ }).catch(() => undefined);
+
+ void (async () => {
+ if (isFinalTerminal) {
+ await clearTerminal({
+ environmentId: threadRef.environmentId,
+ input: { threadId, terminalId },
+ }).catch(() => undefined);
+ }
+ await closeTerminalMutation({
+ environmentId: threadRef.environmentId,
+ input: {
threadId,
terminalId,
deleteHistory: true,
- });
- })().catch(() => fallbackExitWrite());
- } else {
- void fallbackExitWrite();
- }
+ },
+ });
+ })().catch(() => fallbackExitWrite());
storeCloseTerminal(threadRef, terminalId);
bumpFocusRequestId();
},
- [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef],
+ [
+ bumpFocusRequestId,
+ storeCloseTerminal,
+ terminalUiState.terminalIds,
+ threadId,
+ threadRef,
+ clearTerminal,
+ closeTerminalMutation,
+ writeTerminal,
+ ],
);
const handleAddTerminalContext = useCallback(
@@ -779,15 +691,47 @@ export default function ChatView(props: ChatViewProps) {
[environmentId, threadId],
);
const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
+ const updateProject = useAtomSet(projectEnvironment.update, { mode: "promise" });
+ const upsertKeybinding = useAtomSet(serverEnvironment.upsertKeybinding, { mode: "promise" });
+ const openTerminal = useAtomSet(terminalEnvironment.open, { mode: "promise" });
+ const writeTerminal = useAtomSet(terminalEnvironment.write, { mode: "promise" });
+ const clearTerminal = useAtomSet(terminalEnvironment.clear, { mode: "promise" });
+ const closeTerminalMutation = useAtomSet(terminalEnvironment.close, { mode: "promise" });
+ const createThread = useAtomSet(threadEnvironment.create, { mode: "promise" });
+ const deleteThread = useAtomSet(threadEnvironment.delete, { mode: "promise" });
+ const updateThreadMetadata = useAtomSet(threadEnvironment.updateMetadata, {
+ mode: "promise",
+ });
+ const setThreadRuntimeMode = useAtomSet(threadEnvironment.setRuntimeMode, {
+ mode: "promise",
+ });
+ const setThreadInteractionMode = useAtomSet(threadEnvironment.setInteractionMode, {
+ mode: "promise",
+ });
+ const startThreadTurn = useAtomSet(threadEnvironment.startTurn, { mode: "promise" });
+ const interruptThreadTurn = useAtomSet(threadEnvironment.interruptTurn, {
+ mode: "promise",
+ });
+ const respondToThreadApproval = useAtomSet(threadEnvironment.respondToApproval, {
+ mode: "promise",
+ });
+ const respondToThreadUserInput = useAtomSet(threadEnvironment.respondToUserInput, {
+ mode: "promise",
+ });
+ const revertThreadCheckpoint = useAtomSet(threadEnvironment.revertCheckpoint, {
+ mode: "promise",
+ });
+ const { environments } = useEnvironments();
+ const primaryEnvironment = usePrimaryEnvironment();
+ const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
+ const { retryEnvironment } = useEnvironmentActions();
+ const environmentById = useMemo(
+ () => new Map(environments.map((environment) => [environment.environmentId, environment])),
+ [environments],
+ );
const composerDraftTarget: ScopedThreadRef | DraftId =
routeKind === "server" ? routeThreadRef : props.draftId;
- const serverThread = useStore(
- useMemo(
- () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null),
- [routeKind, routeThreadRef],
- ),
- );
- const setStoreThreadError = useStore((store) => store.setError);
+ const serverThread = useThreadDetail(routeKind === "server" ? routeThreadRef : null);
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore((store) =>
routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,
@@ -853,6 +797,9 @@ export default function ChatView(props: ChatViewProps) {
const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
Record
>({});
+ const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState<
+ Record
+ >({});
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [respondingRequestIds, setRespondingRequestIds] = useState([]);
@@ -910,13 +857,8 @@ export default function ChatView(props: ChatViewProps) {
const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal);
const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal);
const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal);
- const serverThreadKeys = useStore(
- useShallow((state) =>
- selectThreadsAcrossEnvironments(state).map((thread) =>
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
- ),
- ),
- );
+ const serverThreadRefs = useThreadRefs();
+ const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]);
const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
const draftThreadKeys = useMemo(
() =>
@@ -938,13 +880,12 @@ export default function ChatView(props: ChatViewProps) {
const fallbackDraftProjectRef = draftThread
? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
: null;
- const fallbackDraftProject = useStore(
- useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]),
- );
+ const fallbackDraftProject = useProject(fallbackDraftProjectRef);
const localDraftError =
routeKind === "server" && serverThread
? null
: ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null);
+ const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? null;
const localDraftThread = useMemo(
() =>
draftThread
@@ -955,13 +896,15 @@ export default function ChatView(props: ChatViewProps) {
instanceId: ProviderInstanceId.make("codex"),
model: DEFAULT_MODEL,
},
- localDraftError,
)
: undefined,
- [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId],
+ [draftThread, fallbackDraftProject?.defaultModelSelection, threadId],
);
- const isServerThread = routeKind === "server" && serverThread !== undefined;
+ const isServerThread = routeKind === "server" && serverThread !== null;
const activeThread = isServerThread ? serverThread : localDraftThread;
+ const threadError = isServerThread
+ ? (localServerError ?? serverThread?.session?.lastError ?? null)
+ : localDraftError;
const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode =
composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
@@ -1028,19 +971,29 @@ export default function ChatView(props: ChatViewProps) {
return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
}, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
const activeLatestTurn = activeThread?.latestTurn ?? null;
- const threadPlanCatalog = useThreadPlanCatalog(
- useMemo(() => {
- const threadIds: ThreadId[] = [];
- if (activeThread?.id) {
- threadIds.push(activeThread.id);
- }
- const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId;
- if (sourceThreadId && sourceThreadId !== activeThread?.id) {
- threadIds.push(sourceThreadId);
- }
- return threadIds;
- }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]),
- );
+ const sourcePlanThreadRef = useMemo(() => {
+ const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId;
+ if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) {
+ return null;
+ }
+ return scopeThreadRef(activeThread.environmentId, sourceThreadId);
+ }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]);
+ const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef);
+ const threadPlanCatalog = useMemo(() => {
+ if (!activeThread) {
+ return [];
+ }
+ const entries: ThreadPlanCatalogEntry[] = [
+ { id: activeThread.id, proposedPlans: activeThread.proposedPlans },
+ ];
+ if (sourcePlanThreadRef) {
+ entries.push({
+ id: sourcePlanThreadRef.threadId,
+ proposedPlans: sourceThreadProposedPlans,
+ });
+ }
+ return entries;
+ }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]);
useEffect(() => {
setMountedTerminalThreadKeys((currentThreadIds) => {
const nextThreadIds = reconcileMountedTerminalThreadIds({
@@ -1060,81 +1013,33 @@ export default function ChatView(props: ChatViewProps) {
const activeProjectRef = activeThread
? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
: null;
- const activeProject = useStore(
- useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),
- );
-
- useEffect(() => {
- if (routeKind !== "server") {
- return;
- }
- return retainThreadDetailSubscription(environmentId, threadId);
- }, [environmentId, routeKind, threadId]);
+ const activeProject = useProject(activeProjectRef);
// Compute the list of environments this logical project spans, used to
// drive the environment picker in BranchToolbar.
- const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments));
- const primaryEnvironmentId = usePrimaryEnvironmentId();
- const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
- const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);
- const activeSavedEnvironmentRecord =
- activeThread && activeThread.environmentId !== primaryEnvironmentId
- ? (savedEnvironmentRegistry[activeThread.environmentId] ?? null)
- : null;
- const activeSavedEnvironmentRuntime = activeSavedEnvironmentRecord
- ? (savedEnvironmentRuntimeById[activeSavedEnvironmentRecord.environmentId] ?? null)
- : null;
- const activeSavedEnvironmentConnectionState = activeSavedEnvironmentRecord
- ? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected")
- : "connected";
+ const allProjects = useProjects();
+ const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
+ const activeEnvironment =
+ activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null);
+ const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";
const activeEnvironmentUnavailable =
- activeSavedEnvironmentRecord !== null && activeSavedEnvironmentConnectionState !== "connected";
- const activeSavedEnvironmentId = activeSavedEnvironmentRecord?.environmentId ?? null;
- const activeEnvironmentUnavailableLabel = activeSavedEnvironmentRecord
- ? resolveEnvironmentOptionLabel({
- isPrimary: false,
- environmentId: activeSavedEnvironmentRecord.environmentId,
- runtimeLabel: activeSavedEnvironmentRuntime?.descriptor?.label ?? null,
- savedLabel: activeSavedEnvironmentRecord.label,
- })
- : null;
+ activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected";
+ const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null;
const activeEnvironmentUnavailableState = useMemo(() => {
- if (
- !activeEnvironmentUnavailable ||
- !activeEnvironmentUnavailableLabel ||
- !activeSavedEnvironmentId
- ) {
+ if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) {
return null;
}
return {
- environmentId: activeSavedEnvironmentId,
+ environmentId: activeEnvironment.environmentId,
label: activeEnvironmentUnavailableLabel,
- connectionState:
- activeSavedEnvironmentConnectionState === "connecting" ||
- activeSavedEnvironmentConnectionState === "error"
- ? activeSavedEnvironmentConnectionState
- : "disconnected",
+ connection: activeEnvironment.connection,
};
- }, [
- activeEnvironmentUnavailable,
- activeEnvironmentUnavailableLabel,
- activeSavedEnvironmentConnectionState,
- activeSavedEnvironmentId,
- ]);
- const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState(
- null,
- );
+ }, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]);
const handleReconnectActiveEnvironment = useCallback(
- async (environmentId: EnvironmentId, label: string) => {
- setReconnectingEnvironmentId(environmentId);
+ async (environmentId: EnvironmentId) => {
try {
- await reconnectSavedEnvironment(environmentId);
- toastManager.add({
- type: "success",
- title: "Environment reconnected",
- description: `${label} is ready.`,
- });
+ await retryEnvironment(environmentId);
} catch (error) {
toastManager.add(
stackedThreadToast({
@@ -1143,11 +1048,9 @@ export default function ChatView(props: ChatViewProps) {
description: error instanceof Error ? error.message : "Failed to reconnect.",
}),
);
- } finally {
- setReconnectingEnvironmentId(null);
}
},
- [],
+ [retryEnvironment],
);
const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
const logicalProjectEnvironments = useMemo(() => {
@@ -1167,14 +1070,7 @@ export default function ChatView(props: ChatViewProps) {
if (seen.has(p.environmentId)) continue;
seen.add(p.environmentId);
const isPrimary = p.environmentId === primaryEnvironmentId;
- const savedRecord = savedEnvironmentRegistry[p.environmentId];
- const runtimeState = savedEnvironmentRuntimeById[p.environmentId];
- const label = resolveEnvironmentOptionLabel({
- isPrimary,
- environmentId: p.environmentId,
- runtimeLabel: runtimeState?.descriptor?.label ?? null,
- savedLabel: savedRecord?.label ?? null,
- });
+ const label = environmentById.get(p.environmentId)?.label ?? p.environmentId;
envs.push({
environmentId: p.environmentId,
projectId: p.id,
@@ -1188,14 +1084,7 @@ export default function ChatView(props: ChatViewProps) {
return a.label.localeCompare(b.label);
});
return envs;
- }, [
- activeProject,
- allProjects,
- projectGroupingSettings,
- primaryEnvironmentId,
- savedEnvironmentRegistry,
- savedEnvironmentRuntimeById,
- ]);
+ }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]);
const hasMultipleEnvironments = logicalProjectEnvironments.length > 1;
const openPullRequestDialog = useCallback(
@@ -1335,17 +1224,7 @@ export default function ChatView(props: ChatViewProps) {
selectedProvider: selectedProviderByThreadId,
threadProvider,
});
- const primaryServerConfig = useServerConfig();
- const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) =>
- activeThread?.environmentId ? s.byId[activeThread.environmentId] : null,
- );
- // Use the server config for the thread's environment. For the primary
- // environment fall back to the global atom; for remote environments use
- // the runtime state stored by the environment manager.
- const serverConfig =
- primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId
- ? primaryServerConfig
- : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig);
+ const serverConfig = activeEnvironment?.serverConfig ?? primaryEnvironment?.serverConfig ?? null;
const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
const versionMismatchDismissKey =
versionMismatch && activeThread
@@ -1359,65 +1238,37 @@ export default function ChatView(props: ChatViewProps) {
isVersionMismatchDismissed(versionMismatchDismissKey);
const showVersionMismatchBanner =
versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed;
- const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0;
- const versionMismatchServerLabel = useMemo(() => {
- if (!hasMultipleRegisteredEnvironments || !activeThread) {
- return "server";
- }
-
- const isPrimary = activeThread.environmentId === primaryEnvironmentId;
- const savedRecord = savedEnvironmentRegistry[activeThread.environmentId];
- const runtimeState = savedEnvironmentRuntimeById[activeThread.environmentId];
- return `${resolveEnvironmentOptionLabel({
- isPrimary,
- environmentId: activeThread.environmentId,
- runtimeLabel: runtimeState?.descriptor?.label ?? serverConfig?.environment.label ?? null,
- savedLabel: savedRecord?.label ?? null,
- })} server`;
- }, [
- activeThread,
- hasMultipleRegisteredEnvironments,
- primaryEnvironmentId,
- savedEnvironmentRegistry,
- savedEnvironmentRuntimeById,
- serverConfig?.environment.label,
- ]);
+ const hasMultipleRegisteredEnvironments = environments.length > 1;
+ const versionMismatchServerLabel =
+ hasMultipleRegisteredEnvironments && activeThread
+ ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`
+ : "server";
const composerBannerItems = useMemo(() => {
const items: ComposerBannerStackItem[] = [];
if (activeEnvironmentUnavailableState) {
+ const connection = activeEnvironmentUnavailableState.connection;
+ const isReconnecting =
+ connection.phase === "connecting" || connection.phase === "reconnecting";
items.push({
id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
- variant:
- activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning",
+ variant: connection.phase === "error" ? "error" : "warning",
icon: ,
- title: (
- <>
- {activeEnvironmentUnavailableState.label} is{" "}
- {activeEnvironmentUnavailableState.connectionState === "connecting"
- ? "connecting"
- : "disconnected"}
- >
- ),
- description: "Reconnect this environment before sending messages or running actions.",
+ title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusText(connection)}`,
+ description:
+ connection.error ??
+ "Reconnect this environment before sending messages or running actions.",
actions: (
<>