From f9944ad20c342119953b991e3330a29bc4321d79 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 7 Jun 2026 13:41:44 -0700 Subject: [PATCH 1/8] Rewrite client connection architecture Co-authored-by: codex --- .../app/DesktopCloudAuthTokenStore.test.ts | 2 +- .../src/app/DesktopCloudAuthTokenStore.ts | 2 +- .../app/DesktopConnectionCatalogStore.test.ts | 96 + .../src/app/DesktopConnectionCatalogStore.ts | 149 ++ .../src/backend/tailscaleEndpointProvider.ts | 2 +- .../src/electron/ElectronSafeStorage.ts | 52 +- .../electron/ElectronSafeStorageService.ts | 48 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 8 + apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/cloudAuth.ts | 2 +- .../src/ipc/methods/connectionCatalog.ts | 37 + apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 4 + .../settings/DesktopSavedEnvironments.test.ts | 2 +- .../src/settings/DesktopSavedEnvironments.ts | 2 +- apps/mobile/package.json | 1 + apps/mobile/src/app/_layout.tsx | 11 +- apps/mobile/src/app/index.tsx | 1 + apps/mobile/src/app/settings/environments.tsx | 413 +++- apps/mobile/src/app/settings/index.tsx | 23 +- apps/mobile/src/app/settings/waitlist.tsx | 13 +- .../src/connection/mobileAppQueries.test.ts | 65 + .../mobile/src/connection/mobileAppQueries.ts | 180 ++ .../src/connection/mobileAppQueryTargets.ts | 51 + .../mobileConnectionMigration.test.ts | 78 + .../connection/mobileConnectionMigration.ts | 108 + .../connection/mobileConnectionPlatform.ts | 180 ++ .../src/connection/mobileConnectionRuntime.ts | 48 + .../src/connection/mobileConnectionStorage.ts | 504 ++++ .../connection/mobileWorkspaceModel.test.ts | 106 + .../src/connection/mobileWorkspaceModel.ts | 132 ++ .../src/connection/useMobileCatalogView.ts | 67 + .../useMobileConnectionController.ts | 87 + .../connection/useMobileEnvironmentData.ts | 33 + .../src/connection/useMobileEnvironments.ts | 86 + .../src/connection/useMobileWorkspace.ts | 41 + .../liveActivityPreferences.test.ts | 112 +- .../remoteRegistration.test.ts | 206 +- .../agent-awareness/remoteRegistration.ts | 159 +- .../src/features/cloud/CloudAuthProvider.tsx | 50 +- .../src/features/cloud/cloudDebugLog.ts | 18 + .../cloudEnvironmentPresentation.test.ts | 107 + .../cloud/cloudEnvironmentPresentation.ts | 64 + .../features/cloud/linkEnvironment.test.ts | 2 + .../src/features/cloud/linkEnvironment.ts | 61 +- .../src/features/cloud/managedRelayLayer.ts | 34 +- .../src/features/cloud/managedRelayState.ts | 31 +- .../cloud/managedRelayTokenStore.test.ts | 51 + .../features/cloud/managedRelayTokenStore.ts | 107 + .../features/cloud/useNativeClerkAuthModal.ts | 6 +- .../connection/ConnectionEnvironmentRow.tsx | 10 +- .../connection/ConnectionStatusDot.tsx | 11 +- .../connection/environmentSections.test.ts | 130 + .../connection/environmentSections.ts | 51 + apps/mobile/src/features/home/HomeScreen.tsx | 247 +- .../features/projects/AddProjectScreen.tsx | 63 +- .../features/review/reviewDiffPreviewState.ts | 111 +- .../src/features/review/useReviewSections.ts | 185 +- .../features/terminal/ThreadTerminalPanel.tsx | 120 +- .../terminal/ThreadTerminalRouteScreen.tsx | 461 ++-- .../src/features/threads/ThreadComposer.tsx | 68 + .../features/threads/ThreadDetailScreen.tsx | 5 + .../features/threads/ThreadRouteScreen.tsx | 22 +- .../threads/new-task-flow-provider.tsx | 51 +- .../features/threads/use-project-actions.ts | 208 +- apps/mobile/src/lib/connection.ts | 48 +- .../mobile/src/lib/copyTextWithHaptic.test.ts | 34 + apps/mobile/src/lib/copyTextWithHaptic.ts | 7 + apps/mobile/src/lib/runtime.ts | 16 +- .../src/state/environment-session-registry.ts | 48 - apps/mobile/src/state/remote-runtime-types.ts | 12 +- apps/mobile/src/state/use-checkpoint-diff.ts | 16 - .../src/state/use-composer-path-search.ts | 47 +- .../src/state/use-environment-runtime.ts | 76 - .../mobile/src/state/use-filesystem-browse.ts | 80 +- apps/mobile/src/state/use-remote-catalog.ts | 197 +- .../use-remote-environment-registry.test.ts | 430 ---- .../state/use-remote-environment-registry.ts | 840 +------ .../src/state/use-selected-thread-commands.ts | 135 +- .../state/use-selected-thread-git-actions.ts | 382 +-- .../src/state/use-selected-thread-requests.ts | 48 +- .../src/state/use-source-control-discovery.ts | 79 +- apps/mobile/src/state/use-terminal-session.ts | 130 +- .../src/state/use-thread-composer-state.ts | 34 +- apps/mobile/src/state/use-thread-detail.ts | 79 +- apps/mobile/src/state/use-vcs-action-state.ts | 81 +- apps/mobile/src/state/use-vcs-refs.ts | 62 +- apps/mobile/src/state/use-vcs-status.ts | 73 +- apps/web/src/cloud/dpop.test.ts | 43 +- apps/web/src/cloud/linkEnvironment.test.ts | 925 ++------ apps/web/src/cloud/linkEnvironment.ts | 362 +-- apps/web/src/cloud/managedRelayLayer.ts | 22 +- apps/web/src/cloud/managedRelayState.ts | 24 +- apps/web/src/cloud/primaryCloudLinkState.ts | 54 +- .../BranchToolbarBranchSelector.tsx | 91 +- apps/web/src/components/ChatMarkdown.tsx | 28 +- apps/web/src/components/ChatView.browser.tsx | 173 -- apps/web/src/components/ChatView.tsx | 676 +++--- apps/web/src/components/CommandPalette.tsx | 292 +-- apps/web/src/components/DiffPanel.tsx | 15 +- apps/web/src/components/GitActionsControl.tsx | 57 +- .../components/KeybindingsToast.browser.tsx | 11 - apps/web/src/components/PlanSidebar.tsx | 19 +- apps/web/src/components/ProjectFavicon.tsx | 31 +- .../ProviderUpdateLaunchNotification.tsx | 18 +- .../components/PullRequestThreadDialog.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 137 +- .../src/components/ThreadStatusIndicators.tsx | 24 +- .../ThreadTerminalDrawer.browser.tsx | 160 +- .../src/components/ThreadTerminalDrawer.tsx | 256 +- .../WebSocketConnectionSurface.logic.test.ts | 114 - .../components/WebSocketConnectionSurface.tsx | 427 ---- .../components/auth/PairingRouteSurface.tsx | 10 +- apps/web/src/components/chat/ChatComposer.tsx | 6 +- apps/web/src/components/chat/ChatHeader.tsx | 5 +- apps/web/src/components/chat/OpenInPicker.tsx | 33 +- .../src/components/chat/ProposedPlanCard.tsx | 17 +- .../chat/ProviderModelPicker.browser.tsx | 58 - .../src/components/settings/CloudSettings.tsx | 10 +- .../settings/ConnectionsSettings.tsx | 605 ++--- .../settings/DiagnosticsSettings.tsx | 61 +- .../settings/KeybindingsSettings.tsx | 110 +- .../settings/SettingsPanels.browser.tsx | 448 +++- .../components/settings/SettingsPanels.tsx | 97 +- .../settings/SourceControlSettings.tsx | 7 +- .../connection/WebEnvironmentProjection.tsx | 62 + .../connection/WebServerStateProjection.tsx | 72 + .../src/connection/useWebEnvironmentData.ts | 33 + apps/web/src/connection/useWebEnvironments.ts | 112 + apps/web/src/connection/webAppQueries.ts | 300 +++ .../src/connection/webConnectionPlatform.ts | 313 +++ .../src/connection/webConnectionRuntime.ts | 75 + .../src/connection/webConnectionStorage.ts | 500 ++++ .../src/connection/webSourceControlActions.ts | 360 +++ .../web/src/connection/webTerminalSessions.ts | 166 ++ apps/web/src/editorPreferences.ts | 37 +- apps/web/src/environmentApi.ts | 99 - apps/web/src/environments/primary/target.ts | 8 +- .../src/environments/runtime/catalog.test.ts | 184 -- apps/web/src/environments/runtime/catalog.ts | 410 ---- .../environments/runtime/connection.test.ts | 295 --- .../src/environments/runtime/connection.ts | 7 - apps/web/src/environments/runtime/index.ts | 32 - .../service.addSavedEnvironment.test.ts | 1065 --------- .../runtime/service.savedEnvironments.test.ts | 329 --- .../service.threadSubscriptions.test.ts | 648 ----- apps/web/src/environments/runtime/service.ts | 2084 ----------------- apps/web/src/hooks/useSettings.ts | 45 +- apps/web/src/hooks/useThreadActions.ts | 86 +- apps/web/src/lib/archivedThreadsState.ts | 64 +- apps/web/src/lib/checkpointDiffState.ts | 64 +- apps/web/src/lib/composerPathSearchState.ts | 65 +- apps/web/src/lib/processDiagnosticsState.ts | 140 -- apps/web/src/lib/runtime.ts | 26 +- apps/web/src/lib/sourceControlActions.ts | 487 +--- .../src/lib/sourceControlDiscoveryState.ts | 96 +- apps/web/src/lib/traceDiagnosticsState.ts | 65 - apps/web/src/lib/vcsRefState.ts | 48 - apps/web/src/lib/vcsStatusState.ts | 69 +- apps/web/src/localApi.test.ts | 764 +----- apps/web/src/localApi.ts | 87 +- apps/web/src/routes/__root.tsx | 118 +- apps/web/src/routes/_chat.index.tsx | 8 +- apps/web/src/rpc/serverState.test.ts | 221 +- apps/web/src/rpc/serverState.ts | 39 - apps/web/src/rpc/wsConnectionState.test.ts | 107 - apps/web/src/rpc/wsConnectionState.ts | 238 -- apps/web/src/rpc/wsTransport.test.ts | 411 ---- apps/web/src/rpc/wsTransport.ts | 63 - apps/web/src/store.ts | 40 +- docs/architecture/connection-runtime.md | 112 + packages/client-runtime/package.json | 13 + .../src/connection/application.ts | 45 + .../client-runtime/src/connection/atoms.ts | 415 ++++ .../src/connection/authAccessSnapshot.test.ts | 79 + .../src/connection/authAccessSnapshot.ts | 69 + .../client-runtime/src/connection/broker.ts | 13 + .../src/connection/brokers.test.ts | 341 +++ .../client-runtime/src/connection/brokers.ts | 240 ++ .../src/connection/capabilities.ts | 120 + .../client-runtime/src/connection/catalog.ts | 143 ++ .../src/connection/commands.test.ts | 103 + .../client-runtime/src/connection/commands.ts | 320 +++ .../src/connection/connectivity.ts | 13 + .../src/connection/dataAtoms.ts | 476 ++++ .../client-runtime/src/connection/errors.ts | 140 ++ .../client-runtime/src/connection/index.ts | 27 + .../client-runtime/src/connection/model.ts | 182 ++ .../src/connection/onboarding.test.ts | 174 ++ .../src/connection/onboarding.ts | 166 ++ .../src/connection/operations.test.ts | 64 + .../src/connection/operations.ts | 458 ++++ .../src/connection/persistence.ts | 76 + .../src/connection/platformSource.ts | 11 + .../src/connection/presentation.test.ts | 79 + .../src/connection/presentation.ts | 111 + .../client-runtime/src/connection/react.ts | 672 ++++++ .../src/connection/readModel.test.ts | 120 + .../src/connection/readModel.ts | 110 + .../src/connection/registry.test.ts | 709 ++++++ .../client-runtime/src/connection/registry.ts | 505 ++++ .../src/connection/relayDiscovery.test.ts | 206 ++ .../src/connection/relayDiscovery.ts | 241 ++ .../connection/remoteAuthorization.test.ts | 334 +++ .../src/connection/remoteAuthorization.ts | 224 ++ .../src/connection/rpcSession.test.ts | 250 ++ .../src/connection/rpcSession.ts | 128 + .../src/connection/runtime.test.ts | 232 ++ .../client-runtime/src/connection/runtime.ts | 514 ++++ .../src/connection/storageDocument.test.ts | 142 ++ .../src/connection/storageDocument.ts | 137 ++ .../src/connection/supervisor.test.ts | 505 ++++ .../src/connection/supervisor.ts | 299 +++ .../src/connection/threads.test.ts | 319 +++ .../client-runtime/src/connection/threads.ts | 241 ++ .../client-runtime/src/connection/wakeups.ts | 11 + .../src/environmentConnection.ts | 244 -- .../src/environmentRuntimeState.test.ts | 5 + .../src/environmentRuntimeState.ts | 2 + .../client-runtime/src/errorTrace.test.ts | 25 + packages/client-runtime/src/errorTrace.ts | 18 + packages/client-runtime/src/index.ts | 6 +- .../client-runtime/src/managedRelay.test.ts | 180 ++ packages/client-runtime/src/managedRelay.ts | 441 ++-- .../src/managedRelayState.test.ts | 176 +- .../client-runtime/src/managedRelayState.ts | 150 +- .../src/reconnectBackoff.test.ts | 65 - .../client-runtime/src/reconnectBackoff.ts | 47 - .../src/terminalSessionState.test.ts | 125 + .../src/terminalSessionState.ts | 91 +- .../src/threadDetailState.test.ts | 322 --- .../client-runtime/src/threadDetailState.ts | 421 +--- .../client-runtime/src/vcsActionState.test.ts | 354 +-- packages/client-runtime/src/vcsActionState.ts | 305 +-- .../client-runtime/src/vcsRefState.test.ts | 399 ---- packages/client-runtime/src/vcsRefState.ts | 416 +--- .../client-runtime/src/vcsStatusState.test.ts | 330 --- packages/client-runtime/src/vcsStatusState.ts | 264 --- .../client-runtime/src/wsRpcClient.test.ts | 186 -- packages/client-runtime/src/wsRpcClient.ts | 376 --- packages/client-runtime/src/wsRpcProtocol.ts | 313 +-- .../client-runtime/src/wsTransport.test.ts | 959 -------- packages/client-runtime/src/wsTransport.ts | 377 --- packages/client-runtime/tsconfig.json | 4 +- packages/contracts/src/ipc.ts | 3 + pnpm-lock.yaml | 23 + 246 files changed, 20226 insertions(+), 20606 deletions(-) create mode 100644 apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts create mode 100644 apps/desktop/src/app/DesktopConnectionCatalogStore.ts create mode 100644 apps/desktop/src/electron/ElectronSafeStorageService.ts create mode 100644 apps/desktop/src/ipc/methods/connectionCatalog.ts create mode 100644 apps/mobile/src/connection/mobileAppQueries.test.ts create mode 100644 apps/mobile/src/connection/mobileAppQueries.ts create mode 100644 apps/mobile/src/connection/mobileAppQueryTargets.ts create mode 100644 apps/mobile/src/connection/mobileConnectionMigration.test.ts create mode 100644 apps/mobile/src/connection/mobileConnectionMigration.ts create mode 100644 apps/mobile/src/connection/mobileConnectionPlatform.ts create mode 100644 apps/mobile/src/connection/mobileConnectionRuntime.ts create mode 100644 apps/mobile/src/connection/mobileConnectionStorage.ts create mode 100644 apps/mobile/src/connection/mobileWorkspaceModel.test.ts create mode 100644 apps/mobile/src/connection/mobileWorkspaceModel.ts create mode 100644 apps/mobile/src/connection/useMobileCatalogView.ts create mode 100644 apps/mobile/src/connection/useMobileConnectionController.ts create mode 100644 apps/mobile/src/connection/useMobileEnvironmentData.ts create mode 100644 apps/mobile/src/connection/useMobileEnvironments.ts create mode 100644 apps/mobile/src/connection/useMobileWorkspace.ts create mode 100644 apps/mobile/src/features/cloud/cloudDebugLog.ts create mode 100644 apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts create mode 100644 apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts create mode 100644 apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts create mode 100644 apps/mobile/src/features/cloud/managedRelayTokenStore.ts create mode 100644 apps/mobile/src/features/connection/environmentSections.test.ts create mode 100644 apps/mobile/src/features/connection/environmentSections.ts create mode 100644 apps/mobile/src/lib/copyTextWithHaptic.test.ts create mode 100644 apps/mobile/src/lib/copyTextWithHaptic.ts delete mode 100644 apps/mobile/src/state/environment-session-registry.ts delete mode 100644 apps/mobile/src/state/use-checkpoint-diff.ts delete mode 100644 apps/mobile/src/state/use-environment-runtime.ts delete mode 100644 apps/mobile/src/state/use-remote-environment-registry.test.ts delete mode 100644 apps/web/src/components/WebSocketConnectionSurface.logic.test.ts delete mode 100644 apps/web/src/components/WebSocketConnectionSurface.tsx create mode 100644 apps/web/src/connection/WebEnvironmentProjection.tsx create mode 100644 apps/web/src/connection/WebServerStateProjection.tsx create mode 100644 apps/web/src/connection/useWebEnvironmentData.ts create mode 100644 apps/web/src/connection/useWebEnvironments.ts create mode 100644 apps/web/src/connection/webAppQueries.ts create mode 100644 apps/web/src/connection/webConnectionPlatform.ts create mode 100644 apps/web/src/connection/webConnectionRuntime.ts create mode 100644 apps/web/src/connection/webConnectionStorage.ts create mode 100644 apps/web/src/connection/webSourceControlActions.ts create mode 100644 apps/web/src/connection/webTerminalSessions.ts delete mode 100644 apps/web/src/environmentApi.ts delete mode 100644 apps/web/src/environments/runtime/catalog.test.ts delete mode 100644 apps/web/src/environments/runtime/catalog.ts delete mode 100644 apps/web/src/environments/runtime/connection.test.ts delete mode 100644 apps/web/src/environments/runtime/connection.ts delete mode 100644 apps/web/src/environments/runtime/index.ts delete mode 100644 apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts delete mode 100644 apps/web/src/environments/runtime/service.savedEnvironments.test.ts delete mode 100644 apps/web/src/environments/runtime/service.threadSubscriptions.test.ts delete mode 100644 apps/web/src/environments/runtime/service.ts delete mode 100644 apps/web/src/lib/processDiagnosticsState.ts delete mode 100644 apps/web/src/lib/traceDiagnosticsState.ts delete mode 100644 apps/web/src/lib/vcsRefState.ts delete mode 100644 apps/web/src/rpc/wsConnectionState.test.ts delete mode 100644 apps/web/src/rpc/wsConnectionState.ts delete mode 100644 apps/web/src/rpc/wsTransport.test.ts delete mode 100644 apps/web/src/rpc/wsTransport.ts create mode 100644 docs/architecture/connection-runtime.md create mode 100644 packages/client-runtime/src/connection/application.ts create mode 100644 packages/client-runtime/src/connection/atoms.ts create mode 100644 packages/client-runtime/src/connection/authAccessSnapshot.test.ts create mode 100644 packages/client-runtime/src/connection/authAccessSnapshot.ts create mode 100644 packages/client-runtime/src/connection/broker.ts create mode 100644 packages/client-runtime/src/connection/brokers.test.ts create mode 100644 packages/client-runtime/src/connection/brokers.ts create mode 100644 packages/client-runtime/src/connection/capabilities.ts create mode 100644 packages/client-runtime/src/connection/catalog.ts create mode 100644 packages/client-runtime/src/connection/commands.test.ts create mode 100644 packages/client-runtime/src/connection/commands.ts create mode 100644 packages/client-runtime/src/connection/connectivity.ts create mode 100644 packages/client-runtime/src/connection/dataAtoms.ts create mode 100644 packages/client-runtime/src/connection/errors.ts create mode 100644 packages/client-runtime/src/connection/index.ts create mode 100644 packages/client-runtime/src/connection/model.ts create mode 100644 packages/client-runtime/src/connection/onboarding.test.ts create mode 100644 packages/client-runtime/src/connection/onboarding.ts create mode 100644 packages/client-runtime/src/connection/operations.test.ts create mode 100644 packages/client-runtime/src/connection/operations.ts create mode 100644 packages/client-runtime/src/connection/persistence.ts create mode 100644 packages/client-runtime/src/connection/platformSource.ts create mode 100644 packages/client-runtime/src/connection/presentation.test.ts create mode 100644 packages/client-runtime/src/connection/presentation.ts create mode 100644 packages/client-runtime/src/connection/react.ts create mode 100644 packages/client-runtime/src/connection/readModel.test.ts create mode 100644 packages/client-runtime/src/connection/readModel.ts create mode 100644 packages/client-runtime/src/connection/registry.test.ts create mode 100644 packages/client-runtime/src/connection/registry.ts create mode 100644 packages/client-runtime/src/connection/relayDiscovery.test.ts create mode 100644 packages/client-runtime/src/connection/relayDiscovery.ts create mode 100644 packages/client-runtime/src/connection/remoteAuthorization.test.ts create mode 100644 packages/client-runtime/src/connection/remoteAuthorization.ts create mode 100644 packages/client-runtime/src/connection/rpcSession.test.ts create mode 100644 packages/client-runtime/src/connection/rpcSession.ts create mode 100644 packages/client-runtime/src/connection/runtime.test.ts create mode 100644 packages/client-runtime/src/connection/runtime.ts create mode 100644 packages/client-runtime/src/connection/storageDocument.test.ts create mode 100644 packages/client-runtime/src/connection/storageDocument.ts create mode 100644 packages/client-runtime/src/connection/supervisor.test.ts create mode 100644 packages/client-runtime/src/connection/supervisor.ts create mode 100644 packages/client-runtime/src/connection/threads.test.ts create mode 100644 packages/client-runtime/src/connection/threads.ts create mode 100644 packages/client-runtime/src/connection/wakeups.ts delete mode 100644 packages/client-runtime/src/environmentConnection.ts create mode 100644 packages/client-runtime/src/errorTrace.test.ts create mode 100644 packages/client-runtime/src/errorTrace.ts delete mode 100644 packages/client-runtime/src/reconnectBackoff.test.ts delete mode 100644 packages/client-runtime/src/reconnectBackoff.ts delete mode 100644 packages/client-runtime/src/threadDetailState.test.ts delete mode 100644 packages/client-runtime/src/vcsRefState.test.ts delete mode 100644 packages/client-runtime/src/vcsStatusState.test.ts delete mode 100644 packages/client-runtime/src/wsRpcClient.test.ts delete mode 100644 packages/client-runtime/src/wsRpcClient.ts delete mode 100644 packages/client-runtime/src/wsTransport.test.ts delete mode 100644 packages/client-runtime/src/wsTransport.ts 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/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..d29b121dacd 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 { useMobileWorkspace } from "../connection/useMobileWorkspace"; 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 } = useMobileWorkspace(); 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..441a6dbec51 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -102,6 +102,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/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index 8a40720089b..33da738939d 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/app/settings/environments.tsx @@ -2,31 +2,33 @@ import { useAuth } from "@clerk/expo"; import { Stack, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; 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 MobileRelayEnvironmentView, + useMobileConnectionController, +} from "../../connection/useMobileConnectionController"; +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 +39,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 +75,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(); +function ConfiguredCloudEnvironmentRows(props: { + readonly connectedCloudEnvironments: ReadonlyArray; + readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; +}) { + const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const controller = useMobileConnectionController(); const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState( null, ); 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); + async (entry: MobileRelayEnvironmentView) => { + setConnectingCloudEnvironmentId(entry.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.", - ); + await controller.connectRelayEnvironment(entry.environment); } finally { setConnectingCloudEnvironmentId(null); } }, - [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 +171,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 +185,49 @@ 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} + isConnecting={connectingCloudEnvironmentId === environment.environment.environmentId} 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 +240,134 @@ function ConfiguredCloudEnvironmentRows() { ); } +function ConnectedCloudEnvironmentRow(props: { + readonly environment: ConnectedEnvironmentSummary; + readonly borderTop: boolean; + readonly errorExpanded: boolean; + readonly onConnect: () => void; + readonly onDisconnect: () => void; + readonly onToggleError: () => void; +}) { + const isAvailable = + props.environment.connectionError === null && + (props.environment.connectionState === "idle" || + props.environment.connectionState === "disconnected"); + const isConnected = + props.environment.connectionState === "ready" || + props.environment.connectionState === "connecting" || + props.environment.connectionState === "reconnecting"; + + return ( + { + if (enabled) { + props.onConnect(); + return; + } + props.onDisconnect(); + }} + onToggleError={props.onToggleError} + statusText={isAvailable ? "Available · Relay status unknown" : undefined} + value={isConnected} + /> + ); +} + function CloudEnvironmentRow(props: { - readonly environment: RelayClientEnvironmentRecord; + readonly environment: MobileRelayEnvironmentView; readonly borderTop: boolean; + readonly errorExpanded: boolean; readonly isConnecting: 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({ + isConnecting: props.isConnecting, + 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={props.isConnecting} + /> + ); +} + +function CloudEnvironmentRowShell(props: { + readonly borderTop: boolean; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly connectionState: ConnectedEnvironmentSummary["connectionState"] | "available"; + 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 visualConnectionState = props.connectionError ? "disconnected" : props.connectionState; + const shouldPulse = + props.connectionError === null && + (props.connectionState === "connecting" || props.connectionState === "reconnecting"); + const statusText = + props.statusText ?? props.connectionError ?? cloudConnectionStatusLabel(props.connectionState); + 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 + + ); +} + +function cloudConnectionStatusLabel( + connectionState: ConnectedEnvironmentSummary["connectionState"] | "available", +): string { + switch (connectionState) { + case "available": + return "Available"; + case "ready": + return "Connected"; + case "connecting": + return "Connecting..."; + case "reconnecting": + return "Reconnecting..."; + case "disconnected": + return "Disconnected"; + case "idle": + return "Disconnected"; + } +} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index b25de626867..d865bf06e21 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -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/mobileAppQueries.test.ts b/apps/mobile/src/connection/mobileAppQueries.test.ts new file mode 100644 index 00000000000..5b58f04f1a9 --- /dev/null +++ b/apps/mobile/src/connection/mobileAppQueries.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { + buildMobileCheckpointDiffTargets, + normalizeMobileComposerPathSearchQuery, +} from "./mobileAppQueryTargets"; + +describe("mobileAppQueries", () => { + it("normalizes composer path search input", () => { + expect(normalizeMobileComposerPathSearchQuery(" src/app ")).toBe("src/app"); + expect(normalizeMobileComposerPathSearchQuery(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( + buildMobileCheckpointDiffTargets({ + 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( + buildMobileCheckpointDiffTargets({ + 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/connection/mobileAppQueries.ts b/apps/mobile/src/connection/mobileAppQueries.ts new file mode 100644 index 00000000000..4ce1ae4e2d1 --- /dev/null +++ b/apps/mobile/src/connection/mobileAppQueries.ts @@ -0,0 +1,180 @@ +import type { + EnvironmentId, + FilesystemBrowseInput, + OrchestrationThread, + ThreadId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useEffect, useMemo, useState } from "react"; + +import { + useMobileEnvironmentThread, + useMobileFilesystemBrowse, + useMobileFullThreadDiff, + useMobileProjectSearchEntries, + useMobileReviewDiffPreview, + useMobileSourceControlDiscovery, + useMobileTurnDiff, + useMobileVcsListRefs, + useMobileVcsStatus, +} from "./useMobileEnvironmentData"; +import { + buildMobileCheckpointDiffTargets, + normalizeMobileComposerPathSearchQuery, + type MobileCheckpointDiffTarget, +} from "./mobileAppQueryTargets"; + +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; +const COMPOSER_PATH_SEARCH_LIMIT = 20; +const VCS_REF_LIST_LIMIT = 100; + +export interface MobileThreadDetailView { + readonly data: OrchestrationThread | null; + readonly error: string | null; + readonly isPending: boolean; + readonly isDeleted: boolean; +} + +export interface MobileComposerPathSearchTarget { + 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 useMobileThreadDetail( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): MobileThreadDetailView { + const state = useMobileEnvironmentThread(environmentId, threadId); + return { + data: Option.getOrNull(state.data), + error: Option.getOrNull(state.error), + isPending: state.status === "synchronizing", + isDeleted: state.status === "deleted", + }; +} + +export function useMobileFilesystemDirectory( + environmentId: EnvironmentId | null, + input: FilesystemBrowseInput | null, +) { + return useMobileFilesystemBrowse( + environmentId !== null && input !== null ? { environmentId, input } : null, + ); +} + +export function useMobileSourceControlCapabilities(environmentId: EnvironmentId | null) { + return useMobileSourceControlDiscovery(environmentId); +} + +export function useMobileBranches(input: { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query?: string | null; +}) { + const query = input.query?.trim() ?? ""; + return useMobileVcsListRefs( + input.environmentId !== null && input.cwd !== null + ? { + environmentId: input.environmentId, + input: { + cwd: input.cwd, + ...(query.length > 0 ? { query } : {}), + limit: VCS_REF_LIST_LIMIT, + }, + } + : null, + ); +} + +export function useMobileRepositoryStatus(input: { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +}) { + return useMobileVcsStatus( + input.environmentId !== null && input.cwd !== null + ? { + environmentId: input.environmentId, + input: { cwd: input.cwd }, + } + : null, + ); +} + +export function useMobileReviewPreview(input: { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +}) { + return useMobileReviewDiffPreview( + input.environmentId !== null && input.cwd !== null + ? { + environmentId: input.environmentId, + input: { cwd: input.cwd }, + } + : null, + ); +} + +export function useMobileComposerPathSearch(target: MobileComposerPathSearchTarget) { + const normalizedTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: normalizeMobileComposerPathSearchQuery(target.query), + }), + [target.cwd, target.environmentId, target.query], + ); + const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const result = useMobileProjectSearchEntries( + debouncedTarget.environmentId !== null && + debouncedTarget.cwd !== null && + debouncedTarget.query.length > 0 + ? { + 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 useMobileCheckpointDiff(target: MobileCheckpointDiffTarget) { + const targets = useMemo( + () => buildMobileCheckpointDiffTargets(target), + [ + target.environmentId, + target.fromTurnCount, + target.ignoreWhitespace, + target.threadId, + target.toTurnCount, + ], + ); + const fullThread = useMobileFullThreadDiff(targets.fullThread); + const turn = useMobileTurnDiff(targets.turn); + return targets.fullThread === null ? turn : fullThread; +} diff --git a/apps/mobile/src/connection/mobileAppQueryTargets.ts b/apps/mobile/src/connection/mobileAppQueryTargets.ts new file mode 100644 index 00000000000..d104c6b08b5 --- /dev/null +++ b/apps/mobile/src/connection/mobileAppQueryTargets.ts @@ -0,0 +1,51 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +export interface MobileCheckpointDiffTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly fromTurnCount: number | null; + readonly toTurnCount: number | null; + readonly ignoreWhitespace: boolean; +} + +export function normalizeMobileComposerPathSearchQuery(query: string | null): string { + return query?.trim() ?? ""; +} + +export function buildMobileCheckpointDiffTargets(target: MobileCheckpointDiffTarget) { + 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/connection/mobileConnectionMigration.test.ts b/apps/mobile/src/connection/mobileConnectionMigration.test.ts new file mode 100644 index 00000000000..3f548504d62 --- /dev/null +++ b/apps/mobile/src/connection/mobileConnectionMigration.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 { migrateLegacyMobileConnectionCatalog } from "./mobileConnectionMigration"; + +describe("migrateLegacyMobileConnectionCatalog", () => { + 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* migrateLegacyMobileConnectionCatalog( + 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* migrateLegacyMobileConnectionCatalog( + 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/mobileConnectionMigration.ts b/apps/mobile/src/connection/mobileConnectionMigration.ts new file mode 100644 index 00000000000..0b1967b8cc0 --- /dev/null +++ b/apps/mobile/src/connection/mobileConnectionMigration.ts @@ -0,0 +1,108 @@ +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + type ConnectionCatalogDocument, + EMPTY_CONNECTION_CATALOG_DOCUMENT, + RelayConnectionRegistration, + registerConnectionInCatalog, + RelayConnectionTarget, + BearerConnectionTarget, +} from "@t3tools/client-runtime"; +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 migrateLegacyMobileConnectionCatalog = 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/mobileConnectionPlatform.ts b/apps/mobile/src/connection/mobileConnectionPlatform.ts new file mode 100644 index 00000000000..20680fb0ae0 --- /dev/null +++ b/apps/mobile/src/connection/mobileConnectionPlatform.ts @@ -0,0 +1,180 @@ +import { + ClientPresentation, + CloudSession, + ConnectionBlockedError, + ConnectionTransientError, + ConnectionWakeups, + Connectivity, + managedRelaySessionAtom, + PlatformConnectionSource, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "@t3tools/client-runtime"; +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 { mobileAuthClientMetadata } from "../lib/authClientMetadata"; +import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; +import { appAtomRegistry } from "../state/atom-registry"; +import { mobileConnectionStorageLayer } from "./mobileConnectionStorage"; + +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 mobileConnectivityLayer = 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 mobileWakeupsLayer = 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 mobileCapabilitiesLayer = 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: mobileAuthClientMetadata(), + 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 mobilePlatformConnectionSourceLayer = Layer.succeed( + PlatformConnectionSource, + PlatformConnectionSource.of({ + registrations: Stream.empty, + }), +); + +export const mobileConnectionPlatformLayer = Layer.mergeAll( + mobileConnectionStorageLayer, + mobileConnectivityLayer, + mobileWakeupsLayer, + mobileCapabilitiesLayer, + mobilePlatformConnectionSourceLayer, +); diff --git a/apps/mobile/src/connection/mobileConnectionRuntime.ts b/apps/mobile/src/connection/mobileConnectionRuntime.ts new file mode 100644 index 00000000000..e47fcc31cc1 --- /dev/null +++ b/apps/mobile/src/connection/mobileConnectionRuntime.ts @@ -0,0 +1,48 @@ +import { + connectionApplicationLayer, + ConnectionOnboarding, + createEnvironmentDataAtoms, + createEnvironmentConnectionAtoms, + createRelayEnvironmentDiscoveryAtoms, +} from "@t3tools/client-runtime"; +import { createEnvironmentReactFacade } from "@t3tools/client-runtime/connection/react"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import { mobileRuntimeLayer } from "../lib/runtime"; +import { mobileConnectionPlatformLayer } from "./mobileConnectionPlatform"; + +const mobileConnectionDependencies = Layer.mergeAll( + mobileRuntimeLayer, + mobileConnectionPlatformLayer, +); + +export const mobileConnectionLayer = connectionApplicationLayer.pipe( + Layer.provide(mobileConnectionDependencies), +); + +export const mobileConnectionAtomRuntime = Atom.runtime(mobileConnectionLayer); + +export const mobileEnvironmentConnections = createEnvironmentConnectionAtoms( + mobileConnectionAtomRuntime, +); + +export const mobileEnvironmentData = createEnvironmentDataAtoms(mobileConnectionAtomRuntime); + +export const mobileEnvironmentReact = createEnvironmentReactFacade( + mobileEnvironmentConnections, + mobileEnvironmentData, +); + +export const mobileRelayEnvironmentDiscovery = createRelayEnvironmentDiscoveryAtoms( + mobileConnectionAtomRuntime, +); + +export const connectMobilePairingUrl = mobileConnectionAtomRuntime + .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/mobileConnectionStorage.ts b/apps/mobile/src/connection/mobileConnectionStorage.ts new file mode 100644 index 00000000000..6fdd9d57744 --- /dev/null +++ b/apps/mobile/src/connection/mobileConnectionStorage.ts @@ -0,0 +1,504 @@ +import { + ConnectionCatalogDocument, + ConnectionCredentialStore, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, + ConnectionPersistenceError, + ConnectionProfileStore, + ConnectionRegistrationStore, + ConnectionTargetStore, + ConnectionTransientError, + EMPTY_CONNECTION_CATALOG_DOCUMENT, + EnvironmentCacheStore, + RemoteDpopAccessTokenStore, + registerConnectionInCatalog, + removeConnectionFromCatalog, + removeCatalogValue, + replaceCatalogValue, +} from "@t3tools/client-runtime"; +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 { migrateLegacyMobileConnectionCatalog } from "./mobileConnectionMigration"; + +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 MobileCatalogStore { + readonly read: Effect.Effect; + readonly update: ( + transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, + ) => Effect.Effect; +} + +const makeMobileCatalogStore = 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* migrateLegacyMobileConnectionCatalog(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: MobileCatalogStore["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 MobileCatalogStore; +}); + +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 mobileConnectionStorageLayer = Layer.effectContext( + Effect.gen(function* () { + const catalog = yield* makeMobileCatalogStore(); + + 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((profile) => profile.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/connection/mobileWorkspaceModel.test.ts b/apps/mobile/src/connection/mobileWorkspaceModel.test.ts new file mode 100644 index 00000000000..f81071b36ef --- /dev/null +++ b/apps/mobile/src/connection/mobileWorkspaceModel.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + BearerConnectionProfile, + BearerConnectionTarget, + type EnvironmentCatalogReadModel, +} from "@t3tools/client-runtime"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +import { + projectMobileWorkspaceEnvironment, + projectMobileWorkspaceState, +} from "./mobileWorkspaceModel"; +import type { MobileEnvironmentPresentation } from "./useMobileEnvironments"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +function environment( + phase: MobileEnvironmentPresentation["connection"]["phase"], +): MobileEnvironmentPresentation { + 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_READ_MODEL: EnvironmentCatalogReadModel = { + projects: [], + threads: [], + snapshotByEnvironmentId: new Map(), + shellStatusByEnvironmentId: new Map(), + shellErrorByEnvironmentId: new Map(), + serverConfigByEnvironmentId: new Map(), + latestSnapshotUpdatedAt: null, + hasCachedData: false, + hasLiveData: false, + hasRemoteActivity: false, +}; + +describe("mobile workspace projection", () => { + it("preserves explicit offline state without presenting it as a connection error", () => { + const projected = projectMobileWorkspaceEnvironment(environment("offline")); + + expect(projected.connectionState).toBe("disconnected"); + expect(projected.connectionPhase).toBe("offline"); + expect(projected.connectionError).toBeNull(); + }); + + it("reports offline before stale connected presentations", () => { + const environments = [projectMobileWorkspaceEnvironment(environment("connected"))]; + const state = projectMobileWorkspaceState({ + isReady: true, + networkStatus: "offline", + environments, + readModel: EMPTY_READ_MODEL, + }); + + expect(state.connectionState).toBe("disconnected"); + expect(state.networkStatus).toBe("offline"); + expect(state.hasReadyEnvironment).toBe(true); + }); + + it("projects reconnecting environments dynamically from active phases", () => { + const environments = [ + projectMobileWorkspaceEnvironment(environment("reconnecting")), + projectMobileWorkspaceEnvironment({ + ...environment("connected"), + environmentId: EnvironmentId.make("environment-2"), + }), + ]; + const state = projectMobileWorkspaceState({ + isReady: true, + networkStatus: "online", + environments, + readModel: EMPTY_READ_MODEL, + }); + + expect(state.connectingEnvironments).toHaveLength(1); + expect(state.connectingEnvironments[0]?.connectionPhase).toBe("reconnecting"); + }); +}); diff --git a/apps/mobile/src/connection/mobileWorkspaceModel.ts b/apps/mobile/src/connection/mobileWorkspaceModel.ts new file mode 100644 index 00000000000..f65c391f8b4 --- /dev/null +++ b/apps/mobile/src/connection/mobileWorkspaceModel.ts @@ -0,0 +1,132 @@ +import type { + EnvironmentCatalogReadModel, + EnvironmentConnectionPhase, + NetworkStatus, +} from "@t3tools/client-runtime"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; + +import type { MobileEnvironmentPresentation } from "./useMobileEnvironments"; + +export type MobileWorkspaceConnectionState = + | "idle" + | "connecting" + | "ready" + | "reconnecting" + | "disconnected"; + +export interface MobileWorkspaceEnvironment { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly displayUrl: string; + readonly isRelayManaged: boolean; + readonly connectionState: MobileWorkspaceConnectionState; + readonly connectionPhase: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; +} + +export interface MobileWorkspaceState { + readonly isLoadingConnections: boolean; + readonly hasConnections: boolean; + readonly hasLoadedShellSnapshot: boolean; + readonly hasPendingShellSnapshot: boolean; + readonly hasReadyEnvironment: boolean; + readonly hasConnectingEnvironment: boolean; + readonly connectingEnvironments: ReadonlyArray; + readonly connectionState: MobileWorkspaceConnectionState; + readonly connectionError: string | null; + readonly shellSnapshotError: string | null; + readonly isUsingCachedData: boolean; + readonly latestCachedSnapshotReceivedAt: string | null; + readonly networkStatus: NetworkStatus; +} + +function legacyConnectionState(phase: EnvironmentConnectionPhase): MobileWorkspaceConnectionState { + switch (phase) { + case "available": + return "idle"; + case "connecting": + return "connecting"; + case "reconnecting": + return "reconnecting"; + case "connected": + return "ready"; + case "offline": + case "error": + return "disconnected"; + } +} + +export function projectMobileWorkspaceEnvironment( + environment: MobileEnvironmentPresentation, +): MobileWorkspaceEnvironment { + return { + environmentId: environment.environmentId, + environmentLabel: environment.label, + displayUrl: environment.displayUrl ?? "", + isRelayManaged: environment.relayManaged, + connectionState: legacyConnectionState(environment.connection.phase), + connectionPhase: environment.connection.phase, + connectionError: environment.connection.error, + connectionErrorTraceId: environment.connection.traceId, + }; +} + +function overallConnectionState( + environments: ReadonlyArray, + networkStatus: NetworkStatus, +): MobileWorkspaceConnectionState { + if (environments.length === 0) { + return "idle"; + } + if (networkStatus === "offline") { + return "disconnected"; + } + 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"; +} + +export function projectMobileWorkspaceState(input: { + readonly isReady: boolean; + readonly networkStatus: NetworkStatus; + readonly environments: ReadonlyArray; + readonly readModel: EnvironmentCatalogReadModel; +}): MobileWorkspaceState { + const connectingEnvironments = input.environments.filter( + (environment) => + environment.connectionState === "connecting" || + environment.connectionState === "reconnecting", + ); + + return { + isLoadingConnections: !input.isReady, + hasConnections: input.environments.length > 0, + hasLoadedShellSnapshot: input.readModel.snapshotByEnvironmentId.size > 0, + hasPendingShellSnapshot: [...input.readModel.shellStatusByEnvironmentId.values()].some( + (status) => status === "synchronizing", + ), + hasReadyEnvironment: input.environments.some( + (environment) => environment.connectionState === "ready", + ), + hasConnectingEnvironment: connectingEnvironments.length > 0, + connectingEnvironments, + connectionState: overallConnectionState(input.environments, input.networkStatus), + connectionError: + input.environments.find((environment) => environment.connectionError !== null) + ?.connectionError ?? null, + shellSnapshotError: input.readModel.shellErrorByEnvironmentId.values().next().value ?? null, + isUsingCachedData: input.readModel.hasCachedData, + latestCachedSnapshotReceivedAt: input.readModel.latestSnapshotUpdatedAt, + networkStatus: input.networkStatus, + }; +} + +export type MobileServerConfigByEnvironmentId = ReadonlyMap; diff --git a/apps/mobile/src/connection/useMobileCatalogView.ts b/apps/mobile/src/connection/useMobileCatalogView.ts new file mode 100644 index 00000000000..df9f43ff4cd --- /dev/null +++ b/apps/mobile/src/connection/useMobileCatalogView.ts @@ -0,0 +1,67 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { mobileEnvironmentConnections } from "./mobileConnectionRuntime"; +import { type MobileEnvironmentPresentation, useMobileEnvironments } from "./useMobileEnvironments"; + +export interface MobileCatalogViewState { + readonly isReady: boolean; + readonly hasEnvironments: boolean; + readonly hasShellSnapshot: boolean; + readonly hasPendingShellSnapshot: boolean; + readonly hasConnectedEnvironment: boolean; + readonly hasConnectingEnvironment: boolean; + readonly connectingEnvironments: ReadonlyArray; + readonly connectionError: string | null; + readonly shellError: string | null; + readonly isUsingCachedData: boolean; + readonly latestSnapshotUpdatedAt: string | null; +} + +export function useMobileCatalogView() { + const { isReady, networkStatus, environments } = useMobileEnvironments(); + const readModel = useAtomValue(mobileEnvironmentConnections.catalogReadModelAtom); + + const state = useMemo(() => { + const connectingEnvironments = environments.filter( + (environment) => + environment.connection.phase === "connecting" || + environment.connection.phase === "reconnecting", + ); + const connectionError = + environments.find((environment) => environment.connection.error !== null)?.connection.error ?? + null; + const shellError = readModel.shellErrorByEnvironmentId.values().next().value ?? null; + + return { + isReady, + hasEnvironments: environments.length > 0, + hasShellSnapshot: readModel.snapshotByEnvironmentId.size > 0, + hasPendingShellSnapshot: [...readModel.shellStatusByEnvironmentId.values()].some( + (status) => status === "synchronizing", + ), + hasConnectedEnvironment: environments.some( + (environment) => environment.connection.phase === "connected", + ), + hasConnectingEnvironment: connectingEnvironments.length > 0, + connectingEnvironments, + connectionError, + shellError, + isUsingCachedData: readModel.hasCachedData, + latestSnapshotUpdatedAt: readModel.latestSnapshotUpdatedAt, + }; + }, [environments, isReady, readModel]); + + return { + projects: readModel.projects, + threads: readModel.threads, + serverConfigByEnvironmentId: readModel.serverConfigByEnvironmentId as ReadonlyMap< + EnvironmentId, + ServerConfig + >, + networkStatus, + state, + hasRemoteActivity: readModel.hasRemoteActivity, + }; +} diff --git a/apps/mobile/src/connection/useMobileConnectionController.ts b/apps/mobile/src/connection/useMobileConnectionController.ts new file mode 100644 index 00000000000..8cae0b0b7e6 --- /dev/null +++ b/apps/mobile/src/connection/useMobileConnectionController.ts @@ -0,0 +1,87 @@ +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 { mobileRelayEnvironmentDiscovery } from "./mobileConnectionRuntime"; +import { + projectMobileWorkspaceEnvironment, + type MobileWorkspaceEnvironment, +} from "./mobileWorkspaceModel"; +import { useMobileEnvironmentActions, useMobileEnvironments } from "./useMobileEnvironments"; + +export interface MobileRelayEnvironmentView { + readonly environment: RelayClientEnvironmentRecord; + readonly availability: "checking" | "online" | "offline" | "error"; + readonly status: RelayEnvironmentStatusResponse | null; + readonly error: string | null; + readonly traceId: string | null; +} + +export function useMobileConnectionController() { + const { environments } = useMobileEnvironments(); + const actions = useMobileEnvironmentActions(); + const discovery = useAtomValue(mobileRelayEnvironmentDiscovery.stateValueAtom); + + const connectedEnvironments = useMemo>( + () => environments.map(projectMobileWorkspaceEnvironment), + [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/connection/useMobileEnvironmentData.ts b/apps/mobile/src/connection/useMobileEnvironmentData.ts new file mode 100644 index 00000000000..b1697460606 --- /dev/null +++ b/apps/mobile/src/connection/useMobileEnvironmentData.ts @@ -0,0 +1,33 @@ +import { mobileEnvironmentReact } from "./mobileConnectionRuntime"; + +export const useMobileEnvironmentConnectionState = mobileEnvironmentReact.useConnectionState; +export const useMobileEnvironmentConfig = mobileEnvironmentReact.useEnvironmentConfig; +export const useMobileEnvironmentShell = mobileEnvironmentReact.useShell; +export const useMobileEnvironmentThread = mobileEnvironmentReact.useThread; +export const useMobileFilesystemBrowse = mobileEnvironmentReact.useFilesystemBrowse; +export const useMobileProjectSearchEntries = mobileEnvironmentReact.useProjectSearchEntries; +export const useMobileVcsListRefs = mobileEnvironmentReact.useVcsListRefs; +export const useMobileVcsStatus = mobileEnvironmentReact.useVcsStatus; +export const useMobileReviewDiffPreview = mobileEnvironmentReact.useReviewDiffPreview; +export const useMobileServerConfig = mobileEnvironmentReact.useServerConfig; +export const useMobileServerSettings = mobileEnvironmentReact.useServerSettings; +export const useMobileSourceControlDiscovery = mobileEnvironmentReact.useSourceControlDiscovery; +export const useMobileTraceDiagnostics = mobileEnvironmentReact.useTraceDiagnostics; +export const useMobileProcessDiagnostics = mobileEnvironmentReact.useProcessDiagnostics; +export const useMobileProcessResourceHistory = mobileEnvironmentReact.useProcessResourceHistory; +export const useMobileSourceControlRepository = mobileEnvironmentReact.useSourceControlRepository; +export const useMobilePullRequestResolution = mobileEnvironmentReact.usePullRequestResolution; +export const useMobileTurnDiff = mobileEnvironmentReact.useTurnDiff; +export const useMobileFullThreadDiff = mobileEnvironmentReact.useFullThreadDiff; +export const useMobileArchivedShellSnapshot = mobileEnvironmentReact.useArchivedShellSnapshot; +export const useMobileRelayClientStatus = mobileEnvironmentReact.useRelayClientStatus; +export const useMobileTerminalAttach = mobileEnvironmentReact.useTerminalAttach; +export const useMobileTerminalEvents = mobileEnvironmentReact.useTerminalEvents; +export const useMobileTerminalMetadata = mobileEnvironmentReact.useTerminalMetadata; +export const useMobileServerConfigChanges = mobileEnvironmentReact.useServerConfigChanges; +export const useMobileServerLifecycleChanges = mobileEnvironmentReact.useServerLifecycleChanges; +export const useMobileAuthAccessChanges = mobileEnvironmentReact.useAuthAccessChanges; +export const useMobileEnvironmentConnectionActions = mobileEnvironmentReact.useConnectionActions; +export const useMobileRunStackedGitActionState = mobileEnvironmentReact.useRunStackedGitActionState; +export const useMobileActions = mobileEnvironmentReact.useActions; +export const useMobileEnvironmentActions = mobileEnvironmentReact.useEnvironmentActions; diff --git a/apps/mobile/src/connection/useMobileEnvironments.ts b/apps/mobile/src/connection/useMobileEnvironments.ts new file mode 100644 index 00000000000..0b1d96b01e4 --- /dev/null +++ b/apps/mobile/src/connection/useMobileEnvironments.ts @@ -0,0 +1,86 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { + connectionCatalogDisplayUrl, + RelayConnectionRegistration, + RelayConnectionTarget, + type EnvironmentPresentation, + type EnvironmentShellState, +} from "@t3tools/client-runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { useCallback, useMemo } from "react"; + +import { + connectMobilePairingUrl, + mobileEnvironmentConnections, + mobileEnvironmentReact, + mobileRelayEnvironmentDiscovery, +} from "./mobileConnectionRuntime"; + +export interface MobileEnvironmentPresentation extends EnvironmentPresentation { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly displayUrl: string | null; + readonly relayManaged: boolean; +} + +export function useMobileEnvironments() { + const catalog = useAtomValue(mobileEnvironmentConnections.catalogValueAtom); + const networkStatus = useAtomValue(mobileEnvironmentConnections.networkStatusValueAtom); + const presentationById = useAtomValue(mobileEnvironmentConnections.presentationsAtom); + const shellStateById = useAtomValue(mobileEnvironmentConnections.shellStatesAtom); + + const environments = useMemo( + () => + [...presentationById.entries()].map( + ([environmentId, presentation]) => + ({ + ...presentation, + environmentId, + label: presentation.entry.target.label, + displayUrl: connectionCatalogDisplayUrl(presentation.entry), + relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", + }) satisfies MobileEnvironmentPresentation, + ), + [presentationById], + ); + + return { + isReady: catalog.isReady, + networkStatus, + environments, + presentationById, + shellStateById: shellStateById as ReadonlyMap, + }; +} + +export function useMobileEnvironmentActions() { + const connectPairingUrl = useAtomSet(connectMobilePairingUrl, { + mode: "promise", + }); + const { register, remove, retryNow } = mobileEnvironmentReact.useConnectionActions(); + const refreshRelayEnvironments = useAtomSet(mobileRelayEnvironmentDiscovery.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/connection/useMobileWorkspace.ts b/apps/mobile/src/connection/useMobileWorkspace.ts new file mode 100644 index 00000000000..85dd872ec22 --- /dev/null +++ b/apps/mobile/src/connection/useMobileWorkspace.ts @@ -0,0 +1,41 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { mobileEnvironmentConnections } from "./mobileConnectionRuntime"; +import { + projectMobileWorkspaceEnvironment, + projectMobileWorkspaceState, +} from "./mobileWorkspaceModel"; +import { useMobileEnvironments } from "./useMobileEnvironments"; + +export function useMobileWorkspace() { + const { isReady, networkStatus, environments } = useMobileEnvironments(); + const readModel = useAtomValue(mobileEnvironmentConnections.catalogReadModelAtom); + const projectedEnvironments = useMemo( + () => environments.map(projectMobileWorkspaceEnvironment), + [environments], + ); + const state = useMemo( + () => + projectMobileWorkspaceState({ + isReady, + networkStatus, + environments: projectedEnvironments, + readModel, + }), + [isReady, networkStatus, projectedEnvironments, readModel], + ); + + return { + environments: projectedEnvironments, + projects: readModel.projects, + threads: readModel.threads, + serverConfigByEnvironmentId: readModel.serverConfigByEnvironmentId as ReadonlyMap< + EnvironmentId, + ServerConfig + >, + state, + hasRemoteActivity: readModel.hasRemoteActivity, + }; +} diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index f06868ed7d9..e90c00f84fb 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -3,6 +3,7 @@ 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 * 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/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 346680df8c0..8dbbb1b602a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -4,8 +4,10 @@ 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"; @@ -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: { @@ -96,16 +105,10 @@ 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)), - ), - ), - ), - ), + 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 = mobileManagedRelayClientLayer("https://relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), +); + +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..a40e13b8478 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -8,7 +8,7 @@ import { type RelayDeviceRegistrationRequest, type RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { findErrorTraceId, ManagedRelayClient } from "@t3tools/client-runtime"; import type { SavedRemoteConnection } from "../../lib/connection"; import { mobileRuntime } from "../../lib/runtime"; @@ -29,6 +29,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 +82,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 +109,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 +168,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 +253,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 { @@ -235,15 +276,94 @@ function runRegistrationInBackground( }); } -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 = mobileRuntime + .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 +375,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 +390,7 @@ function registerDevice(input?: { notificationsEnabled: pushTokenRegistration.notificationsEnabled, preferences, }), + expectedGeneration, ); }); } @@ -277,10 +402,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 +425,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 +441,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 +494,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..f6b2651b6f9 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,6 +1,11 @@ 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"; +import * as Effect from "effect/Effect"; import { type ReactNode, useEffect, useRef } from "react"; import { mobileRuntime } from "../../lib/runtime"; @@ -11,6 +16,12 @@ import { } from "../agent-awareness/remoteRegistration"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; +function resetManagedRelayTokenCache(): Promise { + return mobileRuntime.runPromise( + ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), + ); +} + function CloudAuthBridge(props: { readonly children: ReactNode }) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); const previousTokenProviderRef = useRef<{ @@ -19,6 +30,7 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { } | null>(null); useEffect(() => { + let cancelled = false; if (!isLoaded) { return; } @@ -29,6 +41,7 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { void mobileRuntime .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) .catch(() => undefined); + void resetManagedRelayTokenCache(); } setAgentAwarenessRelayTokenProvider(null); setManagedRelaySession(appAtomRegistry, null); @@ -36,21 +49,36 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { } const previous = previousTokenProviderRef.current; + const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); + const activateSession = () => { + if (cancelled) { + return; + } + previousTokenProviderRef.current = { userId, provider: tokenProvider }; + setAgentAwarenessRelayTokenProvider(tokenProvider, userId); + setManagedRelaySession( + appAtomRegistry, + createManagedRelaySession({ + accountId: userId, + readClerkToken: tokenProvider, + }), + ); + }; if (previous && previous.userId !== userId) { + previousTokenProviderRef.current = null; + setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); void mobileRuntime .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) .catch(() => undefined); + void resetManagedRelayTokenCache().then(activateSession); + } else { + activateSession(); } - const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); - previousTokenProviderRef.current = { userId, provider: tokenProvider }; - setAgentAwarenessRelayTokenProvider(tokenProvider, userId); - setManagedRelaySession( - appAtomRegistry, - createManagedRelaySession({ - accountId: userId, - readClerkToken: tokenProvider, - }), - ); + + return () => { + cancelled = true; + }; }, [getToken, isLoaded, isSignedIn, 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..9d22cf71f51 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts @@ -0,0 +1,107 @@ +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({ + isConnecting: false, + 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({ + isConnecting: false, + isStatusPending: true, + status: null, + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Checking relay status...", + }); + }); + + it("shows connecting only after the user starts connecting", () => { + expect( + availableCloudEnvironmentPresentation({ + isConnecting: true, + isStatusPending: true, + status: null, + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: null, + connectionErrorTraceId: null, + connectionState: "connecting", + statusText: "Connecting...", + }); + }); + + it("surfaces an offline relay as an error", () => { + expect( + availableCloudEnvironmentPresentation({ + isConnecting: false, + isStatusPending: false, + status: relayStatus("offline", "Tunnel is unavailable."), + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: "Tunnel is unavailable.", + connectionErrorTraceId: null, + connectionState: "disconnected", + statusText: "Tunnel is unavailable.", + }); + }); + + it("preserves trace metadata for relay request failures", () => { + expect( + availableCloudEnvironmentPresentation({ + isConnecting: false, + 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..865ac396ca7 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts @@ -0,0 +1,64 @@ +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; + +export type AvailableCloudEnvironmentState = "available" | "connecting" | "disconnected"; + +export interface AvailableCloudEnvironmentPresentation { + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly connectionState: AvailableCloudEnvironmentState; + readonly statusText: string; +} + +export function availableCloudEnvironmentPresentation(input: { + readonly isConnecting: boolean; + readonly isStatusPending: boolean; + readonly status: RelayEnvironmentStatusResponse | null; + readonly statusError: string | null; + readonly statusErrorTraceId: string | null; +}): AvailableCloudEnvironmentPresentation { + if (input.isConnecting) { + return { + connectionError: null, + connectionErrorTraceId: null, + connectionState: "connecting", + statusText: "Connecting...", + }; + } + + 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: "disconnected", + statusText: connectionError, + }; + } + + if (input.statusError) { + return { + connectionError: input.statusError, + connectionErrorTraceId: input.statusErrorTraceId, + connectionState: "disconnected", + 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/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 16a7a745398..9fd65c1ec2f 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -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..52a9283da9d 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -16,7 +16,6 @@ import { type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, - RelayProtectedError, type RelayDpopAccessTokenScope, type RelayProtectedError as RelayProtectedErrorType, type RelayClientEnvironmentRecord, @@ -26,8 +25,10 @@ import { import { exchangeRemoteDpopAccessToken, fetchRemoteEnvironmentDescriptor, + findErrorTraceId, makeEnvironmentHttpApiClient, ManagedRelayClient, + type ManagedRelayClientError, ManagedRelayDpopSigner, } from "@t3tools/client-runtime"; @@ -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, @@ -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..e150665edca 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -9,34 +9,38 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; +import { mobileManagedRelayAccessTokenStore } from "./managedRelayTokenStore"; const mobileRelayDpopSignerLayer = 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), - ); + managedRelayClientLayer({ + relayUrl, + clientId: RelayMobileClientId, + accessTokenStore: mobileManagedRelayAccessTokenStore, + }).pipe(Layer.provideMerge(mobileRelayDpopSignerLayer)); diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 3394a519fd6..0f747bb1f0f 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -9,14 +9,18 @@ import type { 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 { appAtomRegistry } from "../../state/atom-registry"; +import { cloudDebugLog } from "./cloudDebugLog"; const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer); -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..8e90e88615f --- /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 { mobileManagedRelayAccessTokenStore } 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* mobileManagedRelayAccessTokenStore.save(entries); + expect(yield* mobileManagedRelayAccessTokenStore.load).toEqual(entries); + + yield* mobileManagedRelayAccessTokenStore.clear; + expect(yield* mobileManagedRelayAccessTokenStore.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* mobileManagedRelayAccessTokenStore.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..9f477b12431 --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -0,0 +1,107 @@ +import { + type ManagedRelayAccessTokenCacheEntry, + type ManagedRelayAccessTokenStore, +} from "@t3tools/client-runtime"; +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 mobileManagedRelayAccessTokenStore: 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/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..932fa398f79 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -47,6 +47,9 @@ export function ConnectionEnvironmentRow(props: { const primaryFg = useThemeColor("--color-primary-foreground"); const dangerFg = useThemeColor("--color-danger-foreground"); const statusLabel = connectionStatusLabel(props.environment); + const visualConnectionState = props.environment.connectionError + ? "disconnected" + : props.environment.connectionState; const handleSave = useCallback(() => { props.onUpdate(props.environment.environmentId, { @@ -63,10 +66,11 @@ export function ConnectionEnvironmentRow(props: { onPress={props.onToggle} > diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx index 60d86e0118c..0f35e483a1a 100644 --- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -11,11 +11,18 @@ import Animated, { import type { RemoteClientConnectionState } from "../../lib/connection"; -function statusDotTone(state: RemoteClientConnectionState): { +export type ConnectionStatusDotState = RemoteClientConnectionState | "available"; + +function statusDotTone(state: ConnectionStatusDotState): { readonly dotColor: string; readonly haloColor: string; } { switch (state) { + case "available": + return { + dotColor: "#9ca3af", + haloColor: "rgba(156,163,175,0.42)", + }; case "ready": return { dotColor: "#34d399", @@ -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/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts new file mode 100644 index 00000000000..90c578ac5e7 --- /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 ?? "ready", + 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: "disconnected", + 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 a cleanly disconnected relay environment as a fallback when listing is unavailable", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "disconnected", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: null, + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("returns cleanly disconnected relay environments to the available cloud listing", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "disconnected", + }); + const listedCloud = cloudEnvironment("environment-cloud"); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: [listedCloud], + }); + + expect(sections.connectedCloudEnvironments).toEqual([]); + expect(sections.availableCloudEnvironments).toEqual([listedCloud]); + }); + + it("keeps failed relay environments in the local connection row", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "disconnected", + 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..c6129b4e45b --- /dev/null +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -0,0 +1,51 @@ +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; +} + +function isActiveOrFailedCloudConnection(environment: ConnectedEnvironmentSummary): boolean { + return ( + environment.connectionError !== null || + (environment.connectionState !== "idle" && environment.connectionState !== "disconnected") + ); +} + +export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { + const advertisedCloudEnvironmentIds = new Set( + (input.cloudEnvironments ?? []).map((environment) => environment.environmentId), + ); + const savedEnvironmentIds = new Set( + input.connectedEnvironments + .filter( + (environment) => + !environment.isRelayManaged || + isActiveOrFailedCloudConnection(environment) || + !advertisedCloudEnvironmentIds.has(environment.environmentId), + ) + .map((environment) => environment.environmentId), + ); + + return { + localEnvironments: input.connectedEnvironments.filter( + (environment) => !environment.isRelayManaged, + ), + connectedCloudEnvironments: input.connectedEnvironments.filter( + (environment) => + environment.isRelayManaged && + (isActiveOrFailedCloudConnection(environment) || + !advertisedCloudEnvironmentIds.has(environment.environmentId)), + ), + availableCloudEnvironments: (input.cloudEnvironments ?? []).filter( + (environment) => !savedEnvironmentIds.has(environment.environmentId), + ), + }; +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 00e4582957c..871a940a38e 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 { 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"; @@ -17,7 +17,6 @@ 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 ──────────────────────────────────────────────────────────── */ @@ -29,6 +28,7 @@ interface HomeScreenProps { readonly savedConnectionsById: Readonly>; readonly searchQuery: string; readonly onAddConnection: () => void; + readonly onOpenEnvironments: () => void; readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; } @@ -167,27 +167,13 @@ 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 environmentLabel: string | null; readonly onPress: () => void; + readonly disabled?: boolean; readonly isLast: boolean; }) { const separatorColor = useThemeColor("--color-separator"); @@ -195,18 +181,16 @@ 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 })}> + ({ opacity: props.disabled ? 0.48 : pressed ? 0.7 : 1 })} + > - {/* Branch + git info */} - {branch ? ( + {/* Environment + branch */} + {subtitleParts.length > 0 ? ( - {branch} + {subtitleParts.join(" · ")} - {gitParts.length > 0 ? ( - - {" · " + gitParts.join(" · ")} - - ) : null} ) : null} @@ -292,8 +271,58 @@ function ThreadRow(props: { /* ─── Main screen ────────────────────────────────────────────────────── */ +function staleCatalogPillLabel(props: { readonly catalogState: RemoteCatalogState }): string { + 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: RemoteCatalogState; + 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) => { @@ -350,77 +379,95 @@ export function HomeScreen(props: HomeScreenProps) { /* Empty states */ const hasAnyThreads = props.threads.length > 0; const hasResults = filteredThreads.length > 0; + const isShowingUnavailableCachedData = + props.catalogState.isUsingCachedData && !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} + /> + ))} + + + ); + }) + )} + + {isShowingUnavailableCachedData ? ( + + - {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/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index a7423966f67..d7a3fa38d7d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -28,19 +28,16 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; +import { useMobileActions } from "../../connection/useMobileEnvironmentData"; 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 { useSourceControlDiscovery } from "../../state/use-source-control-discovery"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -342,11 +339,6 @@ export function AddProjectSourceScreen() { [discoveryState.data], ); - useEffect(() => { - if (!selectedEnvironment) return; - void refreshSourceControlDiscoveryForEnvironment(selectedEnvironment.environmentId); - }, [selectedEnvironment]); - return ( {environmentOptions.length === 0 ? : null} @@ -435,13 +427,12 @@ export function AddProjectSourceScreen() { function useCreateProject(environment: EnvironmentOption | null) { const router = useRouter(); + const actions = useMobileActions(); const { projects } = useRemoteCatalog(); 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 +453,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 actions.projects.create({ + environmentId: environment.environmentId, + input: command, + }); router.replace({ pathname: "/new/draft", params: { @@ -479,7 +472,7 @@ function useCreateProject(environment: EnvironmentOption | null) { }, }); }, - [environment, projects, router], + [actions.projects, environment, projects, router], ); } @@ -495,6 +488,7 @@ function useEnvironmentFromParam(): EnvironmentOption | null { } export function AddProjectRepositoryScreen() { + const actions = useMobileActions(); const router = useRouter(); const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); const environment = useEnvironmentFromParam(); @@ -523,11 +517,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 actions.sourceControl.lookupRepository({ + environmentId: environment.environmentId, + input: { + provider, + repository: repositoryInput.trim(), + }, }); router.push({ pathname: "/new/add-project/destination", @@ -543,7 +538,7 @@ export function AddProjectRepositoryScreen() { } finally { setIsSubmitting(false); } - }, [environment, isSubmitting, repositoryInput, router, source]); + }, [actions.sourceControl, environment, isSubmitting, repositoryInput, router, source]); return ( @@ -725,6 +720,7 @@ export function AddProjectLocalFolderScreen() { } export function AddProjectDestinationScreen() { + const actions = useMobileActions(); const params = useLocalSearchParams<{ environmentId?: string; remoteUrl?: string; @@ -760,11 +756,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 actions.sourceControl.cloneRepository({ + environmentId: environment.environmentId, + input: { + remoteUrl, + destinationPath: resolved.path, + }, }); await createProject(result.cwd); } catch (nextError) { @@ -772,7 +769,7 @@ export function AddProjectDestinationScreen() { } finally { setIsSubmitting(false); } - }, [createProject, environment, isSubmitting, pathInput, remoteUrl]); + }, [actions.sourceControl, createProject, environment, isSubmitting, pathInput, remoteUrl]); return ( diff --git a/apps/mobile/src/features/review/reviewDiffPreviewState.ts b/apps/mobile/src/features/review/reviewDiffPreviewState.ts index d0f85cd6d89..7bd58aa4fd7 100644 --- a/apps/mobile/src/features/review/reviewDiffPreviewState.ts +++ b/apps/mobile/src/features/review/reviewDiffPreviewState.ts @@ -1,110 +1,13 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId, ReviewDiffPreviewResult } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; -import { appAtomRegistry } from "../../state/atom-registry"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; - -const REVIEW_DIFF_PREVIEW_STALE_TIME_MS = 5_000; -const REVIEW_DIFF_PREVIEW_IDLE_TTL_MS = 5 * 60_000; -const REVIEW_DIFF_PREVIEW_KEY_SEPARATOR = "\u001f"; - -export interface ReviewDiffPreviewState { - readonly data: ReviewDiffPreviewResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => 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."; -} +import { useMobileReviewPreview } from "../../connection/mobileAppQueries"; 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, - }; +}) { + return useMobileReviewPreview({ + environmentId: input.environmentId ?? null, + cwd: input.cwd, + }); } diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 4c5a1abffb1..23059d14c5a 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,11 +1,10 @@ -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 { useMobileCheckpointDiff } from "../../connection/mobileAppQueries"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useReviewDiffPreview } from "./reviewDiffPreviewState"; import { buildReviewSectionItems, @@ -17,8 +16,8 @@ import { setReviewAsyncError, setReviewGitSections, setReviewSelectedSectionId, - setReviewTurnDiffLoading, setReviewTurnDiff, + setReviewTurnDiffLoading, type ReviewCacheForThread, } from "./reviewState"; @@ -31,15 +30,7 @@ export function useReviewSections(input: { const selectedThread = useSelectedThreadDetail(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd }); - const refreshDiffPreview = diffPreview.refresh; 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 +42,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 +80,6 @@ export function useReviewSections(input: { () => getDefaultReviewSectionId(reviewSections), [reviewSections], ); - const hasReviewSections = reviewSections.length > 0; const selectedSectionIdExists = useMemo( () => reviewCache.selectedSectionId @@ -96,140 +88,65 @@ 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 = useMobileCheckpointDiff({ + environmentId: environmentId ?? null, + threadId: threadId ?? null, + fromTurnCount: activeCheckpoint ? Math.max(0, activeCheckpoint.checkpointTurnCount - 1) : null, + toTurnCount: activeCheckpoint?.checkpointTurnCount ?? 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]); - - const refreshSelectedSection = useCallback(async () => { - if (!selectedSection) { - return; + useEffect(() => { + if (reviewCache.threadKey && activeTurnDiff.error) { + setReviewAsyncError(reviewCache.threadKey, activeTurnDiff.error); } + }, [activeTurnDiff.error, reviewCache.threadKey]); - if (selectedSection.kind === "turn") { - const checkpoint = checkpointBySectionId[selectedSection.id]; - if (checkpoint) { - await loadTurnDiff(checkpoint, true); - } + const refreshSelectedSection = useCallback(async () => { + if (selectedSection?.kind === "turn") { + activeTurnDiff.refresh(); return; } - - refreshDiffPreview(); - }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]); + diffPreview.refresh(); + }, [activeTurnDiff, diffPreview, selectedSection?.kind]); const selectSection = useCallback( (sectionId: string) => { @@ -241,8 +158,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..5efef761847 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -1,18 +1,13 @@ 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 { useMobileActions } from "../../connection/useMobileEnvironmentData"; +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 +24,59 @@ const DEFAULT_TERMINAL_ROWS = 24; export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( props: ThreadTerminalPanelProps, ) { + const actions = useMobileActions(); 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 actions.terminal.write({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + data, + }, }); }, - [isRunning, props.environmentId, props.threadId, terminalId], + [actions.terminal, isRunning, props.environmentId, props.threadId, terminalId], ); const handleResize = useCallback( @@ -111,19 +86,22 @@ 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 actions.terminal.resize({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + cols: size.cols, + rows: size.rows, + }, }); }, [ + actions.terminal, isRunning, lastGridSize.cols, lastGridSize.rows, diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index e4ac3cc5c8b..afec2a05367 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,9 +1,4 @@ -import { - DEFAULT_TERMINAL_ID, - EnvironmentId, - type TerminalAttachStreamEvent, - ThreadId, -} from "@t3tools/contracts"; +import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { KnownTerminalSession } from "@t3tools/client-runtime"; import { SymbolView } from "expo-symbols"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; @@ -24,14 +19,12 @@ import { import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; +import { useMobileActions } from "../../connection/useMobileEnvironmentData"; 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"; @@ -44,11 +37,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,6 +150,7 @@ function pickRunningTerminalSessionForBootstrap( export function ThreadTerminalRouteScreen() { const router = useRouter(); + const actions = useMobileActions(); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const { isLoadingSavedConnection } = useRemoteEnvironmentState(); const params = useLocalSearchParams<{ @@ -189,6 +182,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 +232,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 +249,76 @@ 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 && + !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, + 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 +390,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 +486,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 actions.terminal.write({ 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, + actions.terminal, + launchTargetKey, + pendingLaunch?.initialInput, selectedThread, - selectedThreadProject?.workspaceRoot, + terminal.version, terminalId, ]); - 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 +661,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 actions.terminal.write({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + data, + }, }); }, - [isRunning, selectedThread, terminalId], + [actions.terminal, isRunning, selectedThread, terminalId], ); const handleInput = useCallback( @@ -782,19 +728,18 @@ 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 actions.terminal.resize({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + cols: size.cols, + rows: size.rows, + }, }); }, [ + actions.terminal, isRunning, lastGridSize.cols, lastGridSize.rows, @@ -855,17 +800,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 actions.terminal.clear({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + }, }); - }, [selectedThread, terminalId]); + }, [actions.terminal, selectedThread, terminalId]); const handleToolbarActionPress = useCallback( (action: TerminalToolbarAction) => { diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e267ae9a07f..ac0725a1e91 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -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,6 +82,8 @@ export interface ThreadComposerProps { readonly placeholder: string; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; + readonly connectionError: string | null; + readonly environmentLabel: string | null; readonly selectedThread: OrchestrationThread; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; @@ -96,6 +99,7 @@ export interface ThreadComposerProps { 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,58 @@ 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: "disconnected" | "reconnecting"; readonly label: string } | null { + const environmentLabel = input.environmentLabel ?? "Environment"; + + if (input.connectionError !== null) { + return { kind: "disconnected", label: `${environmentLabel} is disconnected` }; + } + + switch (input.connectionState) { + case "connecting": + case "reconnecting": + return { kind: "reconnecting", label: `Reconnecting to ${environmentLabel}...` }; + case "disconnected": + case "idle": + return { kind: "disconnected", label: `${environmentLabel} is disconnected` }; + case "ready": + return null; + } +} + +const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { + readonly onPress: () => void; + readonly status: { readonly kind: "disconnected" | "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"); @@ -196,6 +252,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer 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(() => { @@ -652,6 +713,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + {connectionStatus ? ( + + ) : null} + ; @@ -69,6 +70,7 @@ export interface ThreadDetailScreenProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => Promise; readonly onSendMessage: () => void; + readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise; readonly onUpdateThreadInteractionMode: ( @@ -302,6 +304,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread draftAttachments={props.draftAttachments} placeholder="Ask the repo agent, or run a command…" connectionState={props.connectionStateLabel} + connectionError={props.connectionError} + environmentLabel={props.environmentLabel} selectedThread={props.selectedThread} serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} @@ -315,6 +319,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onRemoveDraftImage={props.onRemoveDraftImage} onStopThread={props.onStopThread} onSendMessage={props.onSendMessage} + onReconnectEnvironment={props.onReconnectEnvironment} onUpdateModelSelection={props.onUpdateThreadModelSelection} onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index fd73a45b0c8..9d6f5e6a2dd 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -18,6 +18,7 @@ import { connectionTone } from "../connection/connectionTone"; import { useRemoteCatalog } from "../../state/use-remote-catalog"; import { + useRemoteConnections, useRemoteConnectionStatus, useRemoteEnvironmentState, } from "../../state/use-remote-environment-registry"; @@ -58,10 +59,9 @@ function OpeningThreadLoadingScreen() { } export function ThreadRouteScreen() { - const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } = - useRemoteEnvironmentState(); - const { connectionState, connectionError: aggregateConnectionError } = - useRemoteConnectionStatus(); + const { isLoadingSavedConnection, environmentStateById } = useRemoteEnvironmentState(); + const { connectionState } = useRemoteConnectionStatus(); + const { onReconnectEnvironment } = useRemoteConnections(); const { projects, threads } = useRemoteCatalog(); const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); @@ -86,9 +86,9 @@ export function ThreadRouteScreen() { const routeEnvironmentRuntime = environmentId ? (environmentStateById[environmentId] ?? null) : null; - const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? connectionState; - const routeConnectionError = - pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; + const routeConnectionState = + routeEnvironmentRuntime?.connectionState ?? (environmentId ? "idle" : connectionState); + const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; /* ─── Native header theming ──────────────────────────────────────── */ const isDark = useColorScheme() === "dark"; @@ -114,6 +114,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( @@ -354,6 +360,7 @@ export function ThreadRouteScreen() { selectedThread={selectedThreadDetail} screenTone={connectionTone(routeConnectionState)} connectionError={routeConnectionError} + environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null} httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null} bearerToken={selectedEnvironmentConnection?.bearerToken ?? null} selectedThreadFeed={composer.selectedThreadFeed} @@ -380,6 +387,7 @@ export function ThreadRouteScreen() { serverConfig={serverConfig} onStopThread={commands.onStopThread} onSendMessage={composer.onSendMessage} + onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection} onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode} onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode} 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..012def73fe5 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -22,7 +22,7 @@ import { setComposerDraftText, useComposerDraft, } from "../../state/use-composer-drafts"; -import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs"; +import { useVcsRefs } from "../../state/use-vcs-refs"; import { useRemoteCatalog } from "../../state/use-remote-catalog"; import { setPendingConnectionError, @@ -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/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 029e1bbdcf6..3f079708170 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -1,67 +1,24 @@ import { useCallback } from "react"; -import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; +import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime"; 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 { useMobileActions } from "../../connection/useMobileEnvironmentData"; 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 { uuidv4 } from "../../lib/uuid"; 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 { setPendingConnectionError } from "../../state/use-remote-environment-registry"; function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -74,8 +31,8 @@ function deriveThreadTitleFromPrompt(value: string): string { } export function useProjectActions() { + const actions = useMobileActions(); const { threads } = useRemoteCatalog(); - const refreshRemoteData = useRefreshRemoteData(); const onCreateThreadWithOptions = useCallback( async (input: { @@ -89,14 +46,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,53 +59,53 @@ 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 actions.threads.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], + [actions.threads], ); const onCreateThread = useCallback( @@ -186,77 +137,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/connection.ts b/apps/mobile/src/lib/connection.ts index aa92c6f5d58..1240a456b0e 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -1,19 +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"; export { mobileAuthClientMetadata } from "./authClientMetadata"; -export interface RemoteConnectionInput { - readonly pairingUrl: string; -} - export interface SavedRemoteConnection { readonly environmentId: EnvironmentId; readonly environmentLabel: string; @@ -59,38 +48,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/runtime.ts b/apps/mobile/src/lib/runtime.ts index ce37a41e8ab..1f6dee3c13e 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,5 +1,6 @@ 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"; @@ -14,12 +15,15 @@ function configuredRelayUrl(): string { const mobileHttpClientLayer = 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 mobileRuntimeLayer = Layer.merge( + mobileManagedRelayClientLayer(configuredRelayUrl()), + Socket.layerWebSocketConstructorGlobal, +).pipe( + Layer.provideMerge(mobileCryptoLayer), + Layer.provideMerge(mobileHttpClientLayer), + Layer.provideMerge(mobileTracingLayer.pipe(Layer.provide(mobileHttpClientLayer))), ); +export const mobileRuntime = ManagedRuntime.make(mobileRuntimeLayer); + export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect); 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/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts index 054203715bd..4fe5290d220 100644 --- a/apps/mobile/src/state/remote-runtime-types.ts +++ b/apps/mobile/src/state/remote-runtime-types.ts @@ -1,8 +1,4 @@ -import type { - EnvironmentConnection, - EnvironmentConnectionState, - WsRpcClient, -} from "@t3tools/client-runtime"; +import type { EnvironmentConnectionState } from "@t3tools/client-runtime"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; export type { EnvironmentRuntimeState } from "@t3tools/client-runtime"; @@ -14,14 +10,10 @@ export interface ConnectedEnvironmentSummary { readonly isRelayManaged: boolean; readonly connectionState: EnvironmentConnectionState; 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/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-path-search.ts b/apps/mobile/src/state/use-composer-path-search.ts index a42143a427b..780cb5806e3 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"; -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; +import { useMobileComposerPathSearch } from "../connection/mobileAppQueries"; -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 useMobileComposerPathSearch(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 index e5ab77a80af..4a33a767e3c 100644 --- a/apps/mobile/src/state/use-filesystem-browse.ts +++ b/apps/mobile/src/state/use-filesystem-browse.ts @@ -1,82 +1,10 @@ -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 type { EnvironmentId, FilesystemBrowseInput } from "@t3tools/contracts"; -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(); -} +import { useMobileFilesystemDirectory } from "../connection/mobileAppQueries"; 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; +) { + return useMobileFilesystemDirectory(environmentId, input); } diff --git a/apps/mobile/src/state/use-remote-catalog.ts b/apps/mobile/src/state/use-remote-catalog.ts index 8a5ddac2c0f..52219369539 100644 --- a/apps/mobile/src/state/use-remote-catalog.ts +++ b/apps/mobile/src/state/use-remote-catalog.ts @@ -1,72 +1,7 @@ -import { useMemo } from "react"; -import * as Order from "effect/Order"; -import * as Arr from "effect/Array"; +import type { EnvironmentConnectionState } from "@t3tools/client-runtime"; -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; -} +import type { MobileWorkspaceState } from "../connection/mobileWorkspaceModel"; +import { useMobileWorkspace } from "../connection/useMobileWorkspace"; export interface RemoteCatalogState { readonly isLoadingSavedConnections: boolean; @@ -75,6 +10,7 @@ export interface RemoteCatalogState { readonly hasPendingShellSnapshot: boolean; readonly hasReadyEnvironment: boolean; readonly hasConnectingEnvironment: boolean; + readonly connectingEnvironments: MobileWorkspaceState["connectingEnvironments"]; readonly connectionState: EnvironmentConnectionState; readonly connectionError: string | null; readonly shellSnapshotError: string | null; @@ -83,116 +19,29 @@ export interface RemoteCatalogState { } 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, - ]); + const workspace = useMobileWorkspace(); + const state: RemoteCatalogState = { + isLoadingSavedConnections: workspace.state.isLoadingConnections, + hasSavedConnections: workspace.state.hasConnections, + hasLoadedShellSnapshot: workspace.state.hasLoadedShellSnapshot, + hasPendingShellSnapshot: workspace.state.hasPendingShellSnapshot, + hasReadyEnvironment: workspace.state.hasReadyEnvironment, + hasConnectingEnvironment: workspace.state.hasConnectingEnvironment, + connectingEnvironments: workspace.state.connectingEnvironments, + connectionState: workspace.state.connectionState, + connectionError: workspace.state.connectionError, + shellSnapshotError: workspace.state.shellSnapshotError, + isUsingCachedData: workspace.state.isUsingCachedData, + latestCachedSnapshotReceivedAt: workspace.state.latestCachedSnapshotReceivedAt, + }; return { - projects, - threads, - serverConfigByEnvironmentId, + projects: workspace.projects, + threads: workspace.threads, + serverConfigByEnvironmentId: Object.fromEntries(workspace.serverConfigByEnvironmentId), connectionState: state.connectionState, connectionError: state.connectionError, state, - hasRemoteActivity, + hasRemoteActivity: workspace.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..f2dc41fbe8b 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,90 +1,15 @@ 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 { EnvironmentConnectionState, EnvironmentRuntimeState } 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 { Atom } from "effect/unstable/reactivity"; -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"; -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; -} +import { useCallback, useMemo } from "react"; +import { Alert } from "react-native"; -const isLoadingSavedConnectionAtom = Atom.make(true).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:is-loading-saved-connection"), -); +import { useMobileConnectionController } from "../connection/useMobileConnectionController"; +import { useMobileWorkspace } from "../connection/useMobileWorkspace"; +import type { SavedRemoteConnection } from "../lib/connection"; +import { appAtomRegistry } from "./atom-registry"; +import type { ConnectedEnvironmentSummary } from "./remote-runtime-types"; const connectionPairingUrlAtom = Atom.make("").pipe( Atom.keepAlive, @@ -96,679 +21,168 @@ 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 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, - }); -} - -function removeSavedConnection(environmentId: EnvironmentId): void { - const current = appAtomRegistry.get(savedConnectionsByIdAtom); - const next = { ...current }; - delete next[environmentId]; - appAtomRegistry.set(savedConnectionsByIdAtom, next); -} - -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], - ); -} - -function setEnvironmentConnectionStatus( - environmentId: EnvironmentId, - state: ConnectedEnvironmentSummary["connectionState"], - error?: string | null, -) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionState: state, - connectionError: error === undefined ? current.connectionError : error, - })); -} +function toSavedConnection( + environment: ReturnType["environments"][number], +): SavedRemoteConnection { + const displayUrl = environment.displayUrl; + const wsBaseUrl = displayUrl.startsWith("https://") + ? displayUrl.replace(/^https:/, "wss:") + : displayUrl.replace(/^http:/, "ws:"); -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, - }), - ); + 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, } - }, - }); - - 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(); - }); -} - -export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void { - const now = Date.now(); - if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) { - return; - } - - 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, - }); - }); - } + : { authenticationMethod: "bearer" as const }), + }; } -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(); +function toRuntimeState( + environment: ReturnType["environments"][number], + serverConfig: ReturnType["serverConfigByEnvironmentId"], +): EnvironmentRuntimeState { + return { + connectionState: environment.connectionState, + connectionError: environment.connectionError, + connectionErrorTraceId: environment.connectionErrorTraceId, + serverConfig: serverConfig.get(environment.environmentId) ?? null, + }; } -const environmentsSortOrder = Order.mapInput( - Order.Struct({ - environmentLabel: Order.String, - }), - (environment: ConnectedEnvironmentSummary) => ({ +function toConnectedEnvironment( + environment: ReturnType["environments"][number], +): ConnectedEnvironmentSummary { + return { + environmentId: environment.environmentId, 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, - ); -} - -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(); - }; - }, []); + displayUrl: environment.displayUrl, + isRelayManaged: environment.isRelayManaged, + connectionState: environment.connectionState, + connectionError: environment.connectionError, + connectionErrorTraceId: environment.connectionErrorTraceId, + }; } export function useRemoteEnvironmentState() { - const state = useRemoteEnvironmentLocalState(); - const environmentStateById = useEnvironmentRuntimeStates( - Object.values(state.savedConnectionsById).map((connection) => connection.environmentId), + const workspace = useMobileWorkspace(); + const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); + const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); + const savedConnectionsById = useMemo( + () => + Object.fromEntries( + workspace.environments.map((environment) => [ + environment.environmentId, + toSavedConnection(environment), + ]), + ) as Record, + [workspace.environments], ); - - return useMemo( - () => ({ - ...state, - environmentStateById, - }), - [environmentStateById, state], + const environmentStateById = useMemo( + () => + Object.fromEntries( + workspace.environments.map((environment) => [ + environment.environmentId, + toRuntimeState(environment, workspace.serverConfigByEnvironmentId), + ]), + ) as Record, + [workspace.environments, workspace.serverConfigByEnvironmentId], ); + + return { + isLoadingSavedConnection: workspace.state.isLoadingConnections, + connectionPairingUrl, + pendingConnectionError, + savedConnectionsById, + environmentStateById, + }; } export function useRemoteConnectionStatus() { - const { environmentStateById, pendingConnectionError, savedConnectionsById } = - useRemoteEnvironmentState(); - + const workspace = useMobileWorkspace(); + const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); 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( - () => - pipe( - Arr.appendAll( - [pendingConnectionError], - Arr.map(connectedEnvironments, (environment) => environment.connectionError), - ), - Arr.findFirst((value) => value !== null), - Option.getOrNull, - ), - [connectedEnvironments, pendingConnectionError], + () => workspace.environments.map(toConnectedEnvironment), + [workspace.environments], ); return { connectedEnvironments, - connectionState, - connectionError, + connectionState: workspace.state.connectionState as EnvironmentConnectionState, + connectionError: pendingConnectionError ?? workspace.state.connectionError, }; } export function useRemoteConnections() { + const controller = useMobileConnectionController(); const { connectionPairingUrl, pendingConnectionError } = useRemoteEnvironmentState(); 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 +191,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..aa567ac3789 100644 --- a/apps/mobile/src/state/use-selected-thread-commands.ts +++ b/apps/mobile/src/state/use-selected-thread-commands.ts @@ -1,16 +1,12 @@ 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 { useMobileActions } from "../connection/useMobileEnvironmentData"; import { useThreadSelection } from "./use-thread-selection"; export function useSelectedThreadCommands(input: { @@ -19,43 +15,15 @@ export function useSelectedThreadCommands(input: { readonly cwd?: string | null; }) => Promise; }) { + const actions = useMobileActions(); 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 +31,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 actions.threads.updateMetadata({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + modelSelection, + }, }); }, - [selectedThread], + [actions.threads, selectedThread], ); const onUpdateThreadRuntimeMode = useCallback( @@ -84,20 +48,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 actions.threads.setRuntimeMode({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + runtimeMode, + }, }); }, - [selectedThread], + [actions.threads, selectedThread], ); const onUpdateThreadInteractionMode = useCallback( @@ -106,20 +65,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 actions.threads.setInteractionMode({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + interactionMode, + }, }); }, - [selectedThread], + [actions.threads, selectedThread], ); const onStopThread = useCallback(async () => { @@ -127,11 +81,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 +88,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 actions.threads.interruptTurn({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + ...(selectedThread.session?.activeTurnId + ? { turnId: selectedThread.session.activeTurnId } + : {}), + }, }); - }, [selectedThread]); + }, [actions.threads, selectedThread]); const onRenameThread = useCallback( async (title: string) => { @@ -156,24 +105,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 actions.threads.updateMetadata({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + title: trimmed, + }, }); }, - [selectedThread], + [actions.threads, selectedThread], ); 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..641bccc23ed 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,46 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { EnvironmentScopedProjectShell, EnvironmentScopedThreadShell, - type VcsRef, type GitActionRequestInput, + type VcsActionOperation, + type VcsRef, } from "@t3tools/client-runtime"; -import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts"; +import type { GitRunStackedActionResult } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; +import { useMobileActions } from "../connection/useMobileEnvironmentData"; 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 { useVcsRefs } from "./use-vcs-refs"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; export function useSelectedThreadGitActions() { + const actions = useMobileActions(); 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 = useVcsRefs(branchTarget); const updateThreadGitContext = useCallback( async ( thread: NonNullable, @@ -35,20 +49,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 actions.threads.updateMetadata({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), + ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), + }, }); }, - [], + [actions.threads], ); const refreshSelectedThreadGitStatus = useCallback( @@ -62,61 +72,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 actions.vcs.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], + [actions.vcs, selectedThread, selectedThreadCwd, selectedThreadProject], ); useEffect(() => { if (!selectedThread || !selectedThreadProject) { return; } - void refreshSelectedThreadGitStatus({ quiet: true }); }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]); const runSelectedThreadGitMutation = useCallback( async ( - operation: (input: { + operation: VcsActionOperation, + label: string, + execute: (input: { readonly thread: EnvironmentScopedThreadShell; readonly project: EnvironmentScopedProjectShell; 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 +148,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 cwd: string; - readonly branchRootCwd?: string | null; readonly nextThreadState?: { readonly branch?: string | null; readonly worktreePath?: string | null; @@ -166,104 +166,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 actions.vcs.switchRef({ + environmentId: thread.environmentId, + input: { cwd, refName: branch }, + }); + await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.refName ?? thread.branch, + worktreePath: selectedThreadWorktreePath, + }, + }); + }, + ); }, - [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], + [ + actions.vcs, + runSelectedThreadGitMutation, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + ], ); 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 actions.vcs.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], + [ + actions.vcs, + runSelectedThreadGitMutation, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + ], ); 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 actions.vcs.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], + [actions.vcs, 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 actions.vcs.pull({ + environmentId: thread.environmentId, + input: { cwd }, + }); + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); showGitActionResult({ type: "success", title: @@ -271,57 +276,60 @@ export function useSelectedThreadGitActions() { ? "Already up to date" : `Pulled latest on ${result.refName}`, }); - } - }); - }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); + }, + ); + }, [actions.vcs, 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 actions.git.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; + }, + ); }, [ + actions.git, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, selectedThreadWorktreePath, diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 232135b6a7e..19d49c5abb8 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 { 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 { useMobileActions } from "../connection/useMobileEnvironmentData"; 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,7 @@ function setUserInputDraftCustomAnswer( } export function useSelectedThreadRequests() { + const actions = useMobileActions(); const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThread = useSelectedThreadDetail(); const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); @@ -112,26 +112,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 actions.threads.respondToApproval({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + requestId, + decision, + }, }); } finally { setRespondingApprovalId((current) => (current === requestId ? null : current)); } }, - [selectedThreadShell], + [actions.threads, selectedThreadShell], ); const onSubmitUserInput = useCallback(async () => { @@ -139,27 +134,22 @@ 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 actions.threads.respondToUserInput({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + requestId: activePendingUserInput.requestId, + answers: activePendingUserInputAnswers, + }, }); } finally { setRespondingUserInputId((current) => current === activePendingUserInput.requestId ? null : current, ); } - }, [activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]); + }, [actions.threads, activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]); return { activePendingApproval, diff --git a/apps/mobile/src/state/use-source-control-discovery.ts b/apps/mobile/src/state/use-source-control-discovery.ts index 8f206be2cee..96edb637eba 100644 --- a/apps/mobile/src/state/use-source-control-discovery.ts +++ b/apps/mobile/src/state/use-source-control-discovery.ts @@ -1,78 +1,7 @@ -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 type { EnvironmentId } from "@t3tools/contracts"; -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; +import { useMobileSourceControlCapabilities } from "../connection/mobileAppQueries"; -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; +export function useSourceControlDiscovery(environmentId: EnvironmentId | null) { + return useMobileSourceControlCapabilities(environmentId); } diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 9ea13eef3e3..6debe644a82 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -1,84 +1,70 @@ -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"; +import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; import { useMemo } from "react"; -import { appAtomRegistry } from "./atom-registry"; - -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 } : {}), - }); -} +import { + useMobileTerminalAttach, + useMobileTerminalMetadata, +} from "../connection/useMobileEnvironmentData"; -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 = useMobileTerminalAttach( + input.environmentId !== null && input.terminal !== null + ? { + environmentId: input.environmentId, + input: input.terminal, + } + : null, ); -} + const metadata = useMobileTerminalMetadata(input.environmentId); -export function useTerminalSessionTarget(input: TerminalSessionTarget) { - return useMemo( - () => ({ - environmentId: input.environmentId, - threadId: input.threadId, - terminalId: input.terminalId, - }), - [input.environmentId, input.threadId, input.terminalId], - ); + 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 = useMobileTerminalMetadata(input.environmentId); + 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..1620271b917 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -6,6 +6,7 @@ import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tool import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { Atom } from "effect/unstable/reactivity"; +import { useMobileActions } from "../connection/useMobileEnvironmentData"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { convertPastedImagesToAttachments, @@ -26,7 +27,6 @@ import { setComposerDraftText, useComposerDraft, } from "./use-composer-drafts"; -import { getEnvironmentClient } from "./environment-session-registry"; import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types"; import { setPendingConnectionError, @@ -175,6 +175,7 @@ function useQueueDrain(input: { } export function useThreadComposerState() { + const actions = useMobileActions(); const { connectedEnvironments } = useRemoteConnectionStatus(); const { threads } = useRemoteCatalog(); const { selectedThread: selectedThreadShell } = useThreadSelection(); @@ -238,31 +239,32 @@ export function useThreadComposerState() { 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) { + if (!thread) { return; } 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 actions.threads.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( @@ -283,7 +285,7 @@ export function useThreadComposerState() { finishDispatchingQueuedMessage(queuedMessage.messageId); } }, - [threads], + [actions.threads, threads], ); useQueueDrain({ diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 900dbd648b5..41e20dd825d 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -1,82 +1,31 @@ -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 * as Option from "effect/Option"; -import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity"; -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; +import { useMobileEnvironmentThread } from "../connection/useMobileEnvironmentData"; 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 - ); -} - -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 = useMobileEnvironmentThread(target.environmentId, target.threadId); + if (target.environmentId === null || target.threadId === null) { + return EMPTY_THREAD_DETAIL_STATE; + } - const state = useAtomValue( - targetKey !== null ? threadDetailStateAtom(targetKey) : EMPTY_THREAD_DETAIL_ATOM, - ); - return targetKey === null ? EMPTY_THREAD_DETAIL_STATE : state; + return { + data: Option.getOrNull(state.data), + error: Option.getOrNull(state.error), + isPending: state.status === "synchronizing", + isDeleted: state.status === "deleted", + }; } export function useSelectedThreadDetail() { const { selectedThread } = useThreadSelection(); - const state = useThreadDetail({ + return useThreadDetail({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, - }); - - return useMemo(() => state.data, [state.data]); + }).data; } diff --git a/apps/mobile/src/state/use-vcs-action-state.ts b/apps/mobile/src/state/use-vcs-action-state.ts index 64e4da958ef..bd95ac0e562 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"; +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 { useMobileRunStackedGitActionState } from "../connection/useMobileEnvironmentData"; import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; -export const vcsActionManager = createVcsActionManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; +function setVcsActionState(target: VcsActionTarget, state: VcsActionState): void { + const targetKey = getVcsActionTargetKey(target); + if (targetKey !== null) { + appAtomRegistry.set(vcsActionStateAtom(targetKey), state); + } +} + +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 = useMobileRunStackedGitActionState(); 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 index 3af3a6e945e..0b49824215f 100644 --- a/apps/mobile/src/state/use-vcs-refs.ts +++ b/apps/mobile/src/state/use-vcs-refs.ts @@ -1,51 +1,13 @@ -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; +import type { VcsRefTarget } from "@t3tools/client-runtime"; + +import { useMobileBranches } from "../connection/mobileAppQueries"; + +export function useVcsRefs(target: VcsRefTarget) { + const state = useMobileBranches(target); + return { + data: state.data, + isPending: state.isPending, + error: state.error, + refresh: state.refresh, + }; } diff --git a/apps/mobile/src/state/use-vcs-status.ts b/apps/mobile/src/state/use-vcs-status.ts index e7d7049d332..633a6d9dcdf 100644 --- a/apps/mobile/src/state/use-vcs-status.ts +++ b/apps/mobile/src/state/use-vcs-status.ts @@ -1,62 +1,13 @@ -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; +import type { VcsStatusTarget } from "@t3tools/client-runtime"; + +import { useMobileRepositoryStatus } from "../connection/mobileAppQueries"; + +export function useVcsStatus(target: VcsStatusTarget) { + const state = useMobileRepositoryStatus(target); + return { + data: state.data, + error: state.error, + cause: null, + isPending: state.isPending, + }; } 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..26b9733c91a 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -1,911 +1,286 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type RelayClientInstallProgressEvent } 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 { HttpClient } from "effect/unstable/http"; +import { afterEach, beforeEach, vi } from "vite-plus/test"; import { + EnvironmentRegistry, managedRelayClientLayer, ManagedRelayClient, ManagedRelayDpopSigner, remoteHttpClientLayer, + type EnvironmentRuntimeService, } from "@t3tools/client-runtime"; -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, +function registryLayer(options?: { + readonly status?: { readonly status: "available"; readonly version: string }; + readonly installEvents?: ReadonlyArray; +}) { + const runtime = { + operations: { + cloud: { + getRelayClientStatus: () => + Effect.succeed(options?.status ?? { status: "available", version: "2026.6.0" }), + installRelayClient: () => Stream.fromIterable(options?.installEvents ?? []), + }, }, - }), -})); - -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"; + } as unknown as EnvironmentRuntimeService; + const service = { + withRuntime: ( + _environmentId: EnvironmentId, + use: (runtime: EnvironmentRuntimeService) => Effect.Effect, + ) => use(runtime), + } as unknown as EnvironmentRegistry["Service"]; + return Layer.succeed(EnvironmentRegistry, EnvironmentRegistry.of(service)); } -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", () => - 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", () => + 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(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..83c9118b15a 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, @@ -13,34 +15,22 @@ import { EnvironmentId, } 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, + EnvironmentRegistry, makeEnvironmentHttpApiClient, ManagedRelayClient, - ManagedRelayDpopSigner, - type WsRpcClient, + type ManagedRelayClientError, } from "@t3tools/client-runtime"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; -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 +55,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 +65,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 + .withRuntime(environmentId, (runtime) => runtime.operations.cloud.getRelayClientStatus()) + .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 +89,34 @@ 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 + .withRuntime(environmentId, (runtime) => { + return runtime.operations.cloud.installRelayClient({}).pipe( + Stream.tap((event) => Effect.sync(() => reportRelayClientInstallProgress(event))), + Stream.runLast, + ); + }) + .pipe( + 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/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index f34ad2f9c99..5e36742d855 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -39,19 +39,23 @@ 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), - ), }); }), ); diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index a31ee9e16f3..038d6fd1426 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -13,7 +13,7 @@ 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 { appAtomRegistry } from "../rpc/atomRegistry"; @@ -44,6 +44,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 +60,7 @@ export function useManagedRelayEnvironments() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; @@ -62,6 +71,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 +87,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..af7b1e0ee07 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,12 +7,12 @@ 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 { useWebPrimaryEnvironment } from "../connection/useWebEnvironments"; import { webRuntime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { readPrimaryCloudLinkState } from "./linkEnvironment"; +import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; const primaryCloudLinkAtomRuntime = Atom.runtime( Layer.effect( @@ -23,35 +23,52 @@ const primaryCloudLinkAtomRuntime = Atom.runtime( ), ); -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 = useWebPrimaryEnvironment(); + 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/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 152df1bf3e5..20a1b50a5fe 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -14,10 +14,8 @@ 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 { useWebPaginatedBranches, useWebRepositoryStatus } from "../connection/webAppQueries"; +import { useWebActions } from "../connection/useWebEnvironmentData"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; @@ -58,8 +56,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 +87,7 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + const actions = useWebActions(); // --------------------------------------------------------------------------- // Thread / project state (pushed down from parent to colocate with mutation) // --------------------------------------------------------------------------- @@ -141,24 +138,22 @@ 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(), + if (serverSession && worktreePath !== activeWorktreePath) { + void actions.threads + .stopSession({ + 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 actions.threads.updateMetadata({ + environmentId, + input: { + threadId: activeThreadId, + branch, + worktreePath, + }, }); } if (hasServerThread) { @@ -181,6 +176,7 @@ export function BranchToolbarBranchSelector({ [ activeThreadId, activeProject, + actions.threads, serverSession, activeWorktreePath, hasServerThread, @@ -201,7 +197,7 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd }); + const branchStatusQuery = useWebRepositoryStatus({ environmentId, cwd: branchCwd }); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); const branchRefTarget = useMemo( @@ -212,11 +208,11 @@ export function BranchToolbarBranchSelector({ }), [branchCwd, deferredTrimmedBranchQuery, environmentId], ); - const branchRefState = useVcsRefs(branchRefTarget); - const refs = branchRefState.data?.refs ?? EMPTY_REFS; + const branchRefState = useWebPaginatedBranches(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 +293,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 +332,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 actions.vcs.switchRef({ + environmentId, + input: { + cwd: selectionTarget.checkoutCwd, + refName: refName.name, + }, }); const nextBranchName = refName.isRemote ? (checkoutResult.refName ?? selectedBranchName) @@ -362,8 +359,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 +368,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 actions.vcs.createRef({ + environmentId, + input: { + cwd: branchCwd, + refName: name, + switchRef: true, + }, }); setOptimisticBranch(createBranchResult.refName); setThreadBranch(createBranchResult.refName, activeWorktreePath); @@ -414,11 +413,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 +424,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..400c120c13b 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,8 @@ import { } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; +import { useStore } from "../store"; +import { useWebServerConfig } from "../connection/useWebEnvironmentData"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -283,6 +285,7 @@ interface MarkdownFileLinkProps { filePath: string; label: string; theme: "light" | "dark"; + onOpen: (targetPath: string) => Promise; className?: string | undefined; } @@ -374,19 +377,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 +390,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 +503,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 +515,12 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const environmentId = useStore((state) => state.activeEnvironmentId); + const serverConfig = useWebServerConfig(environmentId); + const openInPreferredEditor = useOpenInPreferredEditor( + environmentId, + serverConfig.data?.availableEditors ?? [], + ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< @@ -576,6 +578,7 @@ function ChatMarkdown({ filePath={fileLinkMeta.filePath} label={labelParts.join(" · ")} theme={resolvedTheme} + onOpen={openInPreferredEditor} className={props.className} /> ); @@ -607,6 +610,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..039798cd727 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, @@ -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, @@ -113,7 +102,6 @@ const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as Thre 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}$/; @@ -239,38 +227,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; @@ -1745,9 +1701,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: {}, @@ -5135,132 +5088,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.tsx b/apps/web/src/components/ChatView.tsx index 7fc7669fe60..7856afec635 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -40,8 +40,6 @@ 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"; @@ -115,7 +113,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 +122,6 @@ import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { - reconnectSavedEnvironment, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -143,7 +136,18 @@ import { type TerminalContextSelection, } from "../lib/terminalContext"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; +import { + useWebKnownTerminalSessions, + useWebThreadRunningTerminalIds, +} from "../connection/webTerminalSessions"; +import { useWebActions } from "../connection/useWebEnvironmentData"; +import { useWebThreadDetail } from "../connection/webAppQueries"; +import { + useWebEnvironmentActions, + useWebEnvironmentHttpBaseUrl, + useWebEnvironments, + useWebPrimaryEnvironment, +} from "../connection/useWebEnvironments"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -151,7 +155,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"; @@ -179,13 +183,8 @@ import { } 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 { @@ -205,7 +204,7 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record; @@ -494,6 +493,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { + const actions = useWebActions(); const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const projectRef = serverThread @@ -505,7 +505,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), ); - const knownTerminalSessions = useKnownTerminalSessions({ + const knownTerminalSessions = useWebKnownTerminalSessions({ environmentId: threadRef.environmentId, threadId, }); @@ -618,8 +618,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,18 +626,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId(); void (async () => { try { - await api.terminal.open({ - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, + await actions.terminal.open({ + 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. } })(); }, [ + actions.terminal, bumpFocusRequestId, cwd, effectiveWorktreePath, @@ -650,8 +653,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ]); const createNewTerminal = useCallback(() => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); @@ -659,18 +661,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId(); void (async () => { try { - await api.terminal.open({ - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, + await actions.terminal.open({ + 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. } })(); }, [ + actions.terminal, bumpFocusRequestId, cwd, effectiveWorktreePath, @@ -691,31 +697,45 @@ 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); + actions.terminal + .write({ + environmentId: threadRef.environmentId, + input: { 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({ + void (async () => { + if (isFinalTerminal) { + await actions.terminal + .clear({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId }, + }) + .catch(() => undefined); + } + await actions.terminal.close({ + 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], + [ + actions.terminal, + bumpFocusRequestId, + storeCloseTerminal, + terminalUiState.terminalIds, + threadId, + threadRef, + ], ); const handleAddTerminalContext = useCallback( @@ -779,6 +799,19 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const actions = useWebActions(); + const { environments } = useWebEnvironments(); + const primaryEnvironment = useWebPrimaryEnvironment(); + const { retryEnvironment } = useWebEnvironmentActions(); + const environmentById = useMemo( + () => new Map(environments.map((environment) => [environment.environmentId, environment])), + [environments], + ); + const routeThreadDetail = useWebThreadDetail( + routeKind === "server" ? environmentId : null, + routeKind === "server" ? threadId : null, + ); + const environmentHttpBaseUrl = useWebEnvironmentHttpBaseUrl(environmentId); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useStore( @@ -788,6 +821,13 @@ export default function ChatView(props: ChatViewProps) { ), ); const setStoreThreadError = useStore((store) => store.setError); + useEffect(() => { + if (routeThreadDetail.data !== null) { + useStore + .getState() + .syncServerThreadDetail(routeThreadDetail.data, environmentId, environmentHttpBaseUrl); + } + }, [environmentHttpBaseUrl, environmentId, routeThreadDetail.data]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, @@ -969,11 +1009,11 @@ export default function ChatView(props: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; - const runningTerminalIds = useThreadRunningTerminalIds({ + const runningTerminalIds = useWebThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, }); - const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ + const activeThreadKnownSessionsRaw = useWebKnownTerminalSessions({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, }); @@ -1064,63 +1104,36 @@ export default function ChatView(props: ChatViewProps) { useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); - useEffect(() => { - if (routeKind !== "server") { - return; - } - return retainThreadDetailSubscription(environmentId, threadId); - }, [environmentId, routeKind, threadId]); - // 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 primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + const activeEnvironment = + activeThread === undefined ? 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 + activeEnvironmentConnectionPhase === "connecting" || + activeEnvironmentConnectionPhase === "reconnecting" || + activeEnvironmentConnectionPhase === "error" + ? activeEnvironmentConnectionPhase : "disconnected", }; }, [ + activeEnvironment, + activeEnvironmentConnectionPhase, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel, - activeSavedEnvironmentConnectionState, - activeSavedEnvironmentId, ]); const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState( null, @@ -1129,7 +1142,7 @@ export default function ChatView(props: ChatViewProps) { async (environmentId: EnvironmentId, label: string) => { setReconnectingEnvironmentId(environmentId); try { - await reconnectSavedEnvironment(environmentId); + await retryEnvironment(environmentId); toastManager.add({ type: "success", title: "Environment reconnected", @@ -1147,7 +1160,7 @@ export default function ChatView(props: ChatViewProps) { setReconnectingEnvironmentId(null); } }, - [], + [retryEnvironment], ); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const logicalProjectEnvironments = useMemo(() => { @@ -1167,14 +1180,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 +1194,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 +1334,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,27 +1348,17 @@ export default function ChatView(props: ChatViewProps) { isVersionMismatchDismissed(versionMismatchDismissKey); const showVersionMismatchBanner = versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; - const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0; + const hasMultipleRegisteredEnvironments = environments.length > 1; 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`; + return `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`; }, [ activeThread, + environmentById, hasMultipleRegisteredEnvironments, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, serverConfig?.environment.label, ]); const composerBannerItems = useMemo(() => { @@ -1395,7 +1374,9 @@ export default function ChatView(props: ChatViewProps) { {activeEnvironmentUnavailableState.label} is{" "} {activeEnvironmentUnavailableState.connectionState === "connecting" ? "connecting" - : "disconnected"} + : activeEnvironmentUnavailableState.connectionState === "reconnecting" + ? "reconnecting" + : "disconnected"} ), description: "Reconnect this environment before sending messages or running actions.", @@ -1405,6 +1386,7 @@ export default function ChatView(props: ChatViewProps) { size="xs" disabled={ activeEnvironmentUnavailableState.connectionState === "connecting" || + activeEnvironmentUnavailableState.connectionState === "reconnecting" || reconnectingEnvironmentId === activeEnvironmentUnavailableState.environmentId } onClick={() => @@ -1415,6 +1397,7 @@ export default function ChatView(props: ChatViewProps) { } > {activeEnvironmentUnavailableState.connectionState === "connecting" || + activeEnvironmentUnavailableState.connectionState === "reconnecting" || reconnectingEnvironmentId === activeEnvironmentUnavailableState.environmentId ? "Reconnecting..." : "Reconnect"} @@ -2012,30 +1995,30 @@ export default function ChatView(props: ChatViewProps) { if (!cwdForOpen) { return; } - const api = readEnvironmentApi(environmentId); - if (!api) { - return; - } const terminalId = nextTerminalId(activeKnownTerminalIds); storeSplitTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void (async () => { try { - await api.terminal.open({ - threadId: activeThreadId, - terminalId, - cwd: cwdForOpen, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.cwd }, - worktreePath: activeThreadWorktreePath, - }), + await actions.terminal.open({ + environmentId, + input: { + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. } })(); }, [ + actions.terminal, activeProject, activeKnownTerminalIds, activeThreadId, @@ -2054,30 +2037,30 @@ export default function ChatView(props: ChatViewProps) { if (!cwdForOpen) { return; } - const api = readEnvironmentApi(environmentId); - if (!api) { - return; - } const terminalId = nextTerminalId(activeKnownTerminalIds); storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void (async () => { try { - await api.terminal.open({ - threadId: activeThreadId, - terminalId, - cwd: cwdForOpen, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.cwd }, - worktreePath: activeThreadWorktreePath, - }), + await actions.terminal.open({ + environmentId, + input: { + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. } })(); }, [ + actions.terminal, activeProject, activeKnownTerminalIds, activeThreadId, @@ -2089,33 +2072,44 @@ export default function ChatView(props: ChatViewProps) { ]); const closeTerminal = useCallback( (terminalId: string) => { - const api = readEnvironmentApi(environmentId); - if (!activeThreadId || !api || !activeThreadRef) return; + if (!activeThreadId || !activeThreadRef) return; const isFinalTerminal = activeKnownTerminalIds.length <= 1; const fallbackExitWrite = () => - api.terminal - .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) + actions.terminal + .write({ + environmentId, + input: { threadId: activeThreadId, 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: activeThreadId, terminalId }) - .catch(() => undefined); - } - await api.terminal.close({ + void (async () => { + if (isFinalTerminal) { + await actions.terminal + .clear({ + environmentId, + input: { threadId: activeThreadId, terminalId }, + }) + .catch(() => undefined); + } + await actions.terminal.close({ + environmentId, + input: { threadId: activeThreadId, terminalId, deleteHistory: true, - }); - })().catch(() => fallbackExitWrite()); - } else { - void fallbackExitWrite(); - } + }, + }); + })().catch(() => fallbackExitWrite()); storeCloseTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeThreadId, activeThreadRef, activeKnownTerminalIds, environmentId, storeCloseTerminal], + [ + actions.terminal, + activeThreadId, + activeThreadRef, + activeKnownTerminalIds, + environmentId, + storeCloseTerminal, + ], ); const runProjectScript = useCallback( async ( @@ -2128,8 +2122,7 @@ export default function ChatView(props: ChatViewProps) { rememberAsLastInvoked?: boolean; }, ) => { - const api = readEnvironmentApi(environmentId); - if (!api || !activeThreadId || !activeProject || !activeThread) return; + if (!activeThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; @@ -2190,11 +2183,14 @@ export default function ChatView(props: ChatViewProps) { } try { - await api.terminal.open(openTerminalInput); - await api.terminal.write({ - threadId: activeThreadId, - terminalId: targetTerminalId, - data: `${script.command}\r`, + await actions.terminal.open({ environmentId, input: openTerminalInput }); + await actions.terminal.write({ + environmentId, + input: { + threadId: activeThreadId, + terminalId: targetTerminalId, + data: `${script.command}\r`, + }, }); } catch (error) { setThreadError( @@ -2204,6 +2200,7 @@ export default function ChatView(props: ChatViewProps) { } }, [ + actions.terminal, activeProject, activeThread, activeThreadId, @@ -2230,14 +2227,12 @@ export default function ChatView(props: ChatViewProps) { keybinding?: string | null; keybindingCommand: KeybindingCommand; }) => { - const api = readEnvironmentApi(environmentId); - if (!api) return; - - await api.orchestration.dispatchCommand({ - type: "project.meta.update", - commandId: newCommandId(), - projectId: input.projectId, - scripts: input.nextScripts, + await actions.projects.update({ + environmentId, + input: { + projectId: input.projectId, + scripts: input.nextScripts, + }, }); const keybindingRule = decodeProjectScriptKeybindingRule({ @@ -2246,14 +2241,13 @@ export default function ChatView(props: ChatViewProps) { }); if (isElectron && keybindingRule) { - const localApi = readLocalApi(); - if (!localApi) { - throw new Error("Local API unavailable."); - } - await localApi.server.upsertKeybinding(keybindingRule); + await actions.server.upsertKeybinding({ + environmentId, + input: keybindingRule, + }); } }, - [environmentId], + [actions.projects, actions.server, environmentId], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { @@ -2424,10 +2418,6 @@ export default function ChatView(props: ChatViewProps) { if (!serverThread) { return; } - const api = readEnvironmentApi(environmentId); - if (!api) { - return; - } if ( input.modelSelection !== undefined && @@ -2436,35 +2426,38 @@ export default function ChatView(props: ChatViewProps) { JSON.stringify(input.modelSelection.options ?? null) !== JSON.stringify(serverThread.modelSelection.options ?? null)) ) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: input.threadId, - modelSelection: input.modelSelection, + await actions.threads.updateMetadata({ + environmentId, + input: { + threadId: input.threadId, + modelSelection: input.modelSelection, + }, }); } if (input.runtimeMode !== serverThread.runtimeMode) { - await api.orchestration.dispatchCommand({ - type: "thread.runtime-mode.set", - commandId: newCommandId(), - threadId: input.threadId, - runtimeMode: input.runtimeMode, - createdAt: input.createdAt, + await actions.threads.setRuntimeMode({ + environmentId, + input: { + threadId: input.threadId, + runtimeMode: input.runtimeMode, + createdAt: input.createdAt, + }, }); } if (input.interactionMode !== serverThread.interactionMode) { - await api.orchestration.dispatchCommand({ - type: "thread.interaction-mode.set", - commandId: newCommandId(), - threadId: input.threadId, - interactionMode: input.interactionMode, - createdAt: input.createdAt, + await actions.threads.setInteractionMode({ + environmentId, + input: { + threadId: input.threadId, + interactionMode: input.interactionMode, + createdAt: input.createdAt, + }, }); } }, - [environmentId, serverThread], + [actions.threads, environmentId, serverThread], ); // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. @@ -2775,9 +2768,8 @@ export default function ChatView(props: ChatViewProps) { const onRevertToTurnCount = useCallback( async (turnCount: number) => { - const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); - if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; + if (!localApi || !activeThread || isRevertingCheckpoint) return; if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { setThreadError( @@ -2804,12 +2796,12 @@ export default function ChatView(props: ChatViewProps) { setIsRevertingCheckpoint(true); setThreadError(activeThread.id, null); try { - await api.orchestration.dispatchCommand({ - type: "thread.checkpoint.revert", - commandId: newCommandId(), - threadId: activeThread.id, - turnCount, - createdAt: new Date().toISOString(), + await actions.threads.revertCheckpoint({ + environmentId, + input: { + threadId: activeThread.id, + turnCount, + }, }); } catch (err) { setThreadError( @@ -2820,6 +2812,7 @@ export default function ChatView(props: ChatViewProps) { setIsRevertingCheckpoint(false); }, [ + actions.threads, activeThread, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel, @@ -2834,9 +2827,7 @@ export default function ChatView(props: ChatViewProps) { const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); - const api = readEnvironmentApi(environmentId); if ( - !api || !activeThread || isSendBusy || isConnecting || @@ -3029,11 +3020,12 @@ export default function ChatView(props: ChatViewProps) { // Auto-title from first message if (isFirstMessage && isServerThread) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - title, + await actions.threads.updateMetadata({ + environmentId, + input: { + threadId: threadIdForSend, + title, + }, }); } @@ -3078,22 +3070,23 @@ export default function ChatView(props: ChatViewProps) { } : undefined; beginLocalDispatch({ preparingWorktree: false }); - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: turnAttachments, + await actions.threads.startTurn({ + environmentId, + input: { + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: turnAttachments, + }, + modelSelection: ctxSelectedModelSelection, + titleSeed: title, + runtimeMode, + interactionMode, + ...(bootstrap ? { bootstrap } : {}), + createdAt: messageCreatedAt, }, - modelSelection: ctxSelectedModelSelection, - titleSeed: title, - runtimeMode, - interactionMode, - ...(bootstrap ? { bootstrap } : {}), - createdAt: messageCreatedAt, }); turnStartSucceeded = true; })().catch(async (err: unknown) => { @@ -3136,32 +3129,30 @@ export default function ChatView(props: ChatViewProps) { }; const onInterrupt = async () => { - const api = readEnvironmentApi(environmentId); - if (!api || !activeThread) return; - await api.orchestration.dispatchCommand({ - type: "thread.turn.interrupt", - commandId: newCommandId(), - threadId: activeThread.id, - createdAt: new Date().toISOString(), + if (!activeThread) return; + await actions.threads.interruptTurn({ + environmentId, + input: { + threadId: activeThread.id, + }, }); }; const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { - const api = readEnvironmentApi(environmentId); - if (!api || !activeThreadId) return; + if (!activeThreadId) return; setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); - await api.orchestration - .dispatchCommand({ - type: "thread.approval.respond", - commandId: newCommandId(), - threadId: activeThreadId, - requestId, - decision, - createdAt: new Date().toISOString(), + await actions.threads + .respondToApproval({ + environmentId, + input: { + threadId: activeThreadId, + requestId, + decision, + }, }) .catch((err: unknown) => { setThreadError( @@ -3171,25 +3162,24 @@ export default function ChatView(props: ChatViewProps) { }); setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, environmentId, setThreadError], + [actions.threads, activeThreadId, environmentId, setThreadError], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { - const api = readEnvironmentApi(environmentId); - if (!api || !activeThreadId) return; + if (!activeThreadId) return; setRespondingUserInputRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); - await api.orchestration - .dispatchCommand({ - type: "thread.user-input.respond", - commandId: newCommandId(), - threadId: activeThreadId, - requestId, - answers, - createdAt: new Date().toISOString(), + await actions.threads + .respondToUserInput({ + environmentId, + input: { + threadId: activeThreadId, + requestId, + answers, + }, }) .catch((err: unknown) => { setThreadError( @@ -3199,7 +3189,7 @@ export default function ChatView(props: ChatViewProps) { }); setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, environmentId, setThreadError], + [actions.threads, activeThreadId, environmentId, setThreadError], ); const setActivePendingUserInputQuestionIndex = useCallback( @@ -3316,9 +3306,7 @@ export default function ChatView(props: ChatViewProps) { text: string; interactionMode: "default" | "plan"; }) => { - const api = readEnvironmentApi(environmentId); if ( - !api || !activeThread || !isServerThread || isSendBusy || @@ -3393,29 +3381,30 @@ export default function ChatView(props: ChatViewProps) { nextInteractionMode, ); - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: [], + await actions.threads.startTurn({ + environmentId, + input: { + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: [], + }, + modelSelection: ctxSelectedModelSelection, + titleSeed: activeThread.title, + runtimeMode, + interactionMode: nextInteractionMode, + ...(nextInteractionMode === "default" && activeProposedPlan + ? { + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, + } + : {}), + createdAt: messageCreatedAt, }, - modelSelection: ctxSelectedModelSelection, - titleSeed: activeThread.title, - runtimeMode, - interactionMode: nextInteractionMode, - ...(nextInteractionMode === "default" && activeProposedPlan - ? { - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, - } - : {}), - createdAt: messageCreatedAt, }); // Optimistically open the plan sidebar when implementing (not refining). // "default" mode here means the agent is executing the plan, which produces @@ -3438,6 +3427,7 @@ export default function ChatView(props: ChatViewProps) { } }, [ + actions.threads, activeThread, activeProposedPlan, beginLocalDispatch, @@ -3455,9 +3445,7 @@ export default function ChatView(props: ChatViewProps) { ); const onImplementPlanInNewThread = useCallback(async () => { - const api = readEnvironmentApi(environmentId); if ( - !api || !activeThread || !activeProject || !activeProposedPlan || @@ -3503,40 +3491,42 @@ export default function ChatView(props: ChatViewProps) { resetLocalDispatch(); }; - await api.orchestration - .dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: nextThreadId, - projectId: activeProject.id, - title: nextThreadTitle, - modelSelection: nextThreadModelSelection, - runtimeMode, - interactionMode: "default", - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, - createdAt, - }) - .then(() => { - return api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), + await actions.threads + .create({ + environmentId, + input: { threadId: nextThreadId, - message: { - messageId: newMessageId(), - role: "user", - text: outgoingImplementationPrompt, - attachments: [], - }, - modelSelection: ctxSelectedModelSelection, - titleSeed: nextThreadTitle, + projectId: activeProject.id, + title: nextThreadTitle, + modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, createdAt, + }, + }) + .then(() => { + return actions.threads.startTurn({ + environmentId, + input: { + threadId: nextThreadId, + message: { + messageId: newMessageId(), + role: "user", + text: outgoingImplementationPrompt, + attachments: [], + }, + modelSelection: ctxSelectedModelSelection, + titleSeed: nextThreadTitle, + runtimeMode, + interactionMode: "default", + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, + createdAt, + }, }); }) .then(() => { @@ -3554,11 +3544,12 @@ export default function ChatView(props: ChatViewProps) { }); }) .catch(async (err: unknown) => { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: nextThreadId, + await actions.threads + .delete({ + environmentId, + input: { + threadId: nextThreadId, + }, }) .catch(() => undefined); toastManager.add( @@ -3574,6 +3565,7 @@ export default function ChatView(props: ChatViewProps) { }) .then(finish, finish); }, [ + actions.threads, activeProject, activeProposedPlan, activeThreadBranch, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3dc5a3acb74..c8aad725723 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -11,7 +11,6 @@ import { type SourceControlProviderKind, type SourceControlRepositoryInfo, } from "@t3tools/contracts"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { @@ -39,19 +38,15 @@ import { } from "react"; import { useShallow } from "zustand/react/shallow"; import { useCommandPaletteStore } from "../commandPaletteStore"; -import { readEnvironmentApi } from "../environmentApi"; -import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; import { - getSourceControlDiscoverySnapshot, - refreshSourceControlDiscovery, -} from "../lib/sourceControlDiscoveryState"; + useWebFilesystemDirectory, + useWebSourceControlCapabilities, +} from "../connection/webAppQueries"; +import { useWebActions } from "../connection/useWebEnvironmentData"; +import { useWebEnvironments, useWebPrimaryEnvironment } from "../connection/useWebEnvironments"; import { startNewThreadInProjectFromContext, startNewThreadFromContext, @@ -73,7 +68,7 @@ import { } from "../lib/projectPaths"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } from "../lib/threadSort"; -import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectProjectsAcrossEnvironments, selectSidebarThreadsAcrossEnvironments, @@ -120,7 +115,6 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand import type { ChatComposerHandle } from "./chat/ChatComposer"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; -const BROWSE_STALE_TIME_MS = 30_000; function getLocalFileManagerName(platform: string): string { if (isMacPlatform(platform)) { @@ -399,9 +393,11 @@ function OpenCommandPaletteDialog() { const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); - const queryClient = useQueryClient(); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const settings = useSettings(); + const actions = useWebActions(); + const { environments } = useWebEnvironments(); + const primaryEnvironment = useWebPrimaryEnvironment(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); @@ -417,45 +413,21 @@ function OpenCommandPaletteDialog() { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const addProjectEnvironmentOptions = useMemo(() => { - const options: AddProjectEnvironmentOption[] = []; - const seenEnvironmentIds = new Set(); - - if (primaryEnvironmentId) { - seenEnvironmentIds.add(primaryEnvironmentId); - options.push({ - environmentId: primaryEnvironmentId, - label: resolveEnvironmentOptionLabel({ - isPrimary: true, - environmentId: primaryEnvironmentId, - runtimeLabel: primaryEnvironmentLabel, - }), - isPrimary: true, - }); - } - - for (const record of Object.values(savedEnvironmentRegistry)) { - if (seenEnvironmentIds.has(record.environmentId)) { - continue; - } - - const runtimeState = savedEnvironmentRuntimeById[record.environmentId]; - options.push({ - environmentId: record.environmentId, + const options = environments.map((environment): AddProjectEnvironmentOption => { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + return { + environmentId: environment.environmentId, label: resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: record.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? null, - savedLabel: record.label, + isPrimary, + environmentId: environment.environmentId, + runtimeLabel: environment.label, }), - isPrimary: false, - }); - } + isPrimary, + }; + }); options.sort((left, right) => { if (left.isPrimary !== right.isPrimary) { @@ -465,26 +437,15 @@ function OpenCommandPaletteDialog() { }); return options; - }, [ - primaryEnvironmentId, - primaryEnvironmentLabel, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [environments]); const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; - const browseEnvironmentPlatform = useMemo(() => { - const os = - browseEnvironmentId && primaryEnvironmentId && browseEnvironmentId === primaryEnvironmentId - ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null) - : browseEnvironmentId - ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ?? - savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform - .os ?? - null) - : null; - return getEnvironmentBrowsePlatform(os); - }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); + const browseEnvironment = + environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; + const sourceControlDiscovery = useWebSourceControlCapabilities(browseEnvironmentId); + const browseEnvironmentPlatform = getEnvironmentBrowsePlatform( + browseEnvironment?.serverConfig?.environment.platform.os, + ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; const isBrowsing = @@ -492,19 +453,19 @@ function OpenCommandPaletteDialog() { const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { + const environment = environments.find( + (candidate) => candidate.environmentId === environmentId, + ); const environmentSettings = - environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId - ? settings - : environmentId - ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings - : null; + environment?.serverConfig?.settings ?? + (environmentId === primaryEnvironmentId ? settings : null); const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; if (baseDirectory.length === 0) { return "~/"; } return ensureBrowseDirectoryPath(baseDirectory); }, - [primaryEnvironmentId, savedEnvironmentRuntimeById, settings], + [environments, primaryEnvironmentId, settings], ); const projectCwdById = useMemo( @@ -532,69 +493,31 @@ function OpenCommandPaletteDialog() { const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; const browseFilterQuery = isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; - - const fetchBrowseResult = useCallback( - async (partialPath: string): Promise => { - if (!browseEnvironmentId) return null; - const api = readEnvironmentApi(browseEnvironmentId); - if (!api) return null; - return api.filesystem.browse({ - partialPath, - ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), - }); - }, - [browseEnvironmentId, currentProjectCwdForBrowse], - ); - - const { data: browseResult, isPending: isBrowsePending } = useQuery({ - queryKey: [ - "filesystemBrowse", - browseEnvironmentId, - browseDirectoryPath, - currentProjectCwdForBrowse, - ], - queryFn: () => fetchBrowseResult(browseDirectoryPath), - staleTime: BROWSE_STALE_TIME_MS, - enabled: - isBrowsing && + const browseQuery = useWebFilesystemDirectory( + isBrowsing && browseDirectoryPath.length > 0 && browseEnvironmentId !== null && - !relativePathNeedsActiveProject, - }); + !relativePathNeedsActiveProject + ? browseEnvironmentId + : null, + isBrowsing && + browseDirectoryPath.length > 0 && + browseEnvironmentId !== null && + !relativePathNeedsActiveProject + ? { + partialPath: browseDirectoryPath, + ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), + } + : null, + ); + const browseResult = browseQuery.data; + const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), [browseEntries, browseFilterQuery, highlightedItemValue], ); - const prefetchBrowsePath = useCallback( - (partialPath: string) => { - void queryClient.prefetchQuery({ - queryKey: [ - "filesystemBrowse", - browseEnvironmentId, - partialPath, - currentProjectCwdForBrowse, - ], - queryFn: () => fetchBrowseResult(partialPath), - staleTime: BROWSE_STALE_TIME_MS, - }); - }, - [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], - ); - - // Prefetch only the parent (for back-navigation). Prefetching the - // highlighted child on every arrow-key press triggers a macOS TCC prompt - // whenever the highlighted entry is a permission-gated home dir (Music, - // Documents, Downloads, Desktop, etc.), so we wait for explicit navigation. - useEffect(() => { - if (!isBrowsing || filteredBrowseEntries.length === 0) return; - - if (canNavigateUp(query)) { - prefetchBrowsePath(getBrowseParentPath(query)!); - } - }, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]); - const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { const latestThread = getLatestThreadForProject( @@ -866,42 +789,48 @@ function OpenCommandPaletteDialog() { (environmentId: EnvironmentId): void => { setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); - const target = { environmentId }; - const initialDiscovery = getSourceControlDiscoverySnapshot(target).data; pushPaletteView({ addonIcon: , groups: buildAddProjectSourceGroups( environmentId, - buildAddProjectRemoteSourceReadiness(initialDiscovery), + buildAddProjectRemoteSourceReadiness( + browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null, + ), ), }); - - if (initialDiscovery) { - return; - } - - void refreshSourceControlDiscovery(target).then((discovery) => { - setViewStack((previousViews) => { - const currentTopView = previousViews.at(-1); - if (currentTopView?.groups[0]?.value !== `sources:${environmentId}`) { - return previousViews; - } - return [ - ...previousViews.slice(0, -1), - { - addonIcon: , - groups: buildAddProjectSourceGroups( - environmentId, - buildAddProjectRemoteSourceReadiness(discovery), - ), - }, - ]; - }); - }); }, - [buildAddProjectSourceGroups], + [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data], ); + useEffect(() => { + if (addProjectEnvironmentId === null) { + return; + } + sourceControlDiscovery.refresh(); + }, [addProjectEnvironmentId, sourceControlDiscovery.refresh]); + + useEffect(() => { + if (addProjectEnvironmentId === null || sourceControlDiscovery.data === null) { + return; + } + setViewStack((previousViews) => { + const currentTopView = previousViews.at(-1); + if (currentTopView?.groups[0]?.value !== `sources:${addProjectEnvironmentId}`) { + return previousViews; + } + return [ + ...previousViews.slice(0, -1), + { + addonIcon: , + groups: buildAddProjectSourceGroups( + addProjectEnvironmentId, + buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), + ), + }, + ]; + }); + }, [addProjectEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data]); + const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( (option) => ({ kind: "action", @@ -1061,8 +990,6 @@ function OpenCommandPaletteDialog() { const handleAddProject = useCallback( async (rawCwd: string) => { if (!browseEnvironmentId) return; - const api = readEnvironmentApi(browseEnvironmentId); - if (!api) return; if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { toastManager.add( @@ -1117,18 +1044,18 @@ function OpenCommandPaletteDialog() { try { const projectId = newProjectId(); - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title: inferProjectTitleFromPath(cwd), - workspaceRoot: cwd, - createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, + await actions.projects.create({ + environmentId: browseEnvironmentId, + input: { + projectId, + title: inferProjectTitleFromPath(cwd), + workspaceRoot: cwd, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, }, - createdAt: new Date().toISOString(), }); await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { envMode: settings.defaultThreadEnvMode, @@ -1145,6 +1072,7 @@ function OpenCommandPaletteDialog() { } }, [ + actions.projects, browseEnvironmentId, browseEnvironmentPlatform, currentProjectCwdForBrowse, @@ -1167,18 +1095,6 @@ function OpenCommandPaletteDialog() { return; } - const api = readEnvironmentApi(addProjectCloneFlow.environmentId); - if (!api) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to clone project", - description: "Environment API is not available.", - }), - ); - return; - } - if (addProjectCloneFlow.step === "repository") { const rawRepository = query.trim(); if (rawRepository.length === 0 || isRemoteProjectLookingUp) { @@ -1204,9 +1120,12 @@ function OpenCommandPaletteDialog() { setIsRemoteProjectLookingUp(true); try { - const repository = await api.sourceControl.lookupRepository({ - provider, - repository: rawRepository, + const repository = await actions.sourceControl.lookupRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { + provider, + repository: rawRepository, + }, }); const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); setAddProjectCloneFlow({ @@ -1271,9 +1190,12 @@ function OpenCommandPaletteDialog() { setIsRemoteProjectCloning(true); try { - const result = await api.sourceControl.cloneRepository({ - remoteUrl: addProjectCloneFlow.remoteUrl, - destinationPath, + const result = await actions.sourceControl.cloneRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { + remoteUrl: addProjectCloneFlow.remoteUrl, + destinationPath, + }, }); await handleAddProject(result.cwd); } catch (error) { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index dfc25cad9cd..8af69d120af 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -19,11 +19,10 @@ import { useRef, useState, } from "react"; -import { openInPreferredEditor } from "../editorPreferences"; +import { useOpenInPreferredEditor } from "../editorPreferences"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; -import { readLocalApi } from "../localApi"; import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; @@ -42,6 +41,7 @@ import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; +import { useWebServerConfig } from "../connection/useWebEnvironmentData"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; @@ -151,6 +151,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; + const serverConfig = useWebServerConfig(activeThread?.environmentId ?? null); + const openInPreferredEditor = useOpenInPreferredEditor( + activeThread?.environmentId ?? null, + serverConfig.data?.availableEditors ?? [], + ); const gitStatusQuery = useVcsStatus({ environmentId: activeThread?.environmentId ?? null, cwd: activeCwd ?? null, @@ -295,14 +300,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const openDiffFileInEditor = useCallback( (filePath: string) => { - const api = readLocalApi(); - if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void openInPreferredEditor(api, targetPath).catch((error) => { + void openInPreferredEditor(targetPath).catch((error) => { console.warn("Failed to open diff file in editor.", error); }); }, - [activeCwd], + [activeCwd, openInPreferredEditor], ); const toggleDiffFileCollapsed = useCallback((fileKey: string) => { setCollapsedDiffFileKeys((current) => { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index ef28523fe5a..afaab76e27e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -62,7 +62,7 @@ import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { openInPreferredEditor } from "~/editorPreferences"; +import { useOpenInPreferredEditor } from "~/editorPreferences"; import { useGitStackedAction, useSourceControlActionRunning, @@ -70,12 +70,12 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; +import { useWebRepositoryStatus } from "~/connection/webAppQueries"; +import { useWebActions, useWebServerConfig } from "~/connection/useWebEnvironmentData"; import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; -import { newCommandId, randomUUID } from "~/lib/utils"; +import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; -import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useStore } from "~/store"; @@ -478,9 +478,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishResult(result); setPublishWizardStep(2); }); - void refreshVcsStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( - () => undefined, - ); }) .catch((err: unknown) => { setPublishError(err instanceof Error ? err.message : "An error occurred."); @@ -950,7 +947,13 @@ export default function GitActionsControl({ activeThreadRef, draftId, }: GitActionsControlProps) { + const actions = useWebActions(); const activeEnvironmentId = activeThreadRef?.environmentId ?? null; + const serverConfig = useWebServerConfig(activeEnvironmentId); + const openInPreferredEditor = useOpenInPreferredEditor( + activeEnvironmentId, + serverConfig.data?.availableEditors ?? [], + ); const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], @@ -1009,18 +1012,16 @@ export default function GitActionsControl({ } const worktreePath = activeServerThread.worktreePath; - const api = readEnvironmentApi(activeThreadRef.environmentId); - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), + void actions.threads + .updateMetadata({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, branch, worktreePath, - }) - .catch(() => undefined); - } + }, + }) + .catch(() => undefined); setThreadBranch(activeThreadRef, branch, worktreePath); return; @@ -1039,6 +1040,7 @@ export default function GitActionsControl({ activeDraftThread, activeServerThread, activeThreadRef, + actions.threads, draftId, setDraftThreadContext, setThreadBranch, @@ -1057,10 +1059,11 @@ export default function GitActionsControl({ [persistThreadBranchSync], ); - const { data: gitStatus, error: gitStatusError } = useVcsStatus({ + const gitStatusQuery = useWebRepositoryStatus({ environmentId: activeEnvironmentId, cwd: gitCwd, }); + const { data: gitStatus, error: gitStatusError } = gitStatusQuery; const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], @@ -1162,9 +1165,7 @@ export default function GitActionsControl({ } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; - void refreshVcsStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( - () => undefined, - ); + gitStatusQuery.refresh(); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { @@ -1183,7 +1184,7 @@ export default function GitActionsControl({ window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [activeEnvironmentId, gitCwd]); + }, [gitCwd, gitStatusQuery.refresh]); const openExistingPr = useCallback(async () => { const api = readLocalApi(); @@ -1572,8 +1573,7 @@ export default function GitActionsControl({ const openChangedFileInEditor = useCallback( (filePath: string) => { - const api = readLocalApi(); - if (!api || !gitCwd) { + if (!gitCwd) { toastManager.add({ type: "error", title: "Editor opening is unavailable.", @@ -1582,7 +1582,7 @@ export default function GitActionsControl({ return; } const target = resolvePathLinkTarget(filePath, gitCwd); - void openInPreferredEditor(api, target).catch((error) => { + void openInPreferredEditor(target).catch((error) => { toastManager.add( stackedThreadToast({ type: "error", @@ -1593,7 +1593,7 @@ export default function GitActionsControl({ ); }); }, - [gitCwd, threadToastData], + [gitCwd, openInPreferredEditor, threadToastData], ); const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; @@ -1657,10 +1657,7 @@ export default function GitActionsControl({ { if (open) { - void refreshVcsStatus({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - }).catch(() => undefined); + gitStatusQuery.refresh(); } }} > @@ -1741,7 +1738,7 @@ export default function GitActionsControl({

)} {gitStatusError && ( -

{gitStatusError.message}

+

{gitStatusError}

)}
diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b5e9d74764b..a090f954474 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -36,7 +36,6 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig, getServerConfigUpdatedNotification } from "../rpc/serverState"; -import { getWsConnectionStatus } from "../rpc/wsConnectionState"; import { getRouter } from "../router"; import { useStore } from "../store"; import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; @@ -368,15 +367,6 @@ async function waitForToastViewport(): Promise { ); } -async function waitForWsConnection(): Promise { - await vi.waitFor( - () => { - expect(getWsConnectionStatus().phase).toBe("connected"); - }, - { timeout: 8_000, interval: 16 }, - ); -} - async function waitForToast(title: string, count = 1): Promise { await vi.waitFor( () => { @@ -478,7 +468,6 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { await waitForComposerEditor(); await waitForToastViewport(); await waitForInitialWsSubscriptions(); - await waitForWsConnection(); await waitForServerConfigSnapshot(); await waitForServerConfigStreamReady(); await waitForNoToasts(); diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index afd4bb2e0bc..036cff8ff66 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -25,7 +25,7 @@ import { stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { readEnvironmentApi } from "~/environmentApi"; +import { useWebActions } from "~/connection/useWebEnvironmentData"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -76,6 +76,7 @@ const PlanSidebar = memo(function PlanSidebar({ }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const actions = useWebActions(); const { copyToClipboard, isCopied } = useCopyToClipboard(); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; @@ -94,15 +95,17 @@ const PlanSidebar = memo(function PlanSidebar({ }, [planMarkdown]); const handleSaveToWorkspace = useCallback(() => { - const api = readEnvironmentApi(environmentId); - if (!api || !workspaceRoot || !planMarkdown) return; + if (!workspaceRoot || !planMarkdown) return; const filename = buildProposedPlanMarkdownFilename(planMarkdown); setIsSavingToWorkspace(true); - void api.projects + void actions.projects .writeFile({ - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), + environmentId, + input: { + cwd: workspaceRoot, + relativePath: filename, + contents: normalizePlanMarkdownForExport(planMarkdown), + }, }) .then((result) => { toastManager.add({ @@ -124,7 +127,7 @@ const PlanSidebar = memo(function PlanSidebar({ () => setIsSavingToWorkspace(false), () => setIsSavingToWorkspace(false), ); - }, [environmentId, planMarkdown, workspaceRoot]); + }, [actions.projects, environmentId, planMarkdown, workspaceRoot]); return (
(); @@ -10,13 +10,32 @@ export function ProjectFavicon(input: { cwd: string; className?: string; }) { + const { presentationById } = useWebEnvironments(); const src = (() => { try { - return resolveEnvironmentHttpUrl({ - environmentId: input.environmentId, - pathname: "/api/project-favicon", - searchParams: { cwd: input.cwd }, - }); + const baseUrl = presentationById.get(input.environmentId) + ? (() => { + const entry = presentationById.get(input.environmentId)!.entry; + switch (entry.target._tag) { + case "PrimaryConnectionTarget": + return entry.target.httpBaseUrl; + case "BearerConnectionTarget": + return entry.profile._tag === "Some" && + entry.profile.value._tag === "BearerConnectionProfile" + ? entry.profile.value.httpBaseUrl + : null; + case "RelayConnectionTarget": + case "SshConnectionTarget": + return null; + } + })() + : null; + if (baseUrl === null) { + return null; + } + const url = new URL("/api/project-favicon", baseUrl); + url.searchParams.set("cwd", input.cwd); + return url.toString(); } catch { return null; } diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 69cd83bf8dc..c14cf2aa6a0 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -3,7 +3,8 @@ import { DownloadIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; -import { ensureLocalApi } from "../localApi"; +import { useWebActions } from "../connection/useWebEnvironmentData"; +import { useWebPrimaryEnvironment } from "../connection/useWebEnvironments"; import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; import { useServerProviders } from "../rpc/serverState"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; @@ -102,6 +103,8 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { export function ProviderUpdateLaunchNotification() { const navigate = useNavigate(); const providers = useServerProviders(); + const primaryEnvironment = useWebPrimaryEnvironment(); + const actions = useWebActions(); const activeToastRef = useRef(null); const { dismissedNotificationKeys, dismissNotificationKey } = useDismissedProviderUpdateNotificationKeys(); @@ -185,7 +188,7 @@ export function ProviderUpdateLaunchNotification() { }; const runUpdates = () => { - if (updateStarted || oneClickProviders.length === 0) { + if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { return; } updateStarted = true; @@ -208,9 +211,12 @@ export function ProviderUpdateLaunchNotification() { void Promise.allSettled( oneClickProviders.map(async (provider) => - ensureLocalApi().server.updateProvider({ - provider: provider.driver, - instanceId: provider.instanceId, + actions.server.updateProvider({ + environmentId: primaryEnvironment.environmentId, + input: { + provider: provider.driver, + instanceId: provider.instanceId, + }, }), ), ).then((results) => { @@ -288,11 +294,13 @@ export function ProviderUpdateLaunchNotification() { ); activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; }, [ + actions.server, dismissNotificationKey, dismissedNotificationKeys, notificationKey, oneClickProviders, openProviderSettings, + primaryEnvironment, updateProviders, ]); diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 688ea004f52..97e037b43fe 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -173,9 +173,7 @@ export function PullRequestThreadDialog({ const errorMessage = validationMessage ?? (resolvedPullRequest === null && pullRequestResolution.error - ? pullRequestResolution.error instanceof Error - ? pullRequestResolution.error.message - : `Failed to resolve ${terminology.singular}.` + ? pullRequestResolution.error : preparePullRequestThreadAction.error instanceof Error ? preparePullRequestThreadAction.error.message : preparePullRequestThreadAction.error diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3f163b017d..8a4c157cd12 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -60,11 +60,10 @@ import { type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; -import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform, newCommandId } from "../lib/utils"; +import { isMacPlatform } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, @@ -74,7 +73,7 @@ import { useStore, } from "../store"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useWebThreadRunningTerminalIds as useThreadRunningTerminalIds } from "../connection/webTerminalSessions"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -90,9 +89,10 @@ import { useVcsStatus } from "../lib/vcsStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { useThreadActions } from "../hooks/useThreadActions"; +import { useWebActions, useWebEnvironmentThread } from "../connection/useWebEnvironmentData"; +import { useWebEnvironments, useWebPrimaryEnvironment } from "../connection/useWebEnvironments"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -177,7 +177,6 @@ import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; -import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { @@ -186,10 +185,6 @@ import { getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, @@ -218,6 +213,11 @@ const PROJECT_GROUPING_MODE_LABELS: Record = separate: "Keep separate", }; +function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + useWebEnvironmentThread(threadRef.environmentId, threadRef.threadId); + return null; +} + function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { return Math.min( MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -347,18 +347,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); - const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useWebEnvironments(); + const primaryEnvironmentId = useWebPrimaryEnvironment()?.environmentId ?? null; const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (s) => s.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; + const remoteEnvLabel = + environments.find((environment) => environment.environmentId === thread.environmentId)?.label ?? + null; + const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; // For grouped projects, the thread may belong to a different environment // than the representative project. Look up the thread's own project cwd // so git status (and thus PR detection) queries the correct path. @@ -943,6 +939,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec (settings) => settings.defaultThreadEnvMode, ); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const actions = useWebActions(); const { updateSettings } = useUpdateSettings(); const sidebarThreadPreviewCount = useSettings( (settings) => settings.sidebarThreadPreviewCount, @@ -1297,19 +1294,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } draftStore.clearProjectDraftThreadId(memberProjectRef); - const projectApi = readEnvironmentApi(member.environmentId); - if (!projectApi) { - throw new Error("Project API unavailable."); - } - - await projectApi.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId: member.id, - ...(options.force === true ? { force: true } : {}), + await actions.projects.delete({ + environmentId: member.environmentId, + input: { + projectId: member.id, + ...(options.force === true ? { force: true } : {}), + }, }); }, - [], + [actions.projects], ); const handleRemoveProject = useCallback( @@ -1789,17 +1782,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec finishRename(); return; } - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) { - finishRename(); - return; - } try { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadRef.threadId, - title: trimmed, + await actions.threads.updateMetadata({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + title: trimmed, + }, }); } catch (error) { toastManager.add( @@ -1812,7 +1801,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } finishRename(); }, - [], + [actions.threads], ); const closeProjectRenameDialog = useCallback(() => { @@ -1839,24 +1828,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const api = readEnvironmentApi(projectRenameTarget.environmentId); - if (!api) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename project", - description: "Project API unavailable.", - }), - ); - return; - } - try { - await api.orchestration.dispatchCommand({ - type: "project.meta.update", - commandId: newCommandId(), - projectId: projectRenameTarget.id, - title: trimmed, + await actions.projects.update({ + environmentId: projectRenameTarget.environmentId, + input: { + projectId: projectRenameTarget.id, + title: trimmed, + }, }); closeProjectRenameDialog(); } catch (error) { @@ -1868,7 +1846,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }), ); } - }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + }, [actions.projects, closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); const closeProjectGroupingDialog = useCallback(() => { setProjectGroupingTarget(null); @@ -2825,9 +2803,15 @@ export default function Sidebar() { const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); const modelPickerOpen = useModelPickerOpen(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const { environments } = useWebEnvironments(); + const primaryEnvironmentId = useWebPrimaryEnvironment()?.environmentId ?? null; + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -2861,19 +2845,9 @@ export default function Sidebar() { projects: orderedProjects, settings: projectGroupingSettings, primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => { - const rt = savedEnvironmentRuntimeById[environmentId]; - const saved = savedEnvironmentRegistry[environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? null; - }, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }); - }, [ - orderedProjects, - projectGroupingSettings, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [environmentLabelById, orderedProjects, projectGroupingSettings, primaryEnvironmentId]); const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), @@ -3179,18 +3153,6 @@ export default function Sidebar() { [prewarmedSidebarThreadKeys], ); - useEffect(() => { - const releases = prewarmedSidebarThreadRefs.map((ref) => - retainThreadDetailSubscription(ref.environmentId, ref.threadId), - ); - - return () => { - for (const release of releases) { - release(); - } - }; - }, [prewarmedSidebarThreadRefs]); - useEffect(() => { updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); @@ -3415,6 +3377,9 @@ export default function Sidebar() { return ( <> + {prewarmedSidebarThreadRefs.map((threadRef) => ( + + ))} {isOnSettings ? ( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 2423799ac37..89b8b9d7b40 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -2,14 +2,10 @@ import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/clien import type { VcsStatusResult } from "@t3tools/contracts"; import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; -import { usePrimaryEnvironmentId } from "../environments/primary"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; +import { useWebEnvironments, useWebPrimaryEnvironment } from "../connection/useWebEnvironments"; import { useVcsStatus } from "../lib/vcsStatusState"; import { type AppState, selectProjectByRef, useStore } from "../store"; -import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useWebThreadRunningTerminalIds as useThreadRunningTerminalIds } from "../connection/webTerminalSessions"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; @@ -199,18 +195,14 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma environmentId: thread.environmentId, threadId: thread.id, }); - const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useWebEnvironments(); + const primaryEnvironmentId = useWebPrimaryEnvironment()?.environmentId ?? null; const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (state) => state.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; + const remoteEnvLabel = + environments.find((environment) => environment.environmentId === thread.environmentId)?.label ?? + null; + const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); if (!terminalStatus && !isRemoteThread) { diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 56482c44e8e..3e770927c2b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -1,7 +1,7 @@ import "../index.css"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; @@ -10,26 +10,34 @@ const { terminalDisposeSpy, fitAddonFitSpy, fitAddonLoadSpy, - environmentApiById, - readEnvironmentApiMock, + terminalControllerByEnvironmentId, + useWebTerminalControllerMock, readLocalApiMock, } = vi.hoisted(() => ({ terminalConstructorSpy: vi.fn(), terminalDisposeSpy: vi.fn(), fitAddonFitSpy: vi.fn(), fitAddonLoadSpy: vi.fn(), - environmentApiById: new Map< + terminalControllerByEnvironmentId: new Map< string, { - terminal: { - open: ReturnType; - attach: ReturnType; - write: ReturnType; - resize: ReturnType; + session: { + summary: null; + buffer: string; + status: "running"; + error: null; + hasRunningSubprocess: false; + updatedAt: null; + version: number; }; + write: ReturnType; + resize: ReturnType; + clear: ReturnType; + restart: ReturnType; + close: ReturnType; } >(), - readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), + useWebTerminalControllerMock: vi.fn(), readLocalApiMock: vi.fn< () => | { @@ -118,8 +126,23 @@ vi.mock("@xterm/xterm", () => ({ }, })); -vi.mock("~/environmentApi", () => ({ - readEnvironmentApi: readEnvironmentApiMock, +vi.mock("../connection/webTerminalSessions", () => ({ + useWebTerminalController: (input: { environmentId: string }) => { + useWebTerminalControllerMock(input); + const controller = terminalControllerByEnvironmentId.get(input.environmentId); + if (controller === undefined) { + throw new Error(`Missing test terminal controller for ${input.environmentId}`); + } + return controller; + }, +})); + +vi.mock("../connection/useWebEnvironmentData", () => ({ + useWebServerConfig: () => ({ + data: { availableEditors: [] }, + error: null, + isLoading: false, + }), })); vi.mock("~/localApi", () => ({ @@ -133,37 +156,22 @@ import { TerminalViewport } from "./ThreadTerminalDrawer"; const THREAD_ID = ThreadId.make("thread-terminal-browser"); -function createEnvironmentApi() { - const snapshot = { - threadId: THREAD_ID, - terminalId: "term-1", - cwd: "/repo/project", - worktreePath: null, - status: "running" as const, - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "Terminal 1", - updatedAt: "2026-04-07T00:00:00.000Z", - }; - +function createTerminalController() { return { - terminal: { - open: vi.fn(async () => snapshot), - attach: vi.fn( - ( - _input: unknown, - listener: (event: TerminalAttachStreamEvent) => void, - _options?: unknown, - ) => { - listener({ type: "snapshot", snapshot }); - return vi.fn(); - }, - ), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), + session: { + summary: null, + buffer: "", + status: "running" as const, + error: null, + hasRunningSubprocess: false as const, + updatedAt: null, + version: 1, }, + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + clear: vi.fn(async () => undefined), + restart: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), }; } @@ -239,8 +247,8 @@ async function mountTerminalViewport(props: { describe("TerminalViewport", () => { afterEach(() => { - environmentApiById.clear(); - readEnvironmentApiMock.mockClear(); + terminalControllerByEnvironmentId.clear(); + useWebTerminalControllerMock.mockClear(); readLocalApiMock.mockClear(); terminalConstructorSpy.mockClear(); terminalDisposeSpy.mockClear(); @@ -248,26 +256,8 @@ describe("TerminalViewport", () => { fitAddonLoadSpy.mockClear(); }); - it("does not create a terminal when APIs are unavailable", async () => { - readEnvironmentApiMock.mockReturnValueOnce(undefined); - readLocalApiMock.mockReturnValueOnce(undefined); - - const mounted = await mountTerminalViewport({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - }); - - try { - await vi.waitFor(() => { - expect(terminalConstructorSpy).not.toHaveBeenCalled(); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("renders and attaches the terminal without the desktop local API", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); + it("renders the terminal through the shared terminal controller without the desktop API", async () => { + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); readLocalApiMock.mockReturnValueOnce(undefined); const mounted = await mountTerminalViewport({ @@ -276,7 +266,9 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + expect(useWebTerminalControllerMock).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: "environment-a" }), + ); }); expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); } finally { @@ -285,8 +277,7 @@ describe("TerminalViewport", () => { }); it("keeps the terminal mounted when xterm fit runs before dimensions are ready", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); fitAddonFitSpy.mockImplementationOnce(() => { throw new TypeError("Cannot read properties of undefined (reading 'dimensions')"); }); @@ -297,9 +288,8 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); }); - expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); expect(fitAddonFitSpy).toHaveBeenCalled(); } finally { await mounted.cleanup(); @@ -307,10 +297,8 @@ describe("TerminalViewport", () => { }); it("reattaches the terminal when the scoped thread reference changes", async () => { - const environmentA = createEnvironmentApi(); - const environmentB = createEnvironmentApi(); - environmentApiById.set("environment-a", environmentA); - environmentApiById.set("environment-b", environmentB); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); + terminalControllerByEnvironmentId.set("environment-b", createTerminalController()); const mounted = await mountTerminalViewport({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), @@ -318,7 +306,7 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); }); await mounted.rerender({ @@ -326,17 +314,19 @@ describe("TerminalViewport", () => { }); await vi.waitFor(() => { - expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1); + expect(useWebTerminalControllerMock).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: "environment-b" }), + ); }); expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(2); } finally { await mounted.cleanup(); } }); it("does not reattach the terminal when the scoped thread reference values stay the same", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); const mounted = await mountTerminalViewport({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), @@ -344,16 +334,14 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); }); await mounted.rerender({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), }); - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); expect(terminalDisposeSpy).not.toHaveBeenCalled(); } finally { await mounted.cleanup(); @@ -361,8 +349,7 @@ describe("TerminalViewport", () => { }); it("does not reattach when runtime env contents are unchanged but object identity changes", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); const mounted = await mountTerminalViewport({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), @@ -371,7 +358,7 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); }); await mounted.rerender({ @@ -379,9 +366,7 @@ describe("TerminalViewport", () => { runtimeEnv: { T3: "1", PATH: "/usr/bin" }, }); - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); expect(terminalDisposeSpy).not.toHaveBeenCalled(); } finally { await mounted.cleanup(); @@ -389,8 +374,7 @@ describe("TerminalViewport", () => { }); it("uses the drawer surface colors for the terminal theme", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); const mounted = await mountTerminalViewport({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 9e57cbe60e1..fda7ebbec38 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -3,8 +3,6 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci import { type ResolvedKeybindingsConfig, type ScopedThreadRef, - type TerminalAttachStreamEvent, - type TerminalSessionSnapshot, type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; @@ -21,7 +19,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { type TerminalContextSelection } from "~/lib/terminalContext"; -import { openInPreferredEditor } from "../editorPreferences"; +import { useOpenInPreferredEditor } from "../editorPreferences"; import { collectWrappedTerminalLinkLine, extractTerminalLinks, @@ -45,9 +43,9 @@ import { MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; -import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; -import { attachTerminalSession } from "../terminalSessionState"; +import { useWebTerminalController } from "../connection/webTerminalSessions"; +import { useWebServerConfig } from "../connection/useWebEnvironmentData"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -68,10 +66,10 @@ function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } -function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void { +function writeTerminalBuffer(terminal: Terminal, buffer: string): void { terminal.write("\u001bc"); - if (snapshot.history.length > 0) { - terminal.write(snapshot.history); + if (buffer.length > 0) { + terminal.write(buffer); } } @@ -294,6 +292,12 @@ export function TerminalViewport({ const terminalRef = useRef(null); const fitAddonRef = useRef(null); const environmentId = threadRef.environmentId; + const serverConfig = useWebServerConfig(environmentId); + const openInPreferredEditor = useOpenInPreferredEditor( + environmentId, + serverConfig.data?.availableEditors ?? [], + ); + const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target)); const hasHandledExitRef = useRef(false); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); @@ -309,6 +313,20 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + const terminalController = useWebTerminalController({ + environmentId, + terminal: { + threadId, + terminalId, + cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }, + }); + const writeTerminal = useEffectEvent(terminalController.write); + const resizeTerminal = useEffectEvent(terminalController.resize); + const readTerminalSession = useEffectEvent(() => terminalController.session); + const previousSessionRef = useRef(terminalController.session); useEffect(() => { keybindingsRef.current = keybindings; @@ -318,10 +336,7 @@ export function TerminalViewport({ const mount = containerRef.current; if (!mount) return; - let disposed = false; - const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); - if (!api) return; const fitAddon = new FitAddon(); const terminal = new Terminal({ @@ -338,6 +353,13 @@ export function TerminalViewport({ terminalRef.current = terminal; fitAddonRef.current = fitAddon; + previousSessionRef.current = { + ...readTerminalSession(), + buffer: "", + status: "closed", + error: null, + version: 0, + }; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; @@ -422,7 +444,7 @@ export function TerminalViewport({ const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { - await api.terminal.write({ threadId, terminalId, data }); + await writeTerminal(data); } catch (error) { writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); } @@ -502,12 +524,15 @@ export function TerminalViewport({ const latestTerminal = terminalRef.current; if (!latestTerminal) return; - if (!localApi) { - writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); - return; - } if (match.kind === "url") { + if (!localApi) { + writeSystemMessage( + latestTerminal, + "Opening links is unavailable in this browser.", + ); + return; + } void localApi.shell.openExternal(match.text).catch((error: unknown) => { writeSystemMessage( latestTerminal, @@ -518,7 +543,7 @@ export function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void openInPreferredEditor(localApi, target).catch((error) => { + void openTerminalPath(target).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", @@ -531,14 +556,9 @@ export function TerminalViewport({ }); const inputDisposable = terminal.onData((data) => { - void api.terminal - .write({ threadId, terminalId, data }) - .catch((err) => - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Terminal write failed", - ), - ); + void writeTerminal(data).catch((err) => + writeSystemMessage(terminal, err instanceof Error ? err.message : "Terminal write failed"), + ); }); const selectionDisposable = terminal.onSelectionChange(() => { @@ -584,107 +604,6 @@ export function TerminalViewport({ attributeFilter: ["class", "style"], }); - const applyAttachEvent = (event: TerminalAttachStreamEvent) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) { - return; - } - - if (event.type === "activity") { - return; - } - - if (event.type === "snapshot") { - hasHandledExitRef.current = false; - clearSelectionAction(); - writeTerminalSnapshot(activeTerminal, event.snapshot); - return; - } - - if (event.type === "output") { - activeTerminal.write(event.data); - clearSelectionAction(); - return; - } - - if (event.type === "restarted") { - hasHandledExitRef.current = false; - clearSelectionAction(); - writeTerminalSnapshot(activeTerminal, event.snapshot); - return; - } - - if (event.type === "cleared") { - clearSelectionAction(); - activeTerminal.clear(); - activeTerminal.write("\u001bc"); - return; - } - - if (event.type === "error") { - writeSystemMessage(activeTerminal, event.message); - return; - } - - if (event.type === "closed") { - writeSystemMessage(activeTerminal, "Terminal closed"); - } else { - const details = [ - typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, - typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, - ] - .filter((value): value is string => value !== null) - .join(", "); - writeSystemMessage( - activeTerminal, - details.length > 0 ? `Process exited (${details})` : "Process exited", - ); - } - - if (hasHandledExitRef.current) { - return; - } - hasHandledExitRef.current = true; - window.setTimeout(() => { - if (!hasHandledExitRef.current) { - return; - } - handleSessionExited(); - }, 0); - }; - let unsubscribeAttach: (() => void) | null = null; - const attachTerminal = () => { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - fitTerminalSafely(activeFitAddon); - unsubscribeAttach = attachTerminalSession({ - environmentId, - client: api, - terminal: { - threadId, - terminalId, - cwd, - ...(worktreePath !== undefined ? { worktreePath } : {}), - cols: activeTerminal.cols, - rows: activeTerminal.rows, - ...(runtimeEnv ? { env: runtimeEnv } : {}), - }, - onEvent: (event) => { - if (disposed) return; - applyAttachEvent(event); - }, - onSnapshot: () => { - if (disposed) return; - if (autoFocus) { - window.requestAnimationFrame(() => { - activeTerminal.focus(); - }); - } - }, - }); - }; - const fitTimer = window.setTimeout(() => { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; @@ -695,21 +614,10 @@ export function TerminalViewport({ if (wasAtBottom) { activeTerminal.scrollToBottom(); } - void api.terminal - .resize({ - threadId, - terminalId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); + void resizeTerminal(activeTerminal.cols, activeTerminal.rows).catch(() => undefined); }, 30); - attachTerminal(); return () => { - disposed = true; - unsubscribeAttach?.(); - unsubscribeAttach = null; window.clearTimeout(fitTimer); inputDisposable.dispose(); selectionDisposable.dispose(); @@ -729,6 +637,66 @@ export function TerminalViewport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) { + previousSessionRef.current = terminalController.session; + return; + } + + const previous = previousSessionRef.current; + const current = terminalController.session; + if (current.version === previous.version) { + return; + } + + if ( + current.buffer.length >= previous.buffer.length && + current.buffer.startsWith(previous.buffer) + ) { + terminal.write(current.buffer.slice(previous.buffer.length)); + } else { + writeTerminalBuffer(terminal, current.buffer); + } + terminal.clearSelection(); + + if (current.error !== null && current.error !== previous.error) { + writeSystemMessage(terminal, current.error); + } + + if (current.status === "running") { + hasHandledExitRef.current = false; + } else if ( + (current.status === "closed" || current.status === "exited") && + current.status !== previous.status && + !hasHandledExitRef.current + ) { + hasHandledExitRef.current = true; + writeSystemMessage( + terminal, + current.status === "closed" ? "Terminal closed" : "Process exited", + ); + window.setTimeout(() => { + if (hasHandledExitRef.current) { + handleSessionExited(); + } + }, 0); + } + + if (previous.version === 0 && autoFocus) { + window.requestAnimationFrame(() => { + terminal.focus(); + }); + } + previousSessionRef.current = current; + }, [ + autoFocus, + terminalController.session.buffer, + terminalController.session.error, + terminalController.session.status, + terminalController.session.version, + ]); + useEffect(() => { if (!autoFocus) return; const terminal = terminalRef.current; @@ -742,24 +710,16 @@ export function TerminalViewport({ }, [autoFocus, focusRequestId]); useEffect(() => { - const api = readEnvironmentApi(environmentId); const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; - if (!api || !terminal || !fitAddon) return; + if (!terminal || !fitAddon) return; const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { fitTerminalSafely(fitAddon); if (wasAtBottom) { terminal.scrollToBottom(); } - void api.terminal - .resize({ - threadId, - terminalId, - cols: terminal.cols, - rows: terminal.rows, - }) - .catch(() => undefined); + void resizeTerminal(terminal.cols, terminal.rows).catch(() => undefined); }); return () => { window.cancelAnimationFrame(frame); diff --git a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts deleted file mode 100644 index a9673a96ee9..00000000000 --- a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import type { WsConnectionStatus } from "../rpc/wsConnectionState"; -import { shouldAutoReconnect, shouldRestartStalledReconnect } from "./WebSocketConnectionSurface"; - -function makeStatus(overrides: Partial = {}): WsConnectionStatus { - return { - attemptCount: 0, - closeCode: null, - closeReason: null, - connectionLabel: null, - connectedAt: null, - disconnectedAt: null, - hasConnected: false, - lastError: null, - lastErrorAt: null, - nextRetryAt: null, - online: true, - phase: "idle", - reconnectAttemptCount: 0, - reconnectMaxAttempts: 8, - reconnectPhase: "idle", - socketUrl: null, - ...overrides, - }; -} - -describe("WebSocketConnectionSurface.logic", () => { - it("forces reconnect on online when the app was offline", () => { - expect( - shouldAutoReconnect( - makeStatus({ - disconnectedAt: "2026-04-03T20:00:00.000Z", - online: false, - phase: "disconnected", - }), - "online", - ), - ).toBe(true); - }); - - it("forces reconnect on focus only for previously connected disconnected states", () => { - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: true, - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "waiting", - }), - "focus", - ), - ).toBe(true); - - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: false, - online: true, - phase: "disconnected", - reconnectAttemptCount: 1, - reconnectPhase: "waiting", - }), - "focus", - ), - ).toBe(false); - }); - - it("forces reconnect on focus for exhausted reconnect loops", () => { - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: true, - online: true, - phase: "disconnected", - reconnectAttemptCount: 8, - reconnectPhase: "exhausted", - }), - "focus", - ), - ).toBe(true); - }); - - it("restarts a stalled reconnect window after the scheduled retry time passes", () => { - expect( - shouldRestartStalledReconnect( - makeStatus({ - hasConnected: true, - nextRetryAt: "2026-04-03T20:00:01.000Z", - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "waiting", - }), - "2026-04-03T20:00:01.000Z", - ), - ).toBe(true); - - expect( - shouldRestartStalledReconnect( - makeStatus({ - hasConnected: true, - nextRetryAt: "2026-04-03T20:00:01.000Z", - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "attempting", - }), - "2026-04-03T20:00:01.000Z", - ), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx deleted file mode 100644 index b54bd865c8b..00000000000 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; - -import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; -import { - getWsConnectionStatus, - getWsConnectionUiState, - setBrowserOnlineStatus, - type WsConnectionStatus, - type WsConnectionUiState, - useWsConnectionStatus, - WS_RECONNECT_MAX_ATTEMPTS, -} from "../rpc/wsConnectionState"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { getPrimaryEnvironmentConnection } from "../environments/runtime"; - -const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; -type WsAutoReconnectTrigger = "focus" | "online"; - -const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, { - day: "numeric", - hour: "numeric", - minute: "2-digit", - month: "short", - second: "2-digit", -}); - -function formatConnectionMoment(isoDate: string | null): string | null { - if (!isoDate) { - return null; - } - - return connectionTimeFormatter.format(new Date(isoDate)); -} - -function formatRetryCountdown(nextRetryAt: string, nowMs: number): string { - const remainingMs = Math.max(0, new Date(nextRetryAt).getTime() - nowMs); - return `${Math.max(1, Math.ceil(remainingMs / 1000))}s`; -} - -function describeOfflineToast(): string { - return "WebSocket disconnected. Waiting for network."; -} - -function formatReconnectAttemptLabel(status: WsConnectionStatus): string { - const reconnectAttempt = Math.max( - 1, - Math.min(status.reconnectAttemptCount, WS_RECONNECT_MAX_ATTEMPTS), - ); - return `Attempt ${reconnectAttempt}/${status.reconnectMaxAttempts}`; -} - -function describeExhaustedToast(): string { - return "Retries exhausted trying to reconnect"; -} - -function getConnectionDisplayName(status: WsConnectionStatus): string { - return status.connectionLabel?.trim() || "T3 Server"; -} - -function buildReconnectTitle(status: WsConnectionStatus): string { - return `Disconnected from ${getConnectionDisplayName(status)}`; -} - -function buildRecoveredTitle(status: WsConnectionStatus): string { - return `Reconnected to ${getConnectionDisplayName(status)}`; -} - -function describeRecoveredToast( - previousDisconnectedAt: string | null, - connectedAt: string | null, -): string { - const reconnectedAtLabel = formatConnectionMoment(connectedAt); - const disconnectedAtLabel = formatConnectionMoment(previousDisconnectedAt); - - if (disconnectedAtLabel && reconnectedAtLabel) { - return `Disconnected at ${disconnectedAtLabel} and reconnected at ${reconnectedAtLabel}.`; - } - - if (reconnectedAtLabel) { - return `Connection restored at ${reconnectedAtLabel}.`; - } - - return "Connection restored."; -} - -function describeSlowRpcAckToast(requests: ReadonlyArray): string { - const count = requests.length; - const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); - - return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; -} - -function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray }) { - return ( -
    - {requests.map((req) => ( -
  • -
    {req.tag}
    -
    - {req.requestId} -
    -
    - Started {formatConnectionMoment(req.startedAt) ?? req.startedAt} -
    -
  • - ))} -
- ); -} - -export function shouldAutoReconnect( - status: WsConnectionStatus, - trigger: WsAutoReconnectTrigger, -): boolean { - const uiState = getWsConnectionUiState(status); - - if (trigger === "online") { - return ( - uiState === "offline" || - uiState === "reconnecting" || - uiState === "error" || - status.reconnectPhase === "exhausted" - ); - } - - return ( - status.online && - status.hasConnected && - (uiState === "reconnecting" || status.reconnectPhase === "exhausted") - ); -} - -export function shouldRestartStalledReconnect( - status: WsConnectionStatus, - expectedNextRetryAt: string, -): boolean { - return ( - status.reconnectPhase === "waiting" && - status.nextRetryAt === expectedNextRetryAt && - status.online && - status.hasConnected - ); -} - -export function WebSocketConnectionCoordinator() { - const status = useWsConnectionStatus(); - const [nowMs, setNowMs] = useState(() => Date.now()); - const lastForcedReconnectAtRef = useRef(0); - const toastIdRef = useRef | null>(null); - const toastResetTimerRef = useRef(null); - const previousUiStateRef = useRef(getWsConnectionUiState(status)); - const previousDisconnectedAtRef = useRef(status.disconnectedAt); - - const runReconnect = useEffectEvent((showFailureToast: boolean) => { - if (toastResetTimerRef.current !== null) { - window.clearTimeout(toastResetTimerRef.current); - toastResetTimerRef.current = null; - } - lastForcedReconnectAtRef.current = Date.now(); - void getPrimaryEnvironmentConnection() - .reconnect() - .catch((error) => { - if (!showFailureToast) { - console.warn("Automatic WebSocket reconnect failed", { error }); - return; - } - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Reconnect failed", - description: - error instanceof Error ? error.message : "Unable to restart the WebSocket.", - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }), - ); - }); - }); - const syncBrowserOnlineStatus = useEffectEvent(() => { - setBrowserOnlineStatus(navigator.onLine !== false); - }); - const triggerManualReconnect = useEffectEvent(() => { - runReconnect(true); - }); - const triggerAutoReconnect = useEffectEvent((trigger: WsAutoReconnectTrigger) => { - const currentStatus = - trigger === "online" ? setBrowserOnlineStatus(true) : getWsConnectionStatus(); - - if (!shouldAutoReconnect(currentStatus, trigger)) { - return; - } - if (Date.now() - lastForcedReconnectAtRef.current < FORCED_WS_RECONNECT_DEBOUNCE_MS) { - return; - } - - runReconnect(false); - }); - - useEffect(() => { - const handleOnline = () => { - triggerAutoReconnect("online"); - }; - const handleFocus = () => { - triggerAutoReconnect("focus"); - }; - - syncBrowserOnlineStatus(); - window.addEventListener("online", handleOnline); - window.addEventListener("offline", syncBrowserOnlineStatus); - window.addEventListener("focus", handleFocus); - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", syncBrowserOnlineStatus); - window.removeEventListener("focus", handleFocus); - }; - }, []); - - useEffect(() => { - if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) { - return; - } - - setNowMs(Date.now()); - const intervalId = window.setInterval(() => { - setNowMs(Date.now()); - }, 1_000); - - return () => { - window.clearInterval(intervalId); - }; - }, [status.nextRetryAt, status.reconnectPhase]); - - useEffect(() => { - if ( - status.reconnectPhase !== "waiting" || - status.nextRetryAt === null || - !status.online || - !status.hasConnected - ) { - return; - } - - const nextRetryAt = status.nextRetryAt; - const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500; - const timeoutId = window.setTimeout(() => { - const currentStatus = getWsConnectionStatus(); - if (!shouldRestartStalledReconnect(currentStatus, nextRetryAt)) { - return; - } - - runReconnect(false); - }, timeoutMs); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [ - status.hasConnected, - status.nextRetryAt, - status.online, - status.reconnectAttemptCount, - status.reconnectPhase, - ]); - - useEffect(() => { - const uiState = getWsConnectionUiState(status); - const previousUiState = previousUiStateRef.current; - const previousDisconnectedAt = previousDisconnectedAtRef.current; - const shouldShowReconnectToast = status.hasConnected && uiState === "reconnecting"; - const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null; - const shouldShowExhaustedToast = status.hasConnected && status.reconnectPhase === "exhausted"; - - if ( - toastResetTimerRef.current !== null && - (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) - ) { - window.clearTimeout(toastResetTimerRef.current); - toastResetTimerRef.current = null; - } - - if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { - const toastPayload = shouldShowOfflineToast - ? stackedThreadToast({ - data: { - hideCopyButton: true, - }, - description: describeOfflineToast(), - timeout: 0, - title: "Offline", - type: "warning", - }) - : shouldShowExhaustedToast - ? stackedThreadToast({ - actionProps: { - children: "Retry", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: describeExhaustedToast(), - timeout: 0, - title: buildReconnectTitle(status), - type: "error", - }) - : stackedThreadToast({ - actionProps: { - children: "Retry now", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: - status.nextRetryAt === null - ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` - : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, - timeout: 0, - title: buildReconnectTitle(status), - type: "loading", - }); - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, toastPayload); - } else { - toastIdRef.current = toastManager.add(toastPayload); - } - } else if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - - if ( - uiState === "connected" && - (previousUiState === "offline" || previousUiState === "reconnecting") && - previousDisconnectedAt !== null - ) { - const successToast = { - description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), - title: buildRecoveredTitle(status), - type: "success" as const, - timeout: 0, - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }; - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, successToast); - } else { - toastIdRef.current = toastManager.add(successToast); - } - - toastResetTimerRef.current = window.setTimeout(() => { - toastIdRef.current = null; - toastResetTimerRef.current = null; - }, 8_250); - } - - previousUiStateRef.current = uiState; - previousDisconnectedAtRef.current = status.disconnectedAt; - }, [nowMs, status]); - - useEffect(() => { - return () => { - if (toastResetTimerRef.current !== null) { - window.clearTimeout(toastResetTimerRef.current); - } - }; - }, []); - - return null; -} - -export function SlowRpcAckToastCoordinator() { - const slowRequests = useSlowRpcAckRequests(); - const status = useWsConnectionStatus(); - const toastIdRef = useRef | null>(null); - - useEffect(() => { - if (getWsConnectionUiState(status) !== "connected") { - if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - return; - } - - if (slowRequests.length === 0) { - if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - return; - } - - const nextToast = { - data: { - expandableContent: , - expandableDescriptionTrigger: true, - expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, - }, - description: describeSlowRpcAckToast(slowRequests), - timeout: 0, - title: "Some requests are slow", - type: "warning" as const, - }; - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, nextToast); - } else { - toastIdRef.current = toastManager.add(nextToast); - } - }, [slowRequests, status]); - - return null; -} - -export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { - return children; -} diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 65e9c6dd8eb..6dd41e82415 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -2,7 +2,7 @@ import type { AuthSessionState } from "@t3tools/contracts"; import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; import { APP_DISPLAY_NAME } from "../../branding"; -import { addSavedEnvironment } from "../../environments/runtime"; +import { useWebEnvironmentActions } from "../../connection/useWebEnvironments"; import { peekPairingTokenFromUrl, stripPairingTokenFromUrl, @@ -162,6 +162,7 @@ export function PairingRouteSurface({ } export function HostedPairingRouteSurface() { + const { connectPairing } = useWebEnvironmentActions(); const hostedPairingRequestRef = useRef(readHostedPairingRequest()); const [status, setStatus] = useState<"pairing" | "paired" | "error">(() => hostedPairingRequestRef.current ? "pairing" : "error", @@ -198,13 +199,12 @@ export function HostedPairingRouteSurface() { tokenSubmittedRef.current = true; try { - const record = await addSavedEnvironment({ - label: request.label, + await connectPairing({ host: request.host, pairingCode: request.token, }); setStatus("paired"); - setMessage(`${record.label} is saved in this browser.`); + setMessage(`${request.label || "The environment"} is saved in this browser.`); } catch (error) { tokenSubmittedRef.current = false; setStatus("error"); @@ -213,7 +213,7 @@ export function HostedPairingRouteSurface() { `${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`, ); } - }, []); + }, [connectPairing]); useEffect(() => { if (submitAttemptedRef.current) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1185817454d..59bab87b0d3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -392,7 +392,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connectionState: "connecting" | "reconnecting" | "disconnected" | "error"; } | null; // Pending approvals / inputs @@ -2251,7 +2251,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? `${environmentUnavailable.label} is ${ environmentUnavailable.connectionState === "connecting" ? "connecting" - : "disconnected" + : environmentUnavailable.connectionState === "reconnecting" + ? "reconnecting" + : "disconnected" }` : phase === "disconnected" ? "Ask for follow-up changes or attach images" diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ac420317530..f3e88e5e01c 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -16,7 +16,7 @@ import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScr import { Toggle } from "../ui/toggle"; import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; -import { usePrimaryEnvironmentId } from "../../environments/primary"; +import { useWebPrimaryEnvironment } from "../../connection/useWebEnvironments"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -81,7 +81,7 @@ export const ChatHeader = memo(function ChatHeader({ onToggleTerminal, onToggleDiff, }: ChatHeaderProps) { - const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryEnvironmentId = useWebPrimaryEnvironment()?.environmentId ?? null; const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, @@ -126,6 +126,7 @@ export const ChatHeader = memo(function ChatHeader({ )} {showOpenInPicker && ( ) => { const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ @@ -151,14 +151,17 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray; openInCwd: string | null; }) { + const actions = useWebActions(); const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); const options = useMemo( () => resolveOptions(navigator.platform, availableEditors), @@ -168,14 +171,19 @@ export const OpenInPicker = memo(function OpenInPicker({ const openInEditor = useCallback( (editorId: EditorId | null) => { - const api = readLocalApi(); - if (!api || !openInCwd) return; + if (!openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; - void api.shell.openInEditor(openInCwd, editor); + void actions.shell.openInEditor({ + environmentId, + input: { + cwd: openInCwd, + editor, + }, + }); setPreferredEditor(editor); }, - [preferredEditor, openInCwd, setPreferredEditor], + [actions.shell, environmentId, openInCwd, preferredEditor, setPreferredEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -185,17 +193,22 @@ export const OpenInPicker = memo(function OpenInPicker({ useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - const api = readLocalApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !openInCwd) return; + if (!openInCwd) return; if (!preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, preferredEditor); + void actions.shell.openInEditor({ + environmentId, + input: { + cwd: openInCwd, + editor: preferredEditor, + }, + }); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [preferredEditor, keybindings, openInCwd]); + }, [actions.shell, environmentId, keybindings, openInCwd, preferredEditor]); return ( diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index e53ee93b913..bd18c019b9f 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -25,7 +25,7 @@ import { DialogTitle, } from "../ui/dialog"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { readEnvironmentApi } from "~/environmentApi"; +import { useWebActions } from "~/connection/useWebEnvironmentData"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; export const ProposedPlanCard = memo(function ProposedPlanCard({ @@ -43,6 +43,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [savePath, setSavePath] = useState(""); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const actions = useWebActions(); const { copyToClipboard, isCopied } = useCopyToClipboard({ onError: (error) => { toastManager.add( @@ -89,9 +90,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }; const handleSaveToWorkspace = () => { - const api = readEnvironmentApi(environmentId); const relativePath = savePath.trim(); - if (!api || !workspaceRoot) { + if (!workspaceRoot) { return; } if (!relativePath) { @@ -103,11 +103,14 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ } setIsSavingToWorkspace(true); - void api.projects + void actions.projects .writeFile({ - cwd: workspaceRoot, - relativePath, - contents: saveContents, + environmentId, + input: { + cwd: workspaceRoot, + relativePath, + contents: saveContents, + }, }) .then((result) => { setIsSaveDialogOpen(false); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index c84eb347352..933354dcc57 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,5 +1,4 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { EnvironmentId } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { page, userEvent } from "vite-plus/test/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -19,63 +18,6 @@ import { } from "@t3tools/contracts/settings"; import { __resetLocalApiForTests } from "../../localApi"; -// Mock the environments/runtime module to provide a mock primary environment connection -vi.mock("../../environments/runtime", () => { - const primaryConnection = { - kind: "primary" as const, - knownEnvironment: { - id: "environment-local", - label: "Local environment", - source: "manual" as const, - environmentId: EnvironmentId.make("environment-local"), - target: { - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - }, - }, - environmentId: EnvironmentId.make("environment-local"), - client: { - server: { - getConfig: vi.fn(), - updateSettings: vi.fn(), - }, - }, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }; - - return { - getEnvironmentHttpBaseUrl: () => "http://localhost:3000", - getSavedEnvironmentRecord: () => null, - getSavedEnvironmentRuntimeState: () => null, - hasSavedEnvironmentRegistryHydrated: () => true, - listSavedEnvironmentRecords: () => [], - resetSavedEnvironmentRegistryStoreForTests: vi.fn(), - resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), - resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => - new URL(path, "http://localhost:3000").toString(), - waitForSavedEnvironmentRegistryHydration: async () => undefined, - addSavedEnvironment: vi.fn(), - disconnectSavedEnvironment: vi.fn(), - ensureEnvironmentConnectionBootstrapped: async () => undefined, - getPrimaryEnvironmentConnection: () => primaryConnection, - readEnvironmentConnection: () => primaryConnection, - reconnectSavedEnvironment: vi.fn(), - removeSavedEnvironment: vi.fn(), - requireEnvironmentConnection: () => primaryConnection, - resetEnvironmentServiceForTests: vi.fn(), - startEnvironmentConnectionService: vi.fn(), - subscribeEnvironmentConnections: () => () => {}, - useSavedEnvironmentRegistryStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - useSavedEnvironmentRuntimeStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - }; -}); - function selectDescriptor( id: string, label: string, diff --git a/apps/web/src/components/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx index c2a1541b120..0d47197e1a7 100644 --- a/apps/web/src/components/settings/CloudSettings.tsx +++ b/apps/web/src/components/settings/CloudSettings.tsx @@ -101,7 +101,15 @@ function CloudSettingsPanelInner() { const updatePublishAgentActivity = async (enabled: boolean) => { setIsUpdatingPreference(true); try { - await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled })); + if (!primaryLinkState.target) { + throw new Error("Local environment is not ready yet."); + } + await webRuntime.runPromise( + updatePrimaryCloudPreferences({ + target: primaryLinkState.target, + publishAgentActivity: enabled, + }), + ); primaryLinkState.refresh(); toastManager.add({ type: "success", diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 9dab7b61fac..75bf4eeb15c 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -8,6 +8,7 @@ import { TriangleAlertIcon, } from "lucide-react"; import { useAuth } from "@clerk/react"; +import { useAtomSet } from "@effect/atom-react"; import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; import { AuthAccessReadScope, @@ -29,9 +30,10 @@ import { type DesktopServerExposureState, type EnvironmentId, } from "@t3tools/contracts"; -import { WsRpcClient } from "@t3tools/client-runtime"; +import { findErrorTraceId } from "@t3tools/client-runtime"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; @@ -96,39 +98,26 @@ import { revokeServerClientSession, revokeServerPairingLink, isLoopbackHostname, - usePrimaryEnvironmentId, usePrimarySessionState, type ServerClientSessionRecord, type ServerPairingLinkRecord, } from "~/environments/primary"; -import { - type SavedEnvironmentRecord, - type SavedEnvironmentRuntimeState, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - addSavedEnvironment, - addManagedRelayEnvironment, - connectDesktopSshEnvironment, - disconnectSavedEnvironment, - getPrimaryEnvironmentConnection, - reconnectSavedEnvironment, - removeSavedEnvironment, -} from "~/environments/runtime"; import { useUiStateStore } from "~/uiStateStore"; import { resolveServerConfigVersionMismatch } from "~/versionSkew"; -import { useServerConfig } from "~/rpc/serverState"; -import { - connectManagedCloudEnvironment, - linkPrimaryEnvironmentToCloud, - unlinkPrimaryEnvironmentFromCloud, -} from "~/cloud/linkEnvironment"; -import { - refreshManagedRelayEnvironments, - useManagedRelayEnvironments, -} from "~/cloud/managedRelayState"; import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; -import { webRuntime } from "~/lib/runtime"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { + linkWebPrimaryEnvironment, + unlinkWebPrimaryEnvironment, +} from "~/connection/webConnectionRuntime"; +import { useWebAuthAccessChanges } from "~/connection/useWebEnvironmentData"; +import { + type WebEnvironmentPresentation, + useWebEnvironmentActions, + useWebEnvironments, + useWebPrimaryEnvironment, + useWebRelayEnvironmentDiscovery, +} from "~/connection/useWebEnvironments"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; @@ -290,32 +279,7 @@ function ConnectionStatusDot({ ); } -function getSavedBackendStatusTooltip( - runtime: SavedEnvironmentRuntimeState | null, - record: SavedEnvironmentRecord, - nowMs: number, -) { - const connectionState = runtime?.connectionState ?? "disconnected"; - - if (connectionState === "connected") { - const connectedAt = runtime?.connectedAt ?? record.lastConnectedAt; - return connectedAt ? `Connected for ${formatElapsedDurationLabel(connectedAt, nowMs)}` : null; - } - - if (connectionState === "connecting") { - return null; - } - - if (connectionState === "error") { - return runtime?.lastError ?? "An unknown connection error occurred."; - } - - return record.lastConnectedAt - ? `Last connected at ${formatAccessTimestamp(record.lastConnectedAt)}` - : "Not connected yet."; -} - -function formatDesktopSshTarget(target: NonNullable): string { +function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; return target.port ? `${authority}:${target.port}` : authority; } @@ -1449,54 +1413,58 @@ function NetworkAccessDescription({ } type SavedBackendListRowProps = { - environmentId: EnvironmentId; + environment: WebEnvironmentPresentation; reconnectingEnvironmentId: EnvironmentId | null; - disconnectingEnvironmentId: EnvironmentId | null; removingEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; - onDisconnect: (environmentId: EnvironmentId) => void; onRemove: (environmentId: EnvironmentId) => void; }; function SavedBackendListRow({ - environmentId, + environment, reconnectingEnvironmentId, - disconnectingEnvironmentId, removingEnvironmentId, onConnect, - onDisconnect, onRemove, }: SavedBackendListRowProps) { - const nowMs = useRelativeTimeTick(1_000); - const record = useSavedEnvironmentRegistryStore((state) => state.byId[environmentId] ?? null); - const runtime = useSavedEnvironmentRuntimeStore((state) => state.byId[environmentId] ?? null); - - if (!record) { - return null; - } - - const connectionState = runtime?.connectionState ?? "disconnected"; + const environmentId = environment.environmentId; + const connectionState = environment.connection.phase; const isConnected = connectionState === "connected"; const isConnecting = - connectionState === "connecting" || reconnectingEnvironmentId === environmentId; - const isDisconnecting = disconnectingEnvironmentId === environmentId; + connectionState === "connecting" || + connectionState === "reconnecting" || + reconnectingEnvironmentId === environmentId; const stateDotClassName = connectionState === "connected" ? "bg-success" - : connectionState === "connecting" + : connectionState === "connecting" || connectionState === "reconnecting" ? "bg-warning" : connectionState === "error" ? "bg-destructive" : "bg-muted-foreground/40"; - const descriptorLabel = runtime?.descriptor?.label ?? null; - const displayLabel = descriptorLabel ?? record.label; - const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); - const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig); + const statusTooltip = + connectionState === "connected" + ? "Connected" + : connectionState === "connecting" + ? "Connecting" + : connectionState === "reconnecting" + ? "Reconnecting" + : connectionState === "offline" + ? "Offline" + : connectionState === "error" + ? environment.connection.error + : "Available"; + const errorTraceId = environment.connection.traceId; + const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); + const sshTarget = + environment.entry.target._tag === "SshConnectionTarget" && + Option.isSome(environment.entry.profile) && + environment.entry.profile.value._tag === "SshConnectionProfile" + ? environment.entry.profile.value.target + : null; const metadataBits = [ - record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, - record.lastConnectedAt - ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` - : null, + sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, + environment.relayManaged ? "T3 Cloud" : null, ].filter((value): value is string => value !== null); return ( @@ -1508,19 +1476,15 @@ function SavedBackendListRow({ tooltipText={statusTooltip} dotClassName={stateDotClassName} pingClassName={ - connectionState === "connecting" ? "bg-warning/60 duration-2000" : null + connectionState === "connecting" || connectionState === "reconnecting" + ? "bg-warning/60 duration-2000" + : null } /> -

{displayLabel}

+

{environment.label}

- {metadataBits.length > 0 || runtime?.scopes ? ( -

- {metadataBits.length > 0 ? metadataBits.join(" · ") : null} - {metadataBits.length > 0 && runtime?.scopes ? · : null} - {runtime?.scopes ? ( - - ) : null} -

+ {metadataBits.length > 0 ? ( +

{metadataBits.join(" · ")}

) : null} {versionMismatch ? (

@@ -1529,32 +1493,36 @@ function SavedBackendListRow({ {versionMismatch.serverVersion}.

) : null} + {connectionState === "error" && environment.connection.error ? ( +

+ {environment.connection.error} + {errorTraceId ? ( + + ) : null} +

+ ) : null}
-
@@ -1632,6 +1600,9 @@ function CloudLinkSwitch({ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { const { getToken, isSignedIn } = useAuth(); + const { refreshRelayEnvironments } = useWebEnvironmentActions(); + const linkPrimaryEnvironment = useAtomSet(linkWebPrimaryEnvironment, { mode: "promise" }); + const unlinkPrimaryEnvironment = useAtomSet(unlinkWebPrimaryEnvironment, { mode: "promise" }); const primaryCloudLinkState = usePrimaryCloudLinkState(); const [operationError, setOperationError] = useState(null); const [isUpdating, setIsUpdating] = useState(false); @@ -1642,17 +1613,27 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b try { const clerkToken = await getToken(resolveRelayClerkTokenOptions()); if (enabled) { + if (!primaryCloudLinkState.target) { + throw new Error("Local environment is not ready yet."); + } if (!clerkToken) { throw new Error("Sign in from T3 Cloud settings before linking this environment."); } - await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken })); + await linkPrimaryEnvironment({ + target: primaryCloudLinkState.target, + clerkToken, + }); } else { - await webRuntime.runPromise( - unlinkPrimaryEnvironmentFromCloud({ clerkToken: clerkToken ?? null }), - ); + if (!primaryCloudLinkState.target) { + throw new Error("Local environment is not ready yet."); + } + await unlinkPrimaryEnvironment({ + target: primaryCloudLinkState.target, + clerkToken: clerkToken ?? null, + }); } primaryCloudLinkState.refresh(); - refreshManagedRelayEnvironments(); + await refreshRelayEnvironments(); toastManager.add({ type: "success", title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", @@ -1662,11 +1643,21 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b }); } catch (cause) { const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; - setOperationError(message); + const traceId = findErrorTraceId(cause); + console.error("[t3-cloud] Could not update T3 Cloud", { message, traceId, cause }); + setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); toastManager.add({ type: "error", title: "Could not update T3 Cloud", description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, }); } finally { setIsUpdating(false); @@ -1743,48 +1734,62 @@ function ConfiguredCloudRemoteEnvironmentRows({ readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironmentIds: ReadonlyArray; }) { - const { getToken } = useAuth(); - const environmentsState = useManagedRelayEnvironments(); + const environmentsState = useWebRelayEnvironmentDiscovery(); + const { connectRelayEnvironment, refreshRelayEnvironments } = useWebEnvironmentActions(); const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( null, ); const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + useEffect(() => { + void refreshRelayEnvironments().catch(() => { + // The discovery state carries the typed failure for presentation. + }); + }, [refreshRelayEnvironments]); + const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { setConnectingEnvironmentId(environment.environmentId); try { - const clerkToken = await getToken(resolveRelayClerkTokenOptions()); - if (!clerkToken) { - throw new Error("Sign in from T3 Cloud settings before connecting this environment."); - } - const connection = await webRuntime.runPromise( - connectManagedCloudEnvironment({ clerkToken, environment }), - ); - await addManagedRelayEnvironment(connection); + await connectRelayEnvironment(environment); toastManager.add({ type: "success", title: "Environment connected", - description: `${connection.label} is available through T3 Cloud.`, + description: `${environment.label} is available through T3 Cloud.`, }); } catch (cause) { + const message = + cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment."; + const traceId = findErrorTraceId(cause); + console.error("[t3-cloud] Could not connect environment", { message, traceId, cause }); toastManager.add({ type: "error", title: "Could not connect environment", - description: - cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment.", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, }); } finally { setConnectingEnvironmentId(null); } }; - const connectableEnvironments = (environmentsState.data ?? []).filter( - (environment) => + const connectableEnvironments = [...environmentsState.environments.values()].filter( + ({ environment }) => environment.environmentId !== primaryEnvironmentId && !savedIds.has(environment.environmentId), ); - if (savedEnvironmentIds.length === 0 && environmentsState.data === null) { + if ( + savedEnvironmentIds.length === 0 && + environmentsState.refreshing && + environmentsState.environments.size === 0 + ) { return ; } @@ -1792,18 +1797,48 @@ function ConfiguredCloudRemoteEnvironmentRows({ return ; } - return connectableEnvironments.map((environment) => ( + return connectableEnvironments.map(({ environment, availability, error }) => (

{environment.label}

-

T3 Cloud

+

+ {availability === "online" + ? "Available · Relay online" + : availability === "offline" + ? "Available · Relay offline" + : availability === "checking" + ? "Available · Checking relay status…" + : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} +