| author | |
| committer | |
| log | e95b57dc268471c882f734546fdcd92e9a833e45 |
| tree | 09e429a3f404a1488f98bd839faa575d9c08813f |
| parent | 3bdaa6e10046945ccc08264d2d6f3b81775efcb3 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>625 files changed, 40782 insertions(+), 57237 deletions(-)
.github/workflows/ci.yml-46| ... | @@ -60,52 +60,6 @@ jobs: | ... | @@ -60,52 +60,6 @@ jobs: |
| 60 | - name: Test | 60 | - name: Test |
| 61 | run: vp run test | 61 | run: vp run test |
| 62 | 62 | ||
| 63 | test_browser: | ||
| 64 | name: Test Browser | ||
| 65 | runs-on: blacksmith-8vcpu-ubuntu-2404 | ||
| 66 | timeout-minutes: 10 | ||
| 67 | steps: | ||
| 68 | - name: Checkout | ||
| 69 | uses: actions/checkout@v6 | ||
| 70 | |||
| 71 | - name: Setup Vite+ | ||
| 72 | uses: voidzero-dev/setup-vp@v1 | ||
| 73 | with: | ||
| 74 | node-version-file: package.json | ||
| 75 | cache: true | ||
| 76 | run-install: true | ||
| 77 | |||
| 78 | - name: Cache Playwright browsers | ||
| 79 | uses: actions/cache@v5 | ||
| 80 | with: | ||
| 81 | path: ~/.cache/ms-playwright | ||
| 82 | key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} | ||
| 83 | restore-keys: | | ||
| 84 | ${{ runner.os }}-playwright- | ||
| 85 | |||
| 86 | - name: Install browser test runtime | ||
| 87 | run: vp run --filter @t3tools/web test:browser:install | ||
| 88 | |||
| 89 | - name: Browser test / Chat view | ||
| 90 | working-directory: apps/web | ||
| 91 | run: vp test run --mode browser --browser=chromium src/components/ChatView.browser.tsx | ||
| 92 | |||
| 93 | - name: Browser test / Chat markdown | ||
| 94 | working-directory: apps/web | ||
| 95 | run: vp test run --mode browser --browser=chromium src/components/ChatMarkdown.browser.tsx | ||
| 96 | |||
| 97 | - name: Browser test / Components | ||
| 98 | working-directory: apps/web | ||
| 99 | run: | | ||
| 100 | vp test run --mode browser --browser=chromium \ | ||
| 101 | src/components/GitActionsControl.browser.tsx \ | ||
| 102 | src/components/KeybindingsToast.browser.tsx \ | ||
| 103 | src/components/ThreadTerminalDrawer.browser.tsx \ | ||
| 104 | src/components/chat/MessagesTimeline.browser.tsx \ | ||
| 105 | src/components/chat/ProviderModelPicker.browser.tsx \ | ||
| 106 | src/components/chat/CompactComposerControlsMenu.browser.tsx \ | ||
| 107 | src/components/settings/SettingsPanels.browser.tsx | ||
| 108 | |||
| 109 | mobile_native_static_analysis: | 63 | mobile_native_static_analysis: |
| 110 | name: Mobile Native Static Analysis | 64 | name: Mobile Native Static Analysis |
| 111 | runs-on: blacksmith-12vcpu-macos-26 | 65 | runs-on: blacksmith-12vcpu-macos-26 |
apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts created+297| ... | @@ -0,0 +1,297 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; | ||
| 4 | import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as PlatformError from "effect/PlatformError"; | ||
| 10 | import * as Ref from "effect/Ref"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | |||
| 13 | import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | ||
| 14 | import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; | ||
| 15 | import * as DesktopConfig from "./DesktopConfig.ts"; | ||
| 16 | import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; | ||
| 17 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 18 | |||
| 19 | const textDecoder = new TextDecoder(); | ||
| 20 | const textEncoder = new TextEncoder(); | ||
| 21 | const decodeConnectionCatalog = Schema.decodeEffect( | ||
| 22 | Schema.fromJsonString(ConnectionCatalogDocument), | ||
| 23 | ); | ||
| 24 | |||
| 25 | function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean> | null = null) { | ||
| 26 | return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { | ||
| 27 | isEncryptionAvailable: Effect.succeed(available), | ||
| 28 | encryptString: (value) => Effect.succeed(textEncoder.encode(`encrypted:${value}`)), | ||
| 29 | decryptString: (value) => { | ||
| 30 | return Effect.gen(function* () { | ||
| 31 | const decoded = textDecoder.decode(value); | ||
| 32 | if ( | ||
| 33 | !decoded.startsWith("encrypted:") || | ||
| 34 | (failDecrypt !== null && (yield* Ref.get(failDecrypt))) | ||
| 35 | ) { | ||
| 36 | return yield* new ElectronSafeStorage.ElectronSafeStorageDecryptError({ | ||
| 37 | cause: new Error("invalid encrypted catalog"), | ||
| 38 | }); | ||
| 39 | } | ||
| 40 | return decoded.slice("encrypted:".length); | ||
| 41 | }); | ||
| 42 | }, | ||
| 43 | } satisfies ElectronSafeStorage.ElectronSafeStorageShape); | ||
| 44 | } | ||
| 45 | |||
| 46 | function makeLayer( | ||
| 47 | baseDir: string, | ||
| 48 | encryptionAvailable = true, | ||
| 49 | failDecrypt: Ref.Ref<boolean> | null = null, | ||
| 50 | fileSystemLayer: Layer.Layer<FileSystem.FileSystem> = NodeServices.layer, | ||
| 51 | ) { | ||
| 52 | const environmentLayer = DesktopEnvironment.layer({ | ||
| 53 | dirname: "/repo/apps/desktop/src", | ||
| 54 | homeDirectory: baseDir, | ||
| 55 | platform: "darwin", | ||
| 56 | processArch: "arm64", | ||
| 57 | appVersion: "1.2.3", | ||
| 58 | appPath: "/repo", | ||
| 59 | isPackaged: true, | ||
| 60 | resourcesPath: "/missing/resources", | ||
| 61 | runningUnderArm64Translation: false, | ||
| 62 | }).pipe( | ||
| 63 | Layer.provide( | ||
| 64 | Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), | ||
| 65 | ), | ||
| 66 | ); | ||
| 67 | const safeStorageLayer = makeSafeStorageLayer(encryptionAvailable, failDecrypt); | ||
| 68 | const dependencies = Layer.mergeAll( | ||
| 69 | environmentLayer, | ||
| 70 | safeStorageLayer, | ||
| 71 | NodeServices.layer, | ||
| 72 | fileSystemLayer, | ||
| 73 | ); | ||
| 74 | const savedEnvironmentsLayer = DesktopSavedEnvironments.layer.pipe( | ||
| 75 | Layer.provideMerge(dependencies), | ||
| 76 | ); | ||
| 77 | |||
| 78 | return DesktopConnectionCatalogStore.layer.pipe( | ||
| 79 | Layer.provideMerge(savedEnvironmentsLayer), | ||
| 80 | Layer.provideMerge(dependencies), | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | |||
| 84 | const withStore = <A, E, R>( | ||
| 85 | effect: Effect.Effect<A, E, R | DesktopConnectionCatalogStore.DesktopConnectionCatalogStore>, | ||
| 86 | encryptionAvailable = true, | ||
| 87 | ) => | ||
| 88 | Effect.gen(function* () { | ||
| 89 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 90 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 91 | prefix: "t3-desktop-connection-catalog-test-", | ||
| 92 | }); | ||
| 93 | return yield* effect.pipe(Effect.provide(makeLayer(baseDir, encryptionAvailable))); | ||
| 94 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | ||
| 95 | |||
| 96 | describe("DesktopConnectionCatalogStore", () => { | ||
| 97 | it.effect("persists, reads, and clears an encrypted connection catalog", () => | ||
| 98 | withStore( | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; | ||
| 101 | const catalog = '{"schemaVersion":1,"targets":[]}'; | ||
| 102 | |||
| 103 | assert.isTrue(yield* store.set(catalog)); | ||
| 104 | assert.deepStrictEqual(yield* store.get, Option.some(catalog)); | ||
| 105 | |||
| 106 | yield* store.clear; | ||
| 107 | assert.deepStrictEqual(yield* store.get, Option.none()); | ||
| 108 | }), | ||
| 109 | ), | ||
| 110 | ); | ||
| 111 | |||
| 112 | it.effect("does not persist when secure storage is unavailable", () => | ||
| 113 | withStore( | ||
| 114 | Effect.gen(function* () { | ||
| 115 | const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; | ||
| 116 | assert.isFalse(yield* store.set("{}")); | ||
| 117 | assert.deepStrictEqual(yield* store.get, Option.none()); | ||
| 118 | }), | ||
| 119 | false, | ||
| 120 | ), | ||
| 121 | ); | ||
| 122 | |||
| 123 | it.effect("migrates legacy relay, SSH, bearer profile, and credential data", () => | ||
| 124 | withStore( | ||
| 125 | Effect.gen(function* () { | ||
| 126 | const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; | ||
| 127 | const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | ||
| 128 | const records: readonly PersistedSavedEnvironmentRecord[] = [ | ||
| 129 | { | ||
| 130 | environmentId: EnvironmentId.make("relay-environment"), | ||
| 131 | label: "Relay", | ||
| 132 | httpBaseUrl: "https://relay.example.com/", | ||
| 133 | wsBaseUrl: "wss://relay.example.com/", | ||
| 134 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 135 | lastConnectedAt: null, | ||
| 136 | relayManaged: { relayUrl: "https://relay-control.example.com/" }, | ||
| 137 | }, | ||
| 138 | { | ||
| 139 | environmentId: EnvironmentId.make("ssh-environment"), | ||
| 140 | label: "SSH", | ||
| 141 | httpBaseUrl: "http://127.0.0.1:41773/", | ||
| 142 | wsBaseUrl: "ws://127.0.0.1:41773/", | ||
| 143 | createdAt: "2026-06-02T00:00:00.000Z", | ||
| 144 | lastConnectedAt: null, | ||
| 145 | desktopSsh: { | ||
| 146 | alias: "devbox", | ||
| 147 | hostname: "devbox.example.com", | ||
| 148 | username: "julius", | ||
| 149 | port: 22, | ||
| 150 | }, | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | environmentId: EnvironmentId.make("bearer-environment"), | ||
| 154 | label: "Bearer", | ||
| 155 | httpBaseUrl: "https://bearer.example.com/", | ||
| 156 | wsBaseUrl: "wss://bearer.example.com/", | ||
| 157 | createdAt: "2026-06-03T00:00:00.000Z", | ||
| 158 | lastConnectedAt: null, | ||
| 159 | }, | ||
| 160 | ]; | ||
| 161 | yield* savedEnvironments.setRegistry(records); | ||
| 162 | assert.isTrue( | ||
| 163 | yield* savedEnvironments.setSecret({ | ||
| 164 | environmentId: EnvironmentId.make("bearer-environment"), | ||
| 165 | secret: "legacy-token", | ||
| 166 | }), | ||
| 167 | ); | ||
| 168 | |||
| 169 | const migrated = yield* store.get; | ||
| 170 | assert.isTrue(Option.isSome(migrated)); | ||
| 171 | if (Option.isNone(migrated)) { | ||
| 172 | return; | ||
| 173 | } | ||
| 174 | const catalog = yield* decodeConnectionCatalog(migrated.value); | ||
| 175 | |||
| 176 | assert.deepInclude(catalog.targets[0], { | ||
| 177 | _tag: "RelayConnectionTarget", | ||
| 178 | environmentId: EnvironmentId.make("relay-environment"), | ||
| 179 | label: "Relay", | ||
| 180 | }); | ||
| 181 | assert.deepInclude(catalog.targets[1], { | ||
| 182 | _tag: "SshConnectionTarget", | ||
| 183 | environmentId: EnvironmentId.make("ssh-environment"), | ||
| 184 | label: "SSH", | ||
| 185 | connectionId: "ssh:ssh-environment", | ||
| 186 | }); | ||
| 187 | assert.deepInclude(catalog.targets[2], { | ||
| 188 | _tag: "BearerConnectionTarget", | ||
| 189 | environmentId: EnvironmentId.make("bearer-environment"), | ||
| 190 | label: "Bearer", | ||
| 191 | connectionId: "bearer:bearer-environment", | ||
| 192 | }); | ||
| 193 | assert.deepInclude(catalog.profiles[0], { | ||
| 194 | _tag: "SshConnectionProfile", | ||
| 195 | connectionId: "ssh:ssh-environment", | ||
| 196 | environmentId: EnvironmentId.make("ssh-environment"), | ||
| 197 | label: "SSH", | ||
| 198 | target: { | ||
| 199 | alias: "devbox", | ||
| 200 | hostname: "devbox.example.com", | ||
| 201 | username: "julius", | ||
| 202 | port: 22, | ||
| 203 | }, | ||
| 204 | }); | ||
| 205 | assert.deepInclude(catalog.profiles[1], { | ||
| 206 | _tag: "BearerConnectionProfile", | ||
| 207 | connectionId: "bearer:bearer-environment", | ||
| 208 | environmentId: EnvironmentId.make("bearer-environment"), | ||
| 209 | label: "Bearer", | ||
| 210 | httpBaseUrl: "https://bearer.example.com/", | ||
| 211 | wsBaseUrl: "wss://bearer.example.com/", | ||
| 212 | }); | ||
| 213 | assert.equal(catalog.credentials.length, 1); | ||
| 214 | assert.equal(catalog.credentials[0]?.connectionId, "bearer:bearer-environment"); | ||
| 215 | assert.equal(catalog.credentials[0]?.credential._tag, "BearerConnectionCredential"); | ||
| 216 | if (catalog.credentials[0]?.credential._tag === "BearerConnectionCredential") { | ||
| 217 | assert.equal(catalog.credentials[0].credential.token, "legacy-token"); | ||
| 218 | } | ||
| 219 | |||
| 220 | yield* savedEnvironments.setRegistry([]); | ||
| 221 | assert.deepEqual(yield* store.get, migrated); | ||
| 222 | }), | ||
| 223 | ), | ||
| 224 | ); | ||
| 225 | |||
| 226 | it.effect("surfaces malformed catalog documents without deleting them", () => | ||
| 227 | withStore( | ||
| 228 | Effect.gen(function* () { | ||
| 229 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 230 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 231 | const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; | ||
| 232 | const catalogPath = `${environment.stateDir}/connection-catalog.json`; | ||
| 233 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | ||
| 234 | yield* fileSystem.writeFileString(catalogPath, "{not-json"); | ||
| 235 | |||
| 236 | const error = yield* store.get.pipe(Effect.flip); | ||
| 237 | assert.instanceOf( | ||
| 238 | error, | ||
| 239 | DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, | ||
| 240 | ); | ||
| 241 | assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json"); | ||
| 242 | }), | ||
| 243 | ), | ||
| 244 | ); | ||
| 245 | |||
| 246 | it.effect("surfaces catalog filesystem failures instead of treating them as missing", () => | ||
| 247 | Effect.gen(function* () { | ||
| 248 | const baseFileSystem = yield* FileSystem.FileSystem; | ||
| 249 | const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ | ||
| 250 | prefix: "t3-desktop-connection-catalog-test-", | ||
| 251 | }); | ||
| 252 | const permissionError = PlatformError.systemError({ | ||
| 253 | _tag: "PermissionDenied", | ||
| 254 | module: "FileSystem", | ||
| 255 | method: "readFileString", | ||
| 256 | pathOrDescriptor: `${baseDir}/connection-catalog.json`, | ||
| 257 | }); | ||
| 258 | const fileSystemLayer = Layer.succeed( | ||
| 259 | FileSystem.FileSystem, | ||
| 260 | FileSystem.makeNoop({ | ||
| 261 | readFileString: () => Effect.fail(permissionError), | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( | ||
| 265 | Effect.provide(makeLayer(baseDir, true, null, fileSystemLayer)), | ||
| 266 | ); | ||
| 267 | |||
| 268 | const error = yield* store.get.pipe(Effect.flip); | ||
| 269 | assert.instanceOf( | ||
| 270 | error, | ||
| 271 | DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, | ||
| 272 | ); | ||
| 273 | assert.equal(error.cause, permissionError); | ||
| 274 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 275 | ); | ||
| 276 | |||
| 277 | it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => | ||
| 278 | Effect.gen(function* () { | ||
| 279 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 280 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 281 | prefix: "t3-desktop-connection-catalog-test-", | ||
| 282 | }); | ||
| 283 | const failDecrypt = yield* Ref.make(false); | ||
| 284 | const layer = makeLayer(baseDir, true, failDecrypt); | ||
| 285 | const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( | ||
| 286 | Effect.provide(layer), | ||
| 287 | ); | ||
| 288 | |||
| 289 | assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}')); | ||
| 290 | yield* Ref.set(failDecrypt, true); | ||
| 291 | const error = yield* store.get.pipe(Effect.flip); | ||
| 292 | assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageDecryptError); | ||
| 293 | yield* Ref.set(failDecrypt, false); | ||
| 294 | assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); | ||
| 295 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 296 | ); | ||
| 297 | }); | ||
apps/desktop/src/app/DesktopConnectionCatalogStore.ts created+328| ... | @@ -0,0 +1,328 @@ | ||
| 1 | import { | ||
| 2 | BearerConnectionCredential, | ||
| 3 | BearerConnectionProfile, | ||
| 4 | BearerConnectionTarget, | ||
| 5 | RelayConnectionTarget, | ||
| 6 | SshConnectionProfile, | ||
| 7 | SshConnectionTarget, | ||
| 8 | } from "@t3tools/client-runtime/connection"; | ||
| 9 | import { | ||
| 10 | ConnectionCatalogDocument as RuntimeConnectionCatalogDocument, | ||
| 11 | type ConnectionCatalogDocument as RuntimeConnectionCatalogDocumentType, | ||
| 12 | } from "@t3tools/client-runtime/platform"; | ||
| 13 | import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; | ||
| 14 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | ||
| 15 | import * as Context from "effect/Context"; | ||
| 16 | import * as Crypto from "effect/Crypto"; | ||
| 17 | import * as Data from "effect/Data"; | ||
| 18 | import * as Effect from "effect/Effect"; | ||
| 19 | import * as Encoding from "effect/Encoding"; | ||
| 20 | import * as FileSystem from "effect/FileSystem"; | ||
| 21 | import * as Layer from "effect/Layer"; | ||
| 22 | import * as Option from "effect/Option"; | ||
| 23 | import * as Path from "effect/Path"; | ||
| 24 | import * as PlatformError from "effect/PlatformError"; | ||
| 25 | import * as Schema from "effect/Schema"; | ||
| 26 | |||
| 27 | import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | ||
| 28 | import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; | ||
| 29 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 30 | |||
| 31 | const EncryptedConnectionCatalogDocument = Schema.Struct({ | ||
| 32 | version: Schema.Literal(1), | ||
| 33 | encryptedCatalog: Schema.String, | ||
| 34 | }); | ||
| 35 | type EncryptedConnectionCatalogDocument = typeof EncryptedConnectionCatalogDocument.Type; | ||
| 36 | |||
| 37 | const EncryptedConnectionCatalogDocumentJson = fromLenientJson(EncryptedConnectionCatalogDocument); | ||
| 38 | const decodeEncryptedConnectionCatalogDocumentJson = Schema.decodeEffect( | ||
| 39 | EncryptedConnectionCatalogDocumentJson, | ||
| 40 | ); | ||
| 41 | const encodeEncryptedConnectionCatalogDocumentJson = Schema.encodeEffect( | ||
| 42 | EncryptedConnectionCatalogDocumentJson, | ||
| 43 | ); | ||
| 44 | const RuntimeConnectionCatalogDocumentJson = Schema.fromJsonString( | ||
| 45 | RuntimeConnectionCatalogDocument, | ||
| 46 | ); | ||
| 47 | const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect( | ||
| 48 | RuntimeConnectionCatalogDocumentJson, | ||
| 49 | ); | ||
| 50 | |||
| 51 | export class DesktopConnectionCatalogStoreWriteError extends Data.TaggedError( | ||
| 52 | "DesktopConnectionCatalogStoreWriteError", | ||
| 53 | )<{ | ||
| 54 | readonly cause: PlatformError.PlatformError | Schema.SchemaError; | ||
| 55 | }> { | ||
| 56 | override get message() { | ||
| 57 | return `Failed to write desktop connection catalog: ${this.cause.message}`; | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | export class DesktopConnectionCatalogStoreDecodeError extends Data.TaggedError( | ||
| 62 | "DesktopConnectionCatalogStoreDecodeError", | ||
| 63 | )<{ | ||
| 64 | readonly cause: Encoding.EncodingError; | ||
| 65 | }> { | ||
| 66 | override get message() { | ||
| 67 | return "Failed to decode the desktop connection catalog."; | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | export class DesktopConnectionCatalogStoreReadError extends Data.TaggedError( | ||
| 72 | "DesktopConnectionCatalogStoreReadError", | ||
| 73 | )<{ | ||
| 74 | readonly cause: PlatformError.PlatformError | Schema.SchemaError; | ||
| 75 | }> { | ||
| 76 | override get message() { | ||
| 77 | return `Failed to read desktop connection catalog: ${this.cause.message}`; | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | export class DesktopConnectionCatalogStoreMigrationError extends Data.TaggedError( | ||
| 82 | "DesktopConnectionCatalogStoreMigrationError", | ||
| 83 | )<{ | ||
| 84 | readonly cause: unknown; | ||
| 85 | }> { | ||
| 86 | override get message() { | ||
| 87 | return "Failed to migrate legacy desktop saved environments."; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | export interface DesktopConnectionCatalogStoreShape { | ||
| 92 | readonly get: Effect.Effect< | ||
| 93 | Option.Option<string>, | ||
| 94 | | DesktopConnectionCatalogStoreReadError | ||
| 95 | | DesktopConnectionCatalogStoreDecodeError | ||
| 96 | | DesktopConnectionCatalogStoreMigrationError | ||
| 97 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ||
| 98 | | ElectronSafeStorage.ElectronSafeStorageDecryptError | ||
| 99 | >; | ||
| 100 | readonly set: ( | ||
| 101 | catalog: string, | ||
| 102 | ) => Effect.Effect< | ||
| 103 | boolean, | ||
| 104 | | DesktopConnectionCatalogStoreWriteError | ||
| 105 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ||
| 106 | | ElectronSafeStorage.ElectronSafeStorageEncryptError | ||
| 107 | >; | ||
| 108 | readonly clear: Effect.Effect<void>; | ||
| 109 | } | ||
| 110 | |||
| 111 | export class DesktopConnectionCatalogStore extends Context.Service< | ||
| 112 | DesktopConnectionCatalogStore, | ||
| 113 | DesktopConnectionCatalogStoreShape | ||
| 114 | >()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} | ||
| 115 | |||
| 116 | function decodeSecretBytes( | ||
| 117 | encoded: string, | ||
| 118 | ): Effect.Effect<Uint8Array, DesktopConnectionCatalogStoreDecodeError> { | ||
| 119 | return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( | ||
| 120 | Effect.mapError((cause) => new DesktopConnectionCatalogStoreDecodeError({ cause })), | ||
| 121 | ); | ||
| 122 | } | ||
| 123 | |||
| 124 | const readDocument = ( | ||
| 125 | fileSystem: FileSystem.FileSystem, | ||
| 126 | catalogPath: string, | ||
| 127 | ): Effect.Effect< | ||
| 128 | Option.Option<EncryptedConnectionCatalogDocument>, | ||
| 129 | PlatformError.PlatformError | Schema.SchemaError | ||
| 130 | > => | ||
| 131 | fileSystem.readFileString(catalogPath).pipe( | ||
| 132 | Effect.catch((error) => | ||
| 133 | error.reason._tag === "NotFound" ? Effect.succeed<string | null>(null) : Effect.fail(error), | ||
| 134 | ), | ||
| 135 | Effect.flatMap((raw) => | ||
| 136 | raw === null | ||
| 137 | ? Effect.succeed(Option.none<EncryptedConnectionCatalogDocument>()) | ||
| 138 | : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe(Effect.map(Option.some)), | ||
| 139 | ), | ||
| 140 | ); | ||
| 141 | |||
| 142 | const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(function* (input: { | ||
| 143 | readonly fileSystem: FileSystem.FileSystem; | ||
| 144 | readonly path: Path.Path; | ||
| 145 | readonly catalogPath: string; | ||
| 146 | readonly document: EncryptedConnectionCatalogDocument; | ||
| 147 | readonly suffix: string; | ||
| 148 | }): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> { | ||
| 149 | const directory = input.path.dirname(input.catalogPath); | ||
| 150 | const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`; | ||
| 151 | const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document); | ||
| 152 | yield* input.fileSystem.makeDirectory(directory, { recursive: true }); | ||
| 153 | yield* Effect.gen(function* () { | ||
| 154 | yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); | ||
| 155 | yield* input.fileSystem.rename(tempPath, input.catalogPath); | ||
| 156 | }).pipe( | ||
| 157 | Effect.ensuring( | ||
| 158 | input.fileSystem.remove(tempPath, { force: true }).pipe( | ||
| 159 | Effect.catch((error) => | ||
| 160 | Effect.logWarning("Could not remove a temporary connection catalog file.", { | ||
| 161 | tempPath, | ||
| 162 | error, | ||
| 163 | }), | ||
| 164 | ), | ||
| 165 | ), | ||
| 166 | ), | ||
| 167 | ); | ||
| 168 | }); | ||
| 169 | |||
| 170 | function connectionId(prefix: "bearer" | "ssh", environmentId: string): string { | ||
| 171 | return `${prefix}:${environmentId}`; | ||
| 172 | } | ||
| 173 | |||
| 174 | const migrateSavedEnvironmentRecords = Effect.fn( | ||
| 175 | "desktop.connectionCatalogStore.migrateSavedEnvironmentRecords", | ||
| 176 | )(function* ( | ||
| 177 | records: readonly PersistedSavedEnvironmentRecord[], | ||
| 178 | savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironmentsShape, | ||
| 179 | ): Effect.fn.Return< | ||
| 180 | RuntimeConnectionCatalogDocumentType, | ||
| 181 | DesktopSavedEnvironments.DesktopSavedEnvironmentsGetSecretError | ||
| 182 | > { | ||
| 183 | const targets: Array<RuntimeConnectionCatalogDocumentType["targets"][number]> = []; | ||
| 184 | const profiles: Array<RuntimeConnectionCatalogDocumentType["profiles"][number]> = []; | ||
| 185 | const credentials: Array<RuntimeConnectionCatalogDocumentType["credentials"][number]> = []; | ||
| 186 | |||
| 187 | for (const record of records) { | ||
| 188 | if (record.relayManaged !== undefined) { | ||
| 189 | targets.push( | ||
| 190 | new RelayConnectionTarget({ | ||
| 191 | environmentId: record.environmentId, | ||
| 192 | label: record.label, | ||
| 193 | }), | ||
| 194 | ); | ||
| 195 | continue; | ||
| 196 | } | ||
| 197 | |||
| 198 | if (record.desktopSsh !== undefined) { | ||
| 199 | const id = connectionId("ssh", record.environmentId); | ||
| 200 | targets.push( | ||
| 201 | new SshConnectionTarget({ | ||
| 202 | environmentId: record.environmentId, | ||
| 203 | label: record.label, | ||
| 204 | connectionId: id, | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | profiles.push( | ||
| 208 | new SshConnectionProfile({ | ||
| 209 | connectionId: id, | ||
| 210 | environmentId: record.environmentId, | ||
| 211 | label: record.label, | ||
| 212 | target: record.desktopSsh, | ||
| 213 | }), | ||
| 214 | ); | ||
| 215 | continue; | ||
| 216 | } | ||
| 217 | |||
| 218 | const id = connectionId("bearer", record.environmentId); | ||
| 219 | targets.push( | ||
| 220 | new BearerConnectionTarget({ | ||
| 221 | environmentId: record.environmentId, | ||
| 222 | label: record.label, | ||
| 223 | connectionId: id, | ||
| 224 | }), | ||
| 225 | ); | ||
| 226 | profiles.push( | ||
| 227 | new BearerConnectionProfile({ | ||
| 228 | connectionId: id, | ||
| 229 | environmentId: record.environmentId, | ||
| 230 | label: record.label, | ||
| 231 | httpBaseUrl: record.httpBaseUrl, | ||
| 232 | wsBaseUrl: record.wsBaseUrl, | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | const token = yield* savedEnvironments.getSecret(record.environmentId); | ||
| 236 | if (Option.isSome(token)) { | ||
| 237 | credentials.push({ | ||
| 238 | connectionId: id, | ||
| 239 | credential: new BearerConnectionCredential({ token: token.value }), | ||
| 240 | }); | ||
| 241 | } | ||
| 242 | } | ||
| 243 | |||
| 244 | return { | ||
| 245 | schemaVersion: 1, | ||
| 246 | targets, | ||
| 247 | profiles, | ||
| 248 | credentials, | ||
| 249 | remoteDpopTokens: [], | ||
| 250 | }; | ||
| 251 | }); | ||
| 252 | |||
| 253 | export const layer = Layer.effect( | ||
| 254 | DesktopConnectionCatalogStore, | ||
| 255 | Effect.gen(function* () { | ||
| 256 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 257 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 258 | const path = yield* Path.Path; | ||
| 259 | const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; | ||
| 260 | const crypto = yield* Crypto.Crypto; | ||
| 261 | const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | ||
| 262 | const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); | ||
| 263 | |||
| 264 | const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( | ||
| 265 | catalog: string, | ||
| 266 | ) { | ||
| 267 | const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog)); | ||
| 268 | const suffix = (yield* crypto.randomUUIDv4.pipe( | ||
| 269 | Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })), | ||
| 270 | )).replace(/-/g, ""); | ||
| 271 | yield* writeDocument({ | ||
| 272 | fileSystem, | ||
| 273 | path, | ||
| 274 | catalogPath, | ||
| 275 | document: { version: 1, encryptedCatalog }, | ||
| 276 | suffix, | ||
| 277 | }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause }))); | ||
| 278 | }); | ||
| 279 | |||
| 280 | const migrateLegacyCatalog = Effect.gen(function* () { | ||
| 281 | if (!(yield* safeStorage.isEncryptionAvailable)) { | ||
| 282 | return Option.none<string>(); | ||
| 283 | } | ||
| 284 | const records = yield* savedEnvironments.getRegistry; | ||
| 285 | if (records.length === 0) { | ||
| 286 | return Option.none<string>(); | ||
| 287 | } | ||
| 288 | const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments); | ||
| 289 | const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog); | ||
| 290 | yield* writeCatalog(encoded); | ||
| 291 | return Option.some(encoded); | ||
| 292 | }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreMigrationError({ cause }))); | ||
| 293 | |||
| 294 | return DesktopConnectionCatalogStore.of({ | ||
| 295 | get: Effect.gen(function* () { | ||
| 296 | const document = yield* readDocument(fileSystem, catalogPath).pipe( | ||
| 297 | Effect.mapError((cause) => new DesktopConnectionCatalogStoreReadError({ cause })), | ||
| 298 | ); | ||
| 299 | if (Option.isNone(document)) { | ||
| 300 | return yield* migrateLegacyCatalog; | ||
| 301 | } | ||
| 302 | if (!(yield* safeStorage.isEncryptionAvailable)) { | ||
| 303 | return Option.none<string>(); | ||
| 304 | } | ||
| 305 | const decrypted = yield* decodeSecretBytes(document.value.encryptedCatalog).pipe( | ||
| 306 | Effect.flatMap(safeStorage.decryptString), | ||
| 307 | ); | ||
| 308 | return Option.some(decrypted); | ||
| 309 | }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), | ||
| 310 | set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { | ||
| 311 | if (!(yield* safeStorage.isEncryptionAvailable)) { | ||
| 312 | return false; | ||
| 313 | } | ||
| 314 | yield* writeCatalog(catalog); | ||
| 315 | return true; | ||
| 316 | }), | ||
| 317 | clear: fileSystem.remove(catalogPath, { force: true }).pipe( | ||
| 318 | Effect.catch((error) => | ||
| 319 | Effect.logWarning("Could not clear the desktop connection catalog.", { | ||
| 320 | catalogPath, | ||
| 321 | error, | ||
| 322 | }), | ||
| 323 | ), | ||
| 324 | Effect.withSpan("desktop.connectionCatalogStore.clear"), | ||
| 325 | ), | ||
| 326 | }); | ||
| 327 | }), | ||
| 328 | ); | ||
apps/desktop/src/electron/ElectronSafeStorage.ts+4-4| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import * as Context from "effect/Context"; | 1 | import * as Electron from "electron"; |
| 2 | import * as Data from "effect/Data"; | 2 | |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | import * as Context from "effect/Context"; | |
| 6 | import * as Electron from "electron"; | 6 | import * as Data from "effect/Data"; |
| 7 | 7 | ||
| 8 | export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( | 8 | export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( |
| 9 | "ElectronSafeStorageAvailabilityError", | 9 | "ElectronSafeStorageAvailabilityError", |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+7-11| ... | @@ -10,12 +10,10 @@ import { | ... | @@ -10,12 +10,10 @@ import { |
| 10 | } from "./methods/cloudAuth.ts"; | 10 | } from "./methods/cloudAuth.ts"; |
| 11 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; | 11 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; |
| 12 | import { | 12 | import { |
| 13 | getSavedEnvironmentRegistry, | 13 | clearConnectionCatalog, |
| 14 | getSavedEnvironmentSecret, | 14 | getConnectionCatalog, |
| 15 | removeSavedEnvironmentSecret, | 15 | setConnectionCatalog, |
| 16 | setSavedEnvironmentRegistry, | 16 | } from "./methods/connectionCatalog.ts"; |
| 17 | setSavedEnvironmentSecret, | ||
| 18 | } from "./methods/savedEnvironments.ts"; | ||
| 19 | import { | 17 | import { |
| 20 | getAdvertisedEndpoints, | 18 | getAdvertisedEndpoints, |
| 21 | getServerExposureState, | 19 | getServerExposureState, |
| ... | @@ -59,11 +57,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -59,11 +57,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 59 | 57 | ||
| 60 | yield* ipc.handle(getClientSettings); | 58 | yield* ipc.handle(getClientSettings); |
| 61 | yield* ipc.handle(setClientSettings); | 59 | yield* ipc.handle(setClientSettings); |
| 62 | yield* ipc.handle(getSavedEnvironmentRegistry); | 60 | yield* ipc.handle(getConnectionCatalog); |
| 63 | yield* ipc.handle(setSavedEnvironmentRegistry); | 61 | yield* ipc.handle(setConnectionCatalog); |
| 64 | yield* ipc.handle(getSavedEnvironmentSecret); | 62 | yield* ipc.handle(clearConnectionCatalog); |
| 65 | yield* ipc.handle(setSavedEnvironmentSecret); | ||
| 66 | yield* ipc.handle(removeSavedEnvironmentSecret); | ||
| 67 | 63 | ||
| 68 | yield* ipc.handle(discoverSshHosts); | 64 | yield* ipc.handle(discoverSshHosts); |
| 69 | yield* ipc.handle(ensureSshEnvironment); | 65 | yield* ipc.handle(ensureSshEnvironment); |
apps/desktop/src/ipc/channels.ts+3-5| ... | @@ -20,11 +20,9 @@ export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | ... | @@ -20,11 +20,9 @@ export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; |
| 20 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | 20 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; |
| 21 | export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | 21 | export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; |
| 22 | export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; | 22 | export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; |
| 23 | export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; | 23 | export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; |
| 24 | export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; | 24 | export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; |
| 25 | export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; | 25 | export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; |
| 26 | export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; | ||
| 27 | export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | ||
| 28 | export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; | 26 | export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; |
| 29 | export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; | 27 | export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; |
| 30 | export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; | 28 | export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; |
apps/desktop/src/ipc/methods/cloudAuth.ts+1-1| ... | @@ -59,7 +59,7 @@ function executeCloudAuthFetch(url: URL, input: typeof DesktopCloudAuthFetchInpu | ... | @@ -59,7 +59,7 @@ function executeCloudAuthFetch(url: URL, input: typeof DesktopCloudAuthFetchInpu |
| 59 | const method = (input.method ?? "GET") as "GET" | "POST"; | 59 | const method = (input.method ?? "GET") as "GET" | "POST"; |
| 60 | const headers = new Headers(input.headers); | 60 | const headers = new Headers(input.headers); |
| 61 | const response = yield* HttpClientRequest.make(method)(url).pipe( | 61 | const response = yield* HttpClientRequest.make(method)(url).pipe( |
| 62 | HttpClientRequest.setHeaders(headers), | 62 | HttpClientRequest.setHeaders(Object.fromEntries(headers.entries())), |
| 63 | input.body === undefined | 63 | input.body === undefined |
| 64 | ? identity | 64 | ? identity |
| 65 | : HttpClientRequest.bodyText(input.body, headers.get("content-type") ?? undefined), | 65 | : HttpClientRequest.bodyText(input.body, headers.get("content-type") ?? undefined), |
apps/desktop/src/ipc/methods/connectionCatalog.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | import * as DesktopConnectionCatalogStore from "../../app/DesktopConnectionCatalogStore.ts"; | ||
| 6 | import * as IpcChannels from "../channels.ts"; | ||
| 7 | import { makeIpcMethod } from "../DesktopIpc.ts"; | ||
| 8 | |||
| 9 | export const getConnectionCatalog = makeIpcMethod({ | ||
| 10 | channel: IpcChannels.GET_CONNECTION_CATALOG_CHANNEL, | ||
| 11 | payload: Schema.Void, | ||
| 12 | result: Schema.NullOr(Schema.String), | ||
| 13 | handler: Effect.fn("desktop.ipc.connectionCatalog.get")(function* () { | ||
| 14 | const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; | ||
| 15 | return Option.getOrNull(yield* store.get); | ||
| 16 | }), | ||
| 17 | }); | ||
| 18 | |||
| 19 | export const setConnectionCatalog = makeIpcMethod({ | ||
| 20 | channel: IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, | ||
| 21 | payload: Schema.String, | ||
| 22 | result: Schema.Boolean, | ||
| 23 | handler: Effect.fn("desktop.ipc.connectionCatalog.set")(function* (catalog) { | ||
| 24 | const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; | ||
| 25 | return yield* store.set(catalog); | ||
| 26 | }), | ||
| 27 | }); | ||
| 28 | |||
| 29 | export const clearConnectionCatalog = makeIpcMethod({ | ||
| 30 | channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, | ||
| 31 | payload: Schema.Void, | ||
| 32 | result: Schema.Void, | ||
| 33 | handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () { | ||
| 34 | const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; | ||
| 35 | yield* store.clear; | ||
| 36 | }), | ||
| 37 | }); | ||
apps/desktop/src/ipc/methods/savedEnvironments.ts deleted-76| ... | @@ -1,76 +0,0 @@ | ||
| 1 | import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | |||
| 6 | import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts"; | ||
| 7 | import * as IpcChannels from "../channels.ts"; | ||
| 8 | import { makeIpcMethod } from "../DesktopIpc.ts"; | ||
| 9 | |||
| 10 | const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema); | ||
| 11 | const NonBlankString = Schema.String.check( | ||
| 12 | Schema.makeFilter((value) => | ||
| 13 | value.trim().length > 0 ? undefined : "Expected a non-empty string", | ||
| 14 | ), | ||
| 15 | ); | ||
| 16 | |||
| 17 | const SetSavedEnvironmentSecretInput = Schema.Struct({ | ||
| 18 | environmentId: EnvironmentId, | ||
| 19 | secret: NonBlankString, | ||
| 20 | }); | ||
| 21 | |||
| 22 | export const getSavedEnvironmentRegistry = makeIpcMethod({ | ||
| 23 | channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, | ||
| 24 | payload: Schema.Void, | ||
| 25 | result: SavedEnvironmentRegistryPayload, | ||
| 26 | handler: Effect.fn("desktop.ipc.savedEnvironments.getRegistry")(function* () { | ||
| 27 | const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | ||
| 28 | return yield* savedEnvironments.getRegistry; | ||
| 29 | }), | ||
| 30 | }); | ||
| 31 | |||
| 32 | export const setSavedEnvironmentRegistry = makeIpcMethod({ | ||
| 33 | channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, | ||
| 34 | payload: SavedEnvironmentRegistryPayload, | ||
| 35 | result: Schema.Void, | ||
| 36 | handler: Effect.fn("desktop.ipc.savedEnvironments.setRegistry")(function* (records) { | ||
| 37 | const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | ||
| 38 | yield* savedEnvironments.setRegistry(records); | ||
| 39 | }), | ||
| 40 | }); | ||
| 41 | |||
| 42 | export const getSavedEnvironmentSecret = makeIpcMethod({ | ||
| 43 | channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, | ||
| 44 | payload: EnvironmentId, | ||
| 45 | result: Schema.NullOr(Schema.String), | ||
| 46 | handler: Effect.fn("desktop.ipc.savedEnvironments.getSecret")(function* (environmentId) { | ||
| 47 | const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | ||
| 48 | return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId)); | ||
| 49 | }), | ||
| 50 | }); | ||
| 51 | |||
| 52 | export const setSavedEnvironmentSecret = makeIpcMethod({ | ||
| 53 | channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, | ||
| 54 | payload: SetSavedEnvironmentSecretInput, | ||
| 55 | result: Schema.Boolean, | ||
| 56 | handler: Effect.fn("desktop.ipc.savedEnvironments.setSecret")(function* ({ | ||
| 57 | environmentId, | ||
| 58 | secret, | ||
| 59 | }) { | ||
| 60 | const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | ||
| 61 | return yield* savedEnvironments.setSecret({ | ||
| 62 | environmentId, | ||
| 63 | secret, | ||
| 64 | }); | ||
| 65 | }), | ||
| 66 | }); | ||
| 67 | |||
| 68 | export const removeSavedEnvironmentSecret = makeIpcMethod({ | ||
| 69 | channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, | ||
| 70 | payload: EnvironmentId, | ||
| 71 | result: Schema.Void, | ||
| 72 | handler: Effect.fn("desktop.ipc.savedEnvironments.removeSecret")(function* (environmentId) { | ||
| 73 | const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | ||
| 74 | yield* savedEnvironments.removeSecret(environmentId); | ||
| 75 | }), | ||
| 76 | }); | ||
apps/desktop/src/ipc/methods/sshEnvironment.ts+2-2| ... | @@ -1,11 +1,11 @@ | ... | @@ -1,11 +1,11 @@ |
| 1 | import { | 1 | import { |
| 2 | bootstrapRemoteBearerSession, | 2 | bootstrapRemoteBearerSession, |
| 3 | fetchRemoteEnvironmentDescriptor, | ||
| 4 | fetchRemoteSessionState, | 3 | fetchRemoteSessionState, |
| 5 | issueRemoteWebSocketTicket, | 4 | issueRemoteWebSocketTicket, |
| 6 | RemoteEnvironmentAuthUndeclaredStatusError, | 5 | RemoteEnvironmentAuthUndeclaredStatusError, |
| 7 | type RemoteEnvironmentAuthError, | 6 | type RemoteEnvironmentAuthError, |
| 8 | } from "@t3tools/client-runtime"; | 7 | } from "@t3tools/client-runtime/authorization"; |
| 8 | import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; | ||
| 9 | import { | 9 | import { |
| 10 | EnvironmentAuthInvalidError, | 10 | EnvironmentAuthInvalidError, |
| 11 | DesktopDiscoveredSshHostSchema, | 11 | DesktopDiscoveredSshHostSchema, |
apps/desktop/src/main.ts+2-1| ... | @@ -29,6 +29,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; | ... | @@ -29,6 +29,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; |
| 29 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; | 29 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; |
| 30 | import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; | 30 | import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; |
| 31 | import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; | 31 | import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; |
| 32 | import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; | ||
| 32 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; | 33 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; |
| 33 | import * as DesktopAssets from "./app/DesktopAssets.ts"; | 34 | import * as DesktopAssets from "./app/DesktopAssets.ts"; |
| 34 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; | 35 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; |
| ... | @@ -117,7 +118,7 @@ const desktopFoundationLayer = Layer.mergeAll( | ... | @@ -117,7 +118,7 @@ const desktopFoundationLayer = Layer.mergeAll( |
| 117 | DesktopLifecycle.layerShutdown, | 118 | DesktopLifecycle.layerShutdown, |
| 118 | DesktopAppSettings.layer, | 119 | DesktopAppSettings.layer, |
| 119 | DesktopClientSettings.layer, | 120 | DesktopClientSettings.layer, |
| 120 | DesktopSavedEnvironments.layer, | 121 | DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), |
| 121 | DesktopCloudAuthTokenStore.layer, | 122 | DesktopCloudAuthTokenStore.layer, |
| 122 | DesktopAssets.layer, | 123 | DesktopAssets.layer, |
| 123 | DesktopObservability.layer, | 124 | DesktopObservability.layer, |
apps/desktop/src/preload.ts+4-10| ... | @@ -42,16 +42,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -42,16 +42,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 42 | getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), | 42 | getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), |
| 43 | setClientSettings: (settings) => | 43 | setClientSettings: (settings) => |
| 44 | ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), | 44 | ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), |
| 45 | getSavedEnvironmentRegistry: () => | 45 | getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), |
| 46 | ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), | 46 | setConnectionCatalog: (catalog) => |
| 47 | setSavedEnvironmentRegistry: (records) => | 47 | ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), |
| 48 | ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), | 48 | clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), |
| 49 | getSavedEnvironmentSecret: (environmentId) => | ||
| 50 | ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), | ||
| 51 | setSavedEnvironmentSecret: (environmentId, secret) => | ||
| 52 | ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), | ||
| 53 | removeSavedEnvironmentSecret: (environmentId) => | ||
| 54 | ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), | ||
| 55 | discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), | 49 | discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), |
| 56 | ensureSshEnvironment: async (target, options) => | 50 | ensureSshEnvironment: async (target, options) => |
| 57 | unwrapEnsureSshEnvironmentResult( | 51 | unwrapEnsureSshEnvironmentResult( |
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+9-4| ... | @@ -289,7 +289,7 @@ describe("DesktopSavedEnvironments", () => { | ... | @@ -289,7 +289,7 @@ describe("DesktopSavedEnvironments", () => { |
| 289 | ), | 289 | ), |
| 290 | ); | 290 | ); |
| 291 | 291 | ||
| 292 | it.effect("treats malformed saved environment documents as empty", () => | 292 | it.effect("surfaces malformed saved environment documents", () => |
| 293 | withSavedEnvironments( | 293 | withSavedEnvironments( |
| 294 | Effect.gen(function* () { | 294 | Effect.gen(function* () { |
| 295 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 295 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| ... | @@ -298,10 +298,15 @@ describe("DesktopSavedEnvironments", () => { | ... | @@ -298,10 +298,15 @@ describe("DesktopSavedEnvironments", () => { |
| 298 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | 298 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); |
| 299 | yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); | 299 | yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); |
| 300 | 300 | ||
| 301 | assert.deepEqual(yield* savedEnvironments.getRegistry, []); | 301 | const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip); |
| 302 | assert.isTrue( | 302 | assert.instanceOf( |
| 303 | Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), | 303 | registryError, |
| 304 | DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError, | ||
| 304 | ); | 305 | ); |
| 306 | const secretError = yield* savedEnvironments | ||
| 307 | .getSecret(savedRegistryRecord.environmentId) | ||
| 308 | .pipe(Effect.flip); | ||
| 309 | assert.instanceOf(secretError, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); | ||
| 305 | }), | 310 | }), |
| 306 | ), | 311 | ), |
| 307 | ); | 312 | ); |
apps/desktop/src/settings/DesktopSavedEnvironments.ts+31-14| ... | @@ -82,6 +82,16 @@ export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( | ... | @@ -82,6 +82,16 @@ export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( |
| 82 | } | 82 | } |
| 83 | } | 83 | } |
| 84 | 84 | ||
| 85 | export class DesktopSavedEnvironmentsReadError extends Data.TaggedError( | ||
| 86 | "DesktopSavedEnvironmentsReadError", | ||
| 87 | )<{ | ||
| 88 | readonly cause: PlatformError.PlatformError | Schema.SchemaError; | ||
| 89 | }> { | ||
| 90 | override get message() { | ||
| 91 | return `Failed to read desktop saved environments: ${this.cause.message}`; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 85 | export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( | 95 | export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( |
| 86 | "DesktopSavedEnvironmentSecretDecodeError", | 96 | "DesktopSavedEnvironmentSecretDecodeError", |
| 87 | )<{ | 97 | )<{ |
| ... | @@ -93,6 +103,7 @@ export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( | ... | @@ -93,6 +103,7 @@ export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( |
| 93 | } | 103 | } |
| 94 | 104 | ||
| 95 | export type DesktopSavedEnvironmentsGetSecretError = | 105 | export type DesktopSavedEnvironmentsGetSecretError = |
| 106 | | DesktopSavedEnvironmentsReadError | ||
| 96 | | DesktopSavedEnvironmentSecretDecodeError | 107 | | DesktopSavedEnvironmentSecretDecodeError |
| 97 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | 108 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError |
| 98 | | ElectronSafeStorage.ElectronSafeStorageDecryptError; | 109 | | ElectronSafeStorage.ElectronSafeStorageDecryptError; |
| ... | @@ -103,7 +114,10 @@ export type DesktopSavedEnvironmentsSetSecretError = | ... | @@ -103,7 +114,10 @@ export type DesktopSavedEnvironmentsSetSecretError = |
| 103 | | ElectronSafeStorage.ElectronSafeStorageEncryptError; | 114 | | ElectronSafeStorage.ElectronSafeStorageEncryptError; |
| 104 | 115 | ||
| 105 | export interface DesktopSavedEnvironmentsShape { | 116 | export interface DesktopSavedEnvironmentsShape { |
| 106 | readonly getRegistry: Effect.Effect<readonly PersistedSavedEnvironmentRecord[]>; | 117 | readonly getRegistry: Effect.Effect< |
| 118 | readonly PersistedSavedEnvironmentRecord[], | ||
| 119 | DesktopSavedEnvironmentsReadError | ||
| 120 | >; | ||
| 107 | readonly setRegistry: ( | 121 | readonly setRegistry: ( |
| 108 | records: readonly PersistedSavedEnvironmentRecord[], | 122 | records: readonly PersistedSavedEnvironmentRecord[], |
| 109 | ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>; | 123 | ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>; |
| ... | @@ -176,18 +190,20 @@ function normalizeSavedEnvironmentRegistryDocument( | ... | @@ -176,18 +190,20 @@ function normalizeSavedEnvironmentRegistryDocument( |
| 176 | function readRegistryDocument( | 190 | function readRegistryDocument( |
| 177 | fileSystem: FileSystem.FileSystem, | 191 | fileSystem: FileSystem.FileSystem, |
| 178 | registryPath: string, | 192 | registryPath: string, |
| 179 | ): Effect.Effect<SavedEnvironmentRegistryDocument> { | 193 | ): Effect.Effect< |
| 194 | SavedEnvironmentRegistryDocument, | ||
| 195 | PlatformError.PlatformError | Schema.SchemaError | ||
| 196 | > { | ||
| 180 | return fileSystem.readFileString(registryPath).pipe( | 197 | return fileSystem.readFileString(registryPath).pipe( |
| 181 | Effect.option, | 198 | Effect.catch((error) => |
| 182 | Effect.flatMap( | 199 | error.reason._tag === "NotFound" ? Effect.succeed<string | null>(null) : Effect.fail(error), |
| 183 | Option.match({ | 200 | ), |
| 184 | onNone: () => Effect.succeed({ version: 1, records: [] }), | 201 | Effect.flatMap((raw) => |
| 185 | onSome: (raw) => | 202 | raw === null |
| 186 | decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( | 203 | ? Effect.succeed({ version: 1, records: [] }) |
| 204 | : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( | ||
| 187 | Effect.map(normalizeSavedEnvironmentRegistryDocument), | 205 | Effect.map(normalizeSavedEnvironmentRegistryDocument), |
| 188 | Effect.orElseSucceed(() => ({ version: 1, records: [] })), | ||
| 189 | ), | 206 | ), |
| 190 | }), | ||
| 191 | ), | 207 | ), |
| 192 | ); | 208 | ); |
| 193 | } | 209 | } |
| ... | @@ -267,13 +283,14 @@ export const layer = Layer.effect( | ... | @@ -267,13 +283,14 @@ export const layer = Layer.effect( |
| 267 | Effect.map((document) => | 283 | Effect.map((document) => |
| 268 | document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), | 284 | document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), |
| 269 | ), | 285 | ), |
| 286 | Effect.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause })), | ||
| 270 | Effect.withSpan("desktop.savedEnvironments.getRegistry"), | 287 | Effect.withSpan("desktop.savedEnvironments.getRegistry"), |
| 271 | ), | 288 | ), |
| 272 | setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { | 289 | setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { |
| 273 | const currentDocument = yield* readRegistryDocument( | 290 | const currentDocument = yield* readRegistryDocument( |
| 274 | fileSystem, | 291 | fileSystem, |
| 275 | environment.savedEnvironmentRegistryPath, | 292 | environment.savedEnvironmentRegistryPath, |
| 276 | ); | 293 | ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); |
| 277 | yield* writeDocument(preserveExistingSecrets(currentDocument, records)); | 294 | yield* writeDocument(preserveExistingSecrets(currentDocument, records)); |
| 278 | }), | 295 | }), |
| 279 | getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { | 296 | getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { |
| ... | @@ -281,7 +298,7 @@ export const layer = Layer.effect( | ... | @@ -281,7 +298,7 @@ export const layer = Layer.effect( |
| 281 | const document = yield* readRegistryDocument( | 298 | const document = yield* readRegistryDocument( |
| 282 | fileSystem, | 299 | fileSystem, |
| 283 | environment.savedEnvironmentRegistryPath, | 300 | environment.savedEnvironmentRegistryPath, |
| 284 | ); | 301 | ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause }))); |
| 285 | const encoded = Option.fromNullishOr( | 302 | const encoded = Option.fromNullishOr( |
| 286 | document.records.find((record) => record.environmentId === environmentId) | 303 | document.records.find((record) => record.environmentId === environmentId) |
| 287 | ?.encryptedBearerToken, | 304 | ?.encryptedBearerToken, |
| ... | @@ -299,7 +316,7 @@ export const layer = Layer.effect( | ... | @@ -299,7 +316,7 @@ export const layer = Layer.effect( |
| 299 | const document = yield* readRegistryDocument( | 316 | const document = yield* readRegistryDocument( |
| 300 | fileSystem, | 317 | fileSystem, |
| 301 | environment.savedEnvironmentRegistryPath, | 318 | environment.savedEnvironmentRegistryPath, |
| 302 | ); | 319 | ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); |
| 303 | 320 | ||
| 304 | if (!(yield* safeStorage.isEncryptionAvailable)) { | 321 | if (!(yield* safeStorage.isEncryptionAvailable)) { |
| 305 | return false; | 322 | return false; |
| ... | @@ -331,7 +348,7 @@ export const layer = Layer.effect( | ... | @@ -331,7 +348,7 @@ export const layer = Layer.effect( |
| 331 | const document = yield* readRegistryDocument( | 348 | const document = yield* readRegistryDocument( |
| 332 | fileSystem, | 349 | fileSystem, |
| 333 | environment.savedEnvironmentRegistryPath, | 350 | environment.savedEnvironmentRegistryPath, |
| 334 | ); | 351 | ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); |
| 335 | if ( | 352 | if ( |
| 336 | !document.records.some( | 353 | !document.records.some( |
| 337 | (record) => | 354 | (record) => |
apps/mobile/app.config.ts+5| ... | @@ -131,6 +131,11 @@ const config: ExpoConfig = { | ... | @@ -131,6 +131,11 @@ const config: ExpoConfig = { |
| 131 | { | 131 | { |
| 132 | ios: { | 132 | ios: { |
| 133 | deploymentTarget: "18.0", | 133 | deploymentTarget: "18.0", |
| 134 | // AppCheckCore 11.3+ includes Swift and needs module maps for these Objective-C dependencies. | ||
| 135 | extraPods: [ | ||
| 136 | { name: "GoogleUtilities", modular_headers: true }, | ||
| 137 | { name: "RecaptchaInterop", modular_headers: true }, | ||
| 138 | ], | ||
| 134 | }, | 139 | }, |
| 135 | }, | 140 | }, |
| 136 | ], | 141 | ], |
apps/mobile/eas.json+2-1| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | { | 1 | { |
| 2 | "cli": { | 2 | "cli": { |
| 3 | "version": ">= 18.4.0", | 3 | "version": ">= 18.4.0", |
| 4 | "appVersionSource": "remote" | 4 | "appVersionSource": "remote", |
| 5 | "promptToConfigurePushNotifications": false | ||
| 5 | }, | 6 | }, |
| 6 | "build": { | 7 | "build": { |
| 7 | "development": { | 8 | "development": { |
apps/mobile/package.json+1| ... | @@ -77,6 +77,7 @@ | ... | @@ -77,6 +77,7 @@ |
| 77 | "expo-haptics": "~56.0.3", | 77 | "expo-haptics": "~56.0.3", |
| 78 | "expo-image-picker": "~56.0.14", | 78 | "expo-image-picker": "~56.0.14", |
| 79 | "expo-linking": "~56.0.12", | 79 | "expo-linking": "~56.0.12", |
| 80 | "expo-network": "~56.0.5", | ||
| 80 | "expo-notifications": "~56.0.14", | 81 | "expo-notifications": "~56.0.14", |
| 81 | "expo-paste-input": "^0.1.15", | 82 | "expo-paste-input": "^0.1.15", |
| 82 | "expo-router": "~56.2.7", | 83 | "expo-router": "~56.2.7", |
apps/mobile/src/app/_layout.tsx+5-8| ... | @@ -16,10 +16,8 @@ import { useResolveClassNames } from "uniwind"; | ... | @@ -16,10 +16,8 @@ import { useResolveClassNames } from "uniwind"; |
| 16 | 16 | ||
| 17 | import { LoadingScreen } from "../components/LoadingScreen"; | 17 | import { LoadingScreen } from "../components/LoadingScreen"; |
| 18 | 18 | ||
| 19 | import { | 19 | import { useWorkspaceState } from "../state/workspace"; |
| 20 | useRemoteEnvironmentBootstrap, | 20 | import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain"; |
| 21 | useRemoteEnvironmentState, | ||
| 22 | } from "../state/use-remote-environment-registry"; | ||
| 23 | import { RegistryContext } from "@effect/atom-react"; | 21 | import { RegistryContext } from "@effect/atom-react"; |
| 24 | import { appAtomRegistry } from "../state/atom-registry"; | 22 | import { appAtomRegistry } from "../state/atom-registry"; |
| 25 | import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; | 23 | import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; |
| ... | @@ -42,12 +40,13 @@ function AppNavigator() { | ... | @@ -42,12 +40,13 @@ function AppNavigator() { |
| 42 | } | 40 | } |
| 43 | 41 | ||
| 44 | function AppNavigatorContent() { | 42 | function AppNavigatorContent() { |
| 45 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); | 43 | const { state } = useWorkspaceState(); |
| 46 | const { collapse, isExpanded } = useClerkSettingsSheetDetent(); | 44 | const { collapse, isExpanded } = useClerkSettingsSheetDetent(); |
| 47 | const colorScheme = useColorScheme(); | 45 | const colorScheme = useColorScheme(); |
| 48 | const statusBarBg = useThemeColor("--color-status-bar"); | 46 | const statusBarBg = useThemeColor("--color-status-bar"); |
| 49 | const sheetStyle = useResolveClassNames("bg-sheet"); | 47 | const sheetStyle = useResolveClassNames("bg-sheet"); |
| 50 | useAgentNotificationNavigation(); | 48 | useAgentNotificationNavigation(); |
| 49 | useThreadOutboxDrain(); | ||
| 51 | 50 | ||
| 52 | const handleSettingsTransitionEnd = useCallback( | 51 | const handleSettingsTransitionEnd = useCallback( |
| 53 | (event: { data: { closing: boolean } }) => { | 52 | (event: { data: { closing: boolean } }) => { |
| ... | @@ -81,7 +80,7 @@ function AppNavigatorContent() { | ... | @@ -81,7 +80,7 @@ function AppNavigatorContent() { |
| 81 | sheetAllowedDetents: isExpanded ? [0.92] : [0.7], | 80 | sheetAllowedDetents: isExpanded ? [0.92] : [0.7], |
| 82 | }; | 81 | }; |
| 83 | 82 | ||
| 84 | if (isLoadingSavedConnection) { | 83 | if (state.isLoadingConnections) { |
| 85 | return <LoadingScreen message="Loading remote workspace…" />; | 84 | return <LoadingScreen message="Loading remote workspace…" />; |
| 86 | } | 85 | } |
| 87 | 86 | ||
| ... | @@ -129,8 +128,6 @@ export default function RootLayout() { | ... | @@ -129,8 +128,6 @@ export default function RootLayout() { |
| 129 | DMSans_500Medium, | 128 | DMSans_500Medium, |
| 130 | DMSans_700Bold, | 129 | DMSans_700Bold, |
| 131 | }); | 130 | }); |
| 132 | useRemoteEnvironmentBootstrap(); | ||
| 133 | |||
| 134 | return ( | 131 | return ( |
| 135 | <RegistryContext.Provider value={appAtomRegistry}> | 132 | <RegistryContext.Provider value={appAtomRegistry}> |
| 136 | <CloudAuthProvider> | 133 | <CloudAuthProvider> |
apps/mobile/src/app/connections/new.tsx+6-5| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { CameraView, useCameraPermissions } from "expo-camera"; | 1 | import { CameraView, useCameraPermissions } from "expo-camera"; |
| 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 3 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | import { useCallback, useEffect, useState } from "react"; | 4 | import { useCallback, useEffect, useState } from "react"; |
| 4 | import { Alert, ScrollView, View } from "react-native"; | 5 | import { Alert, ScrollView, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | @@ -111,12 +112,12 @@ export default function ConnectionsNewRouteScreen() { | ... | @@ -111,12 +112,12 @@ export default function ConnectionsNewRouteScreen() { |
| 111 | const handleSubmit = useCallback(async () => { | 112 | const handleSubmit = useCallback(async () => { |
| 112 | setIsSubmitting(true); | 113 | setIsSubmitting(true); |
| 113 | 114 | ||
| 114 | try { | 115 | const pairingUrl = buildPairingUrl(hostInput, codeInput); |
| 115 | const pairingUrl = buildPairingUrl(hostInput, codeInput); | 116 | onChangeConnectionPairingUrl(pairingUrl); |
| 116 | onChangeConnectionPairingUrl(pairingUrl); | 117 | const result = await onConnectPress(pairingUrl); |
| 117 | await onConnectPress(pairingUrl); | 118 | if (AsyncResult.isSuccess(result)) { |
| 118 | dismissRoute(router); | 119 | dismissRoute(router); |
| 119 | } catch { | 120 | } else { |
| 120 | setIsSubmitting(false); | 121 | setIsSubmitting(false); |
| 121 | } | 122 | } |
| 122 | }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); | 123 | }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); |
apps/mobile/src/app/index.tsx+12-4| ... | @@ -2,17 +2,20 @@ import { Stack, useRouter } from "expo-router"; | ... | @@ -2,17 +2,20 @@ import { Stack, useRouter } from "expo-router"; |
| 2 | import { useState } from "react"; | 2 | import { useState } from "react"; |
| 3 | import { Text as RNText, View } from "react-native"; | 3 | import { Text as RNText, View } from "react-native"; |
| 4 | 4 | ||
| 5 | import { useProjects, useThreadShells } from "../state/entities"; | ||
| 6 | import { useWorkspaceState } from "../state/workspace"; | ||
| 5 | import { buildThreadRoutePath } from "../lib/routes"; | 7 | import { buildThreadRoutePath } from "../lib/routes"; |
| 6 | import { useRemoteCatalog } from "../state/use-remote-catalog"; | 8 | import { useSavedRemoteConnections } from "../state/use-remote-environment-registry"; |
| 7 | import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry"; | ||
| 8 | import { HomeScreen } from "../features/home/HomeScreen"; | 9 | import { HomeScreen } from "../features/home/HomeScreen"; |
| 9 | import { useThemeColor } from "../lib/useThemeColor"; | 10 | import { useThemeColor } from "../lib/useThemeColor"; |
| 10 | 11 | ||
| 11 | /* ─── Route screen ───────────────────────────────────────────────────── */ | 12 | /* ─── Route screen ───────────────────────────────────────────────────── */ |
| 12 | 13 | ||
| 13 | export default function HomeRouteScreen() { | 14 | export default function HomeRouteScreen() { |
| 14 | const { projects, state: catalogState, threads } = useRemoteCatalog(); | 15 | const projects = useProjects(); |
| 15 | const { savedConnectionsById } = useRemoteEnvironmentState(); | 16 | const threads = useThreadShells(); |
| 17 | const { state: catalogState } = useWorkspaceState(); | ||
| 18 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 16 | const router = useRouter(); | 19 | const router = useRouter(); |
| 17 | const [searchQuery, setSearchQuery] = useState(""); | 20 | const [searchQuery, setSearchQuery] = useState(""); |
| 18 | 21 | ||
| ... | @@ -32,9 +35,13 @@ export default function HomeRouteScreen() { | ... | @@ -32,9 +35,13 @@ export default function HomeRouteScreen() { |
| 32 | headerTitle: "", | 35 | headerTitle: "", |
| 33 | headerSearchBarOptions: { | 36 | headerSearchBarOptions: { |
| 34 | placeholder: "Search threads", | 37 | placeholder: "Search threads", |
| 38 | hideNavigationBar: false, | ||
| 35 | onChangeText: (event) => { | 39 | onChangeText: (event) => { |
| 36 | setSearchQuery(event.nativeEvent.text); | 40 | setSearchQuery(event.nativeEvent.text); |
| 37 | }, | 41 | }, |
| 42 | onCancelButtonPress: () => { | ||
| 43 | setSearchQuery(""); | ||
| 44 | }, | ||
| 38 | allowToolbarIntegration: true, | 45 | allowToolbarIntegration: true, |
| 39 | }, | 46 | }, |
| 40 | }} | 47 | }} |
| ... | @@ -104,6 +111,7 @@ export default function HomeRouteScreen() { | ... | @@ -104,6 +111,7 @@ export default function HomeRouteScreen() { |
| 104 | savedConnectionsById={savedConnectionsById} | 111 | savedConnectionsById={savedConnectionsById} |
| 105 | searchQuery={searchQuery} | 112 | searchQuery={searchQuery} |
| 106 | onAddConnection={() => router.push("/connections/new")} | 113 | onAddConnection={() => router.push("/connections/new")} |
| 114 | onOpenEnvironments={() => router.push("/settings/environments")} | ||
| 107 | onSelectThread={(thread) => { | 115 | onSelectThread={(thread) => { |
| 108 | router.push(buildThreadRoutePath(thread)); | 116 | router.push(buildThreadRoutePath(thread)); |
| 109 | }} | 117 | }} |
apps/mobile/src/app/new/add-project/repository.tsx+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Stack, useLocalSearchParams } from "expo-router"; | 1 | import { Stack, useLocalSearchParams } from "expo-router"; |
| 2 | import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime"; | 2 | import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects"; |
| 3 | 3 | ||
| 4 | import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; | 4 | import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; |
| 5 | 5 |
apps/mobile/src/app/new/index.tsx+16-14| ... | @@ -8,16 +8,17 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -8,16 +8,17 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 8 | 8 | ||
| 9 | import { AppText as Text } from "../../components/AppText"; | 9 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 10 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 11 | import { useProjects, useThreadShells } from "../../state/entities"; | ||
| 12 | import type { WorkspaceState } from "../../state/workspaceModel"; | ||
| 13 | import { useWorkspaceState } from "../../state/workspace"; | ||
| 11 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | 14 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 12 | import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 13 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | ||
| 14 | 15 | ||
| 15 | function deriveProjectEmptyState(catalogState: RemoteCatalogState): { | 16 | function deriveProjectEmptyState(catalogState: WorkspaceState): { |
| 16 | readonly title: string; | 17 | readonly title: string; |
| 17 | readonly detail: string; | 18 | readonly detail: string; |
| 18 | readonly loading: boolean; | 19 | readonly loading: boolean; |
| 19 | } { | 20 | } { |
| 20 | if (catalogState.isLoadingSavedConnections) { | 21 | if (catalogState.isLoadingConnections) { |
| 21 | return { | 22 | return { |
| 22 | title: "Loading environments", | 23 | title: "Loading environments", |
| 23 | detail: "Checking saved environments on this device.", | 24 | detail: "Checking saved environments on this device.", |
| ... | @@ -25,7 +26,7 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { | ... | @@ -25,7 +26,7 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { |
| 25 | }; | 26 | }; |
| 26 | } | 27 | } |
| 27 | 28 | ||
| 28 | if (!catalogState.hasSavedConnections) { | 29 | if (!catalogState.hasConnections) { |
| 29 | return { | 30 | return { |
| 30 | title: "No environments connected", | 31 | title: "No environments connected", |
| 31 | detail: "Add an environment before creating a task.", | 32 | detail: "Add an environment before creating a task.", |
| ... | @@ -33,7 +34,12 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { | ... | @@ -33,7 +34,12 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { |
| 33 | }; | 34 | }; |
| 34 | } | 35 | } |
| 35 | 36 | ||
| 36 | if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { | 37 | if ( |
| 38 | (catalogState.connectionState === "available" || | ||
| 39 | catalogState.connectionState === "offline" || | ||
| 40 | catalogState.connectionState === "error") && | ||
| 41 | !catalogState.hasLoadedShellSnapshot | ||
| 42 | ) { | ||
| 37 | return { | 43 | return { |
| 38 | title: "Environment unavailable", | 44 | title: "Environment unavailable", |
| 39 | detail: | 45 | detail: |
| ... | @@ -63,8 +69,9 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { | ... | @@ -63,8 +69,9 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { |
| 63 | } | 69 | } |
| 64 | 70 | ||
| 65 | export default function NewTaskRoute() { | 71 | export default function NewTaskRoute() { |
| 66 | const { projects, state: catalogState, threads } = useRemoteCatalog(); | 72 | const projects = useProjects(); |
| 67 | const { savedConnectionsById } = useRemoteEnvironmentState(); | 73 | const threads = useThreadShells(); |
| 74 | const { state: catalogState } = useWorkspaceState(); | ||
| 68 | const router = useRouter(); | 75 | const router = useRouter(); |
| 69 | const insets = useSafeAreaInsets(); | 76 | const insets = useSafeAreaInsets(); |
| 70 | const chevronColor = useThemeColor("--color-chevron"); | 77 | const chevronColor = useThemeColor("--color-chevron"); |
| ... | @@ -183,15 +190,10 @@ export default function NewTaskRoute() { | ... | @@ -183,15 +190,10 @@ export default function NewTaskRoute() { |
| 183 | <View className="flex-row items-center justify-between gap-3"> | 190 | <View className="flex-row items-center justify-between gap-3"> |
| 184 | <View className="h-7 w-7 items-center justify-center"> | 191 | <View className="h-7 w-7 items-center justify-center"> |
| 185 | <ProjectFavicon | 192 | <ProjectFavicon |
| 193 | environmentId={item.environmentId} | ||
| 186 | size={20} | 194 | size={20} |
| 187 | projectTitle={item.title} | 195 | projectTitle={item.title} |
| 188 | httpBaseUrl={ | ||
| 189 | savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null | ||
| 190 | } | ||
| 191 | workspaceRoot={item.workspaceRoot} | 196 | workspaceRoot={item.workspaceRoot} |
| 192 | bearerToken={ | ||
| 193 | savedConnectionsById[item.environmentId]?.bearerToken ?? null | ||
| 194 | } | ||
| 195 | /> | 197 | /> |
| 196 | </View> | 198 | </View> |
| 197 | <View className="flex-1"> | 199 | <View className="flex-1"> |
apps/mobile/src/app/settings/environments.tsx+286-109| ... | @@ -1,32 +1,38 @@ | ... | @@ -1,32 +1,38 @@ |
| 1 | import { useAuth } from "@clerk/expo"; | 1 | import { useAuth } from "@clerk/expo"; |
| 2 | import { Stack, useRouter } from "expo-router"; | 2 | import { Stack, useRouter } from "expo-router"; |
| 3 | import { SymbolView } from "expo-symbols"; | 3 | import { SymbolView } from "expo-symbols"; |
| 4 | import { | ||
| 5 | connectionStatusText, | ||
| 6 | type EnvironmentConnectionPhase, | ||
| 7 | } from "@t3tools/client-runtime/connection"; | ||
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | 8 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | 9 | import { useCallback, useState } from "react"; |
| 6 | import * as Effect from "effect/Effect"; | 10 | import { |
| 7 | import { useCallback, useMemo, useState } from "react"; | 11 | ActivityIndicator, |
| 8 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | 12 | Pressable, |
| 13 | ScrollView, | ||
| 14 | Switch, | ||
| 15 | type NativeSyntheticEvent, | ||
| 16 | type TextLayoutEventData, | ||
| 17 | View, | ||
| 18 | } from "react-native"; | ||
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | 20 | ||
| 11 | import { AppText as Text } from "../../components/AppText"; | 21 | import { AppText as Text } from "../../components/AppText"; |
| 12 | import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment"; | ||
| 13 | import { | 22 | import { |
| 14 | hasCloudPublicConfig, | 23 | type RelayEnvironmentView, |
| 15 | resolveRelayClerkTokenOptions, | 24 | useConnectionController, |
| 16 | } from "../../features/cloud/publicConfig"; | 25 | } from "../../features/connection/useConnectionController"; |
| 17 | import { | 26 | import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; |
| 18 | useManagedRelayEnvironments, | 27 | import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation"; |
| 19 | useManagedRelayEnvironmentStatus, | ||
| 20 | } from "../../features/cloud/managedRelayState"; | ||
| 21 | import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; | 28 | import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; |
| 29 | import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot"; | ||
| 30 | import { splitEnvironmentSections } from "../../features/connection/environmentSections"; | ||
| 22 | import { cn } from "../../lib/cn"; | 31 | import { cn } from "../../lib/cn"; |
| 23 | import { mobileRuntime } from "../../lib/runtime"; | 32 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 24 | import { useThemeColor } from "../../lib/useThemeColor"; | 33 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 25 | import { | 34 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 26 | connectSavedEnvironment, | 35 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 27 | useRemoteConnections, | ||
| 28 | useRemoteEnvironmentState, | ||
| 29 | } from "../../state/use-remote-environment-registry"; | ||
| 30 | 36 | ||
| 31 | export default function SettingsEnvironmentsRouteScreen() { | 37 | export default function SettingsEnvironmentsRouteScreen() { |
| 32 | const { | 38 | const { |
| ... | @@ -37,7 +43,11 @@ export default function SettingsEnvironmentsRouteScreen() { | ... | @@ -37,7 +43,11 @@ export default function SettingsEnvironmentsRouteScreen() { |
| 37 | } = useRemoteConnections(); | 43 | } = useRemoteConnections(); |
| 38 | const router = useRouter(); | 44 | const router = useRouter(); |
| 39 | const insets = useSafeAreaInsets(); | 45 | const insets = useSafeAreaInsets(); |
| 40 | const hasEnvironments = connectedEnvironments.length > 0; | 46 | const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ |
| 47 | connectedEnvironments, | ||
| 48 | cloudEnvironments: null, | ||
| 49 | }); | ||
| 50 | const hasLocalEnvironments = localEnvironments.length > 0; | ||
| 41 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | 51 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); |
| 42 | const accentColor = useThemeColor("--color-icon-muted"); | 52 | const accentColor = useThemeColor("--color-icon-muted"); |
| 43 | 53 | ||
| ... | @@ -69,9 +79,9 @@ export default function SettingsEnvironmentsRouteScreen() { | ... | @@ -69,9 +79,9 @@ export default function SettingsEnvironmentsRouteScreen() { |
| 69 | paddingTop: 16, | 79 | paddingTop: 16, |
| 70 | }} | 80 | }} |
| 71 | > | 81 | > |
| 72 | {hasEnvironments ? ( | 82 | {hasLocalEnvironments ? ( |
| 73 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | 83 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> |
| 74 | {connectedEnvironments.map((environment, index) => ( | 84 | {localEnvironments.map((environment, index) => ( |
| 75 | <View | 85 | <View |
| 76 | key={environment.environmentId} | 86 | key={environment.environmentId} |
| 77 | collapsable={false} | 87 | collapsable={false} |
| ... | @@ -108,54 +118,43 @@ export default function SettingsEnvironmentsRouteScreen() { | ... | @@ -108,54 +118,43 @@ export default function SettingsEnvironmentsRouteScreen() { |
| 108 | </View> | 118 | </View> |
| 109 | )} | 119 | )} |
| 110 | 120 | ||
| 111 | {hasCloudPublicConfig() ? <ConfiguredCloudEnvironmentRows /> : null} | 121 | {hasCloudPublicConfig() ? ( |
| 122 | <ConfiguredCloudEnvironmentRows | ||
| 123 | connectedCloudEnvironments={connectedCloudEnvironments} | ||
| 124 | onReconnectEnvironment={onReconnectEnvironment} | ||
| 125 | /> | ||
| 126 | ) : null} | ||
| 112 | </ScrollView> | 127 | </ScrollView> |
| 113 | </View> | 128 | </View> |
| 114 | ); | 129 | ); |
| 115 | } | 130 | } |
| 116 | 131 | ||
| 117 | function ConfiguredCloudEnvironmentRows() { | 132 | function ConfiguredCloudEnvironmentRows(props: { |
| 118 | const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | 133 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; |
| 119 | const { savedConnectionsById } = useRemoteEnvironmentState(); | 134 | readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; |
| 120 | const cloudEnvironmentsState = useManagedRelayEnvironments(); | 135 | }) { |
| 121 | const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState<string | null>( | 136 | const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 122 | null, | 137 | const controller = useConnectionController(); |
| 123 | ); | ||
| 124 | const iconColor = useThemeColor("--color-icon"); | 138 | const iconColor = useThemeColor("--color-icon"); |
| 125 | const availableCloudEnvironments = useMemo( | 139 | const availableCloudEnvironments = controller.availableRelayEnvironments; |
| 126 | () => | 140 | const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null); |
| 127 | (cloudEnvironmentsState.data ?? []).filter( | 141 | const hasCloudRows = |
| 128 | (environment) => savedConnectionsById[environment.environmentId] === undefined, | 142 | props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; |
| 129 | ), | ||
| 130 | [cloudEnvironmentsState.data, savedConnectionsById], | ||
| 131 | ); | ||
| 132 | 143 | ||
| 133 | const handleConnectCloudEnvironment = useCallback( | 144 | const handleConnectCloudEnvironment = useCallback( |
| 134 | async (environment: RelayClientEnvironmentRecord) => { | 145 | (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), |
| 135 | setConnectingCloudEnvironmentId(environment.environmentId); | 146 | [controller], |
| 136 | try { | ||
| 137 | const token = await getToken(resolveRelayClerkTokenOptions()); | ||
| 138 | if (!token) { | ||
| 139 | throw new Error("Sign in to T3 Cloud before connecting."); | ||
| 140 | } | ||
| 141 | await mobileRuntime.runPromise( | ||
| 142 | connectCloudEnvironment({ | ||
| 143 | clerkToken: token, | ||
| 144 | environment, | ||
| 145 | }).pipe(Effect.flatMap(connectSavedEnvironment)), | ||
| 146 | ); | ||
| 147 | } catch (error) { | ||
| 148 | Alert.alert( | ||
| 149 | "Connect failed", | ||
| 150 | error instanceof Error ? error.message : "Could not connect to this environment.", | ||
| 151 | ); | ||
| 152 | } finally { | ||
| 153 | setConnectingCloudEnvironmentId(null); | ||
| 154 | } | ||
| 155 | }, | ||
| 156 | [getToken], | ||
| 157 | ); | 147 | ); |
| 158 | 148 | ||
| 149 | const handleDisconnectCloudEnvironment = useCallback( | ||
| 150 | (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), | ||
| 151 | [controller], | ||
| 152 | ); | ||
| 153 | |||
| 154 | const handleToggleCloudError = useCallback((environmentId: string) => { | ||
| 155 | setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); | ||
| 156 | }, []); | ||
| 157 | |||
| 159 | if (!isSignedIn) return null; | 158 | if (!isSignedIn) return null; |
| 160 | 159 | ||
| 161 | return ( | 160 | return ( |
| ... | @@ -164,11 +163,13 @@ function ConfiguredCloudEnvironmentRows() { | ... | @@ -164,11 +163,13 @@ function ConfiguredCloudEnvironmentRows() { |
| 164 | <Text className="text-[13px] font-t3-bold uppercase text-foreground-muted">T3 Cloud</Text> | 163 | <Text className="text-[13px] font-t3-bold uppercase text-foreground-muted">T3 Cloud</Text> |
| 165 | <Pressable | 164 | <Pressable |
| 166 | accessibilityRole="button" | 165 | accessibilityRole="button" |
| 167 | disabled={cloudEnvironmentsState.isPending} | 166 | disabled={controller.relayDiscovery.isRefreshing} |
| 168 | onPress={cloudEnvironmentsState.refresh} | 167 | onPress={() => { |
| 168 | void controller.refreshRelayEnvironments(); | ||
| 169 | }} | ||
| 169 | className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" | 170 | className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" |
| 170 | > | 171 | > |
| 171 | {cloudEnvironmentsState.isPending ? ( | 172 | {controller.relayDiscovery.isRefreshing ? ( |
| 172 | <ActivityIndicator color={iconColor} size="small" /> | 173 | <ActivityIndicator color={iconColor} size="small" /> |
| 173 | ) : ( | 174 | ) : ( |
| 174 | <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" /> | 175 | <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" /> |
| ... | @@ -176,33 +177,48 @@ function ConfiguredCloudEnvironmentRows() { | ... | @@ -176,33 +177,48 @@ function ConfiguredCloudEnvironmentRows() { |
| 176 | </Pressable> | 177 | </Pressable> |
| 177 | </View> | 178 | </View> |
| 178 | 179 | ||
| 179 | {availableCloudEnvironments.length > 0 ? ( | 180 | {hasCloudRows ? ( |
| 180 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | 181 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> |
| 181 | {availableCloudEnvironments.map((environment, index) => ( | 182 | {props.connectedCloudEnvironments.map((environment, index) => ( |
| 182 | <CloudEnvironmentRow | 183 | <ConnectedCloudEnvironmentRow |
| 183 | key={environment.environmentId} | 184 | key={environment.environmentId} |
| 184 | environment={environment} | 185 | environment={environment} |
| 185 | borderTop={index !== 0} | 186 | borderTop={index !== 0} |
| 186 | isConnecting={connectingCloudEnvironmentId === environment.environmentId} | 187 | onConnect={() => props.onReconnectEnvironment(environment.environmentId)} |
| 188 | onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} | ||
| 189 | errorExpanded={expandedErrorId === environment.environmentId} | ||
| 190 | onToggleError={() => handleToggleCloudError(environment.environmentId)} | ||
| 191 | /> | ||
| 192 | ))} | ||
| 193 | {availableCloudEnvironments.map((environment, index) => ( | ||
| 194 | <CloudEnvironmentRow | ||
| 195 | key={environment.environment.environmentId} | ||
| 196 | environment={environment} | ||
| 197 | borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0} | ||
| 187 | onConnect={() => handleConnectCloudEnvironment(environment)} | 198 | onConnect={() => handleConnectCloudEnvironment(environment)} |
| 199 | errorExpanded={expandedErrorId === environment.environment.environmentId} | ||
| 200 | onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} | ||
| 188 | /> | 201 | /> |
| 189 | ))} | 202 | ))} |
| 190 | </View> | 203 | </View> |
| 191 | ) : cloudEnvironmentsState.data === null ? ( | 204 | ) : controller.relayDiscovery.isRefreshing ? ( |
| 192 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6"> | 205 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6"> |
| 193 | <ActivityIndicator color={iconColor} /> | 206 | <ActivityIndicator color={iconColor} /> |
| 194 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> | 207 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> |
| 195 | Loading linked cloud environments. | 208 | Loading linked cloud environments. |
| 196 | </Text> | 209 | </Text> |
| 197 | </View> | 210 | </View> |
| 198 | ) : cloudEnvironmentsState.error ? ( | 211 | ) : controller.relayDiscovery.error ? ( |
| 199 | <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5"> | 212 | <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5"> |
| 200 | <Text className="text-[15px] font-t3-bold text-foreground"> | 213 | <Text className="text-[15px] font-t3-bold text-foreground"> |
| 201 | Could not load T3 Cloud environments | 214 | Could not load T3 Cloud environments |
| 202 | </Text> | 215 | </Text> |
| 203 | <Text className="text-[13px] leading-[18px] text-foreground-muted"> | 216 | <Text className="text-[13px] leading-[18px] text-foreground-muted"> |
| 204 | {cloudEnvironmentsState.error} | 217 | {controller.relayDiscovery.error} |
| 205 | </Text> | 218 | </Text> |
| 219 | {controller.relayDiscovery.errorTraceId ? ( | ||
| 220 | <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} /> | ||
| 221 | ) : null} | ||
| 206 | </View> | 222 | </View> |
| 207 | ) : ( | 223 | ) : ( |
| 208 | <View collapsable={false} className="rounded-[24px] bg-card p-5"> | 224 | <View collapsable={false} className="rounded-[24px] bg-card p-5"> |
| ... | @@ -215,23 +231,124 @@ function ConfiguredCloudEnvironmentRows() { | ... | @@ -215,23 +231,124 @@ function ConfiguredCloudEnvironmentRows() { |
| 215 | ); | 231 | ); |
| 216 | } | 232 | } |
| 217 | 233 | ||
| 234 | function ConnectedCloudEnvironmentRow(props: { | ||
| 235 | readonly environment: ConnectedEnvironmentSummary; | ||
| 236 | readonly borderTop: boolean; | ||
| 237 | readonly errorExpanded: boolean; | ||
| 238 | readonly onConnect: () => void; | ||
| 239 | readonly onDisconnect: () => void; | ||
| 240 | readonly onToggleError: () => void; | ||
| 241 | }) { | ||
| 242 | return ( | ||
| 243 | <CloudEnvironmentRowShell | ||
| 244 | borderTop={props.borderTop} | ||
| 245 | connectionError={props.environment.connectionError} | ||
| 246 | connectionErrorTraceId={props.environment.connectionErrorTraceId} | ||
| 247 | connectionState={props.environment.connectionState} | ||
| 248 | errorExpanded={props.errorExpanded} | ||
| 249 | label={props.environment.environmentLabel} | ||
| 250 | onValueChange={(enabled) => { | ||
| 251 | if (enabled) { | ||
| 252 | props.onConnect(); | ||
| 253 | return; | ||
| 254 | } | ||
| 255 | props.onDisconnect(); | ||
| 256 | }} | ||
| 257 | onToggleError={props.onToggleError} | ||
| 258 | value={props.environment.connectionState !== "available"} | ||
| 259 | /> | ||
| 260 | ); | ||
| 261 | } | ||
| 262 | |||
| 218 | function CloudEnvironmentRow(props: { | 263 | function CloudEnvironmentRow(props: { |
| 219 | readonly environment: RelayClientEnvironmentRecord; | 264 | readonly environment: RelayEnvironmentView; |
| 220 | readonly borderTop: boolean; | 265 | readonly borderTop: boolean; |
| 221 | readonly isConnecting: boolean; | 266 | readonly errorExpanded: boolean; |
| 222 | readonly onConnect: () => void; | 267 | readonly onConnect: () => void; |
| 268 | readonly onToggleError: () => void; | ||
| 223 | }) { | 269 | }) { |
| 224 | const mutedColor = useThemeColor("--color-icon-muted"); | 270 | const presentation = availableCloudEnvironmentPresentation({ |
| 225 | const statusState = useManagedRelayEnvironmentStatus(props.environment); | 271 | isStatusPending: props.environment.availability === "checking", |
| 226 | const status = statusState.data; | 272 | status: props.environment.status, |
| 227 | const disabled = props.isConnecting; | 273 | statusError: props.environment.error, |
| 228 | const statusText = | 274 | statusErrorTraceId: props.environment.traceId, |
| 229 | status === null | 275 | }); |
| 230 | ? (statusState.error ?? (statusState.isPending ? "Checking status..." : "Status unavailable")) | ||
| 231 | : status.status === "online" | ||
| 232 | ? "Online" | ||
| 233 | : (status.error ?? "Offline"); | ||
| 234 | 276 | ||
| 277 | return ( | ||
| 278 | <CloudEnvironmentRowShell | ||
| 279 | borderTop={props.borderTop} | ||
| 280 | connectionError={presentation.connectionError} | ||
| 281 | connectionErrorTraceId={presentation.connectionErrorTraceId} | ||
| 282 | connectionState={presentation.connectionState} | ||
| 283 | errorExpanded={props.errorExpanded} | ||
| 284 | label={props.environment.environment.label} | ||
| 285 | onValueChange={(enabled) => { | ||
| 286 | if (enabled) { | ||
| 287 | props.onConnect(); | ||
| 288 | } | ||
| 289 | }} | ||
| 290 | onToggleError={props.onToggleError} | ||
| 291 | statusText={presentation.statusText} | ||
| 292 | value={false} | ||
| 293 | /> | ||
| 294 | ); | ||
| 295 | } | ||
| 296 | |||
| 297 | function CloudEnvironmentRowShell(props: { | ||
| 298 | readonly borderTop: boolean; | ||
| 299 | readonly connectionError: string | null; | ||
| 300 | readonly connectionErrorTraceId: string | null; | ||
| 301 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 302 | readonly disabled?: boolean; | ||
| 303 | readonly errorExpanded: boolean; | ||
| 304 | readonly label: string; | ||
| 305 | readonly onToggleError: () => void; | ||
| 306 | readonly onValueChange: (enabled: boolean) => void; | ||
| 307 | readonly statusText?: string; | ||
| 308 | readonly value: boolean; | ||
| 309 | }) { | ||
| 310 | const activeTrack = String(useThemeColor("--color-switch-active")); | ||
| 311 | const track = String(useThemeColor("--color-secondary-border")); | ||
| 312 | const chevron = useThemeColor("--color-chevron"); | ||
| 313 | const isRetrying = | ||
| 314 | props.connectionState === "connecting" || props.connectionState === "reconnecting"; | ||
| 315 | const shouldPulse = isRetrying; | ||
| 316 | const statusText = | ||
| 317 | props.statusText ?? | ||
| 318 | connectionStatusText({ | ||
| 319 | phase: props.connectionState, | ||
| 320 | error: props.connectionError, | ||
| 321 | traceId: props.connectionErrorTraceId, | ||
| 322 | }); | ||
| 323 | const statusClassName = props.connectionError | ||
| 324 | ? "text-rose-500 dark:text-rose-400" | ||
| 325 | : "text-foreground-muted"; | ||
| 326 | const [errorMeasurement, setErrorMeasurement] = useState<{ | ||
| 327 | readonly text: string; | ||
| 328 | readonly lineCount: number; | ||
| 329 | } | null>(null); | ||
| 330 | const errorTraceId = props.connectionErrorTraceId; | ||
| 331 | const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; | ||
| 332 | const errorLineCount = | ||
| 333 | errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; | ||
| 334 | const errorCanExpand = props.connectionError !== null && errorLineCount > 1; | ||
| 335 | const isErrorExpanded = errorCanExpand && props.errorExpanded; | ||
| 336 | const StatusContainer = errorCanExpand ? Pressable : View; | ||
| 337 | const onMeasuredErrorTextLayout = useCallback( | ||
| 338 | (event: NativeSyntheticEvent<TextLayoutEventData>) => { | ||
| 339 | if (!props.connectionError) { | ||
| 340 | return; | ||
| 341 | } | ||
| 342 | const nextLineCount = event.nativeEvent.lines.length; | ||
| 343 | setErrorMeasurement((currentMeasurement) => | ||
| 344 | currentMeasurement?.text === measuredErrorText && | ||
| 345 | currentMeasurement.lineCount === nextLineCount | ||
| 346 | ? currentMeasurement | ||
| 347 | : { text: measuredErrorText, lineCount: nextLineCount }, | ||
| 348 | ); | ||
| 349 | }, | ||
| 350 | [measuredErrorText, props.connectionError], | ||
| 351 | ); | ||
| 235 | return ( | 352 | return ( |
| 236 | <View | 353 | <View |
| 237 | collapsable={false} | 354 | collapsable={false} |
| ... | @@ -240,36 +357,96 @@ function CloudEnvironmentRow(props: { | ... | @@ -240,36 +357,96 @@ function CloudEnvironmentRow(props: { |
| 240 | props.borderTop && "border-t border-border", | 357 | props.borderTop && "border-t border-border", |
| 241 | )} | 358 | )} |
| 242 | > | 359 | > |
| 243 | <View className="h-9 w-9 items-center justify-center rounded-[14px] bg-subtle"> | ||
| 244 | <SymbolView | ||
| 245 | name="cloud" | ||
| 246 | size={17} | ||
| 247 | tintColor={mutedColor} | ||
| 248 | type="monochrome" | ||
| 249 | weight="semibold" | ||
| 250 | /> | ||
| 251 | </View> | ||
| 252 | <View className="min-w-0 flex-1 gap-0.5"> | 360 | <View className="min-w-0 flex-1 gap-0.5"> |
| 253 | <Text className="text-[16px] font-t3-bold leading-[21px] text-foreground" numberOfLines={1}> | 361 | <View className="min-w-0 flex-row items-center gap-2"> |
| 254 | {props.environment.label} | 362 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> |
| 255 | </Text> | 363 | <Text |
| 256 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}> | 364 | className="min-w-0 flex-shrink text-[16px] font-t3-bold leading-[21px] text-foreground" |
| 257 | {props.environment.endpoint.httpBaseUrl} | 365 | numberOfLines={1} |
| 258 | </Text> | 366 | > |
| 259 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}> | 367 | {props.label} |
| 260 | {statusText} | 368 | </Text> |
| 261 | </Text> | 369 | </View> |
| 370 | {props.connectionError ? ( | ||
| 371 | <Text | ||
| 372 | aria-hidden | ||
| 373 | className={cn("absolute left-0 right-0 text-[12px] leading-[16px]", statusClassName)} | ||
| 374 | onTextLayout={onMeasuredErrorTextLayout} | ||
| 375 | style={{ opacity: 0, zIndex: -1 }} | ||
| 376 | > | ||
| 377 | {measuredErrorText} | ||
| 378 | </Text> | ||
| 379 | ) : null} | ||
| 380 | <StatusContainer | ||
| 381 | {...(errorCanExpand | ||
| 382 | ? { accessibilityRole: "button" as const, onPress: props.onToggleError } | ||
| 383 | : {})} | ||
| 384 | className="min-w-0 flex-row items-start gap-1" | ||
| 385 | > | ||
| 386 | <Text | ||
| 387 | className={cn("min-w-0 flex-1 text-[12px] leading-[16px]", statusClassName)} | ||
| 388 | numberOfLines={isErrorExpanded ? undefined : 1} | ||
| 389 | > | ||
| 390 | {statusText} | ||
| 391 | {errorTraceId ? ( | ||
| 392 | <> | ||
| 393 | {" Trace ID: "} | ||
| 394 | <Text | ||
| 395 | accessibilityHint="Copies the trace ID" | ||
| 396 | accessibilityRole="button" | ||
| 397 | className={cn("text-[12px] leading-[16px] underline", statusClassName)} | ||
| 398 | onLongPress={(event) => { | ||
| 399 | event.stopPropagation(); | ||
| 400 | copyTextWithHaptic(errorTraceId); | ||
| 401 | }} | ||
| 402 | onPress={(event) => { | ||
| 403 | event.stopPropagation(); | ||
| 404 | }} | ||
| 405 | style={{ textDecorationStyle: "dotted" }} | ||
| 406 | > | ||
| 407 | {errorTraceId} | ||
| 408 | </Text> | ||
| 409 | </> | ||
| 410 | ) : null} | ||
| 411 | </Text> | ||
| 412 | {errorCanExpand ? ( | ||
| 413 | <SymbolView | ||
| 414 | name="chevron.down" | ||
| 415 | size={10} | ||
| 416 | tintColor={chevron} | ||
| 417 | type="monochrome" | ||
| 418 | style={{ | ||
| 419 | marginTop: 3, | ||
| 420 | transform: [{ rotate: isErrorExpanded ? "180deg" : "0deg" }], | ||
| 421 | }} | ||
| 422 | /> | ||
| 423 | ) : null} | ||
| 424 | </StatusContainer> | ||
| 262 | </View> | 425 | </View> |
| 263 | <Pressable | 426 | <Switch |
| 264 | accessibilityRole="button" | 427 | disabled={props.disabled} |
| 265 | disabled={disabled} | 428 | ios_backgroundColor={track} |
| 266 | onPress={props.onConnect} | 429 | onValueChange={props.onValueChange} |
| 267 | className="min-h-[40px] min-w-[88px] items-center justify-center rounded-[14px] bg-primary px-4 active:opacity-70 disabled:opacity-50" | 430 | trackColor={{ false: track, true: activeTrack }} |
| 268 | > | 431 | value={props.value} |
| 269 | <Text className="text-[13px] font-t3-bold text-primary-foreground"> | 432 | /> |
| 270 | {props.isConnecting ? "Connecting" : "Connect"} | ||
| 271 | </Text> | ||
| 272 | </Pressable> | ||
| 273 | </View> | 433 | </View> |
| 274 | ); | 434 | ); |
| 275 | } | 435 | } |
| 436 | |||
| 437 | function CopyTraceIdButton(props: { readonly traceId: string }) { | ||
| 438 | const iconColor = useThemeColor("--color-icon"); | ||
| 439 | |||
| 440 | return ( | ||
| 441 | <Pressable | ||
| 442 | accessibilityRole="button" | ||
| 443 | onPress={() => { | ||
| 444 | copyTextWithHaptic(props.traceId); | ||
| 445 | }} | ||
| 446 | className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" | ||
| 447 | > | ||
| 448 | <SymbolView name="doc.on.doc" size={12} tintColor={iconColor} type="monochrome" /> | ||
| 449 | <Text className="text-[12px] font-t3-bold text-foreground">Copy trace ID</Text> | ||
| 450 | </Pressable> | ||
| 451 | ); | ||
| 452 | } |
apps/mobile/src/app/settings/index.tsx+135-80| ... | @@ -8,6 +8,13 @@ import type { ComponentProps, ReactNode } from "react"; | ... | @@ -8,6 +8,13 @@ import type { ComponentProps, ReactNode } from "react"; |
| 8 | import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native"; | 8 | import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native"; |
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | 10 | ||
| 11 | import { | ||
| 12 | isAtomCommandInterrupted, | ||
| 13 | reportAtomCommandResult, | ||
| 14 | settleAsyncResult, | ||
| 15 | settlePromise, | ||
| 16 | squashAtomCommandFailure, | ||
| 17 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 11 | import { AppText as Text } from "../../components/AppText"; | 18 | import { AppText as Text } from "../../components/AppText"; |
| 12 | import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences"; | 19 | import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences"; |
| 13 | import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; | 20 | import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; |
| ... | @@ -18,10 +25,10 @@ import { | ... | @@ -18,10 +25,10 @@ import { |
| 18 | hasCloudPublicConfig, | 25 | hasCloudPublicConfig, |
| 19 | resolveRelayClerkTokenOptions, | 26 | resolveRelayClerkTokenOptions, |
| 20 | } from "../../features/cloud/publicConfig"; | 27 | } from "../../features/cloud/publicConfig"; |
| 21 | import { mobileRuntime } from "../../lib/runtime"; | 28 | import { runtime } from "../../lib/runtime"; |
| 22 | import { loadPreferences } from "../../lib/storage"; | 29 | import { loadPreferences } from "../../lib/storage"; |
| 23 | import { useThemeColor } from "../../lib/useThemeColor"; | 30 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 24 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | 31 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 25 | 32 | ||
| 26 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | 33 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; |
| 27 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; | 34 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; |
| ... | @@ -32,7 +39,7 @@ export default function SettingsRouteScreen() { | ... | @@ -32,7 +39,7 @@ export default function SettingsRouteScreen() { |
| 32 | 39 | ||
| 33 | function LocalSettingsRouteScreen() { | 40 | function LocalSettingsRouteScreen() { |
| 34 | const insets = useSafeAreaInsets(); | 41 | const insets = useSafeAreaInsets(); |
| 35 | const { savedConnectionsById } = useRemoteEnvironmentState(); | 42 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 36 | const environmentCount = Object.keys(savedConnectionsById).length; | 43 | const environmentCount = Object.keys(savedConnectionsById).length; |
| 37 | 44 | ||
| 38 | return ( | 45 | return ( |
| ... | @@ -70,7 +77,7 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -70,7 +77,7 @@ function ConfiguredSettingsRouteScreen() { |
| 70 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); | 77 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); |
| 71 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | 78 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 72 | const { user } = useUser(); | 79 | const { user } = useUser(); |
| 73 | const { savedConnectionsById } = useRemoteEnvironmentState(); | 80 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 74 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); | 81 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); |
| 75 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); | 82 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); |
| 76 | 83 | ||
| ... | @@ -87,8 +94,13 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -87,8 +94,13 @@ function ConfiguredSettingsRouteScreen() { |
| 87 | setNotificationStatus("unsupported"); | 94 | setNotificationStatus("unsupported"); |
| 88 | return; | 95 | return; |
| 89 | } | 96 | } |
| 90 | const permission = await Notifications.getPermissionsAsync(); | 97 | const result = await settlePromise(() => Notifications.getPermissionsAsync()); |
| 91 | setNotificationStatus(permission.granted ? "enabled" : "disabled"); | 98 | if (result._tag === "Failure") { |
| 99 | reportAtomCommandResult(result, { label: "notification permission refresh" }); | ||
| 100 | setNotificationStatus("disabled"); | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | setNotificationStatus(result.value.granted ? "enabled" : "disabled"); | ||
| 92 | }, []); | 104 | }, []); |
| 93 | 105 | ||
| 94 | useEffect(() => { | 106 | useEffect(() => { |
| ... | @@ -104,60 +116,66 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -104,60 +116,66 @@ function ConfiguredSettingsRouteScreen() { |
| 104 | setLiveActivityStatus("signed-out"); | 116 | setLiveActivityStatus("signed-out"); |
| 105 | return; | 117 | return; |
| 106 | } | 118 | } |
| 107 | void loadPreferences().then( | 119 | void (async () => { |
| 108 | (preferences) => { | 120 | const result = await settlePromise(() => loadPreferences()); |
| 109 | setLiveActivityStatus(preferences.liveActivitiesEnabled === false ? "disabled" : "enabled"); | 121 | if (result._tag === "Failure") { |
| 110 | }, | 122 | reportAtomCommandResult(result, { label: "live activity preference load" }); |
| 111 | () => { | ||
| 112 | setLiveActivityStatus("enabled"); | 123 | setLiveActivityStatus("enabled"); |
| 113 | }, | 124 | return; |
| 114 | ); | 125 | } |
| 126 | setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); | ||
| 127 | })(); | ||
| 115 | }, [isLoaded, isSignedIn]); | 128 | }, [isLoaded, isSignedIn]); |
| 116 | 129 | ||
| 117 | const requestNotifications = useCallback(async () => { | 130 | const requestNotifications = useCallback(async () => { |
| 118 | try { | 131 | const result = await settleAsyncResult(() => |
| 119 | const result = await mobileRuntime.runPromise( | 132 | runtime.runPromiseExit( |
| 120 | requestAgentNotificationPermission.pipe( | 133 | requestAgentNotificationPermission.pipe( |
| 121 | Effect.tap((permission) => | 134 | Effect.tap((permission) => |
| 122 | permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, | 135 | permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, |
| 123 | ), | 136 | ), |
| 124 | ), | 137 | ), |
| 125 | ); | 138 | ), |
| 126 | if (result.type === "granted") { | 139 | ); |
| 127 | setNotificationStatus("enabled"); | 140 | if (result._tag === "Failure") { |
| 128 | Alert.alert( | 141 | if (!isAtomCommandInterrupted(result)) { |
| 129 | "Notifications enabled", | 142 | const error = squashAtomCommandFailure(result); |
| 130 | "Live Activity notifications are enabled for this device.", | ||
| 131 | ); | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | if (result.type === "unsupported") { | ||
| 135 | setNotificationStatus("unsupported"); | ||
| 136 | Alert.alert( | 143 | Alert.alert( |
| 137 | "Notifications unavailable", | 144 | "Notifications unavailable", |
| 138 | "Live Activity notifications are only available on iOS.", | 145 | error instanceof Error ? error.message : "Could not request notification permission.", |
| 139 | ); | 146 | ); |
| 140 | return; | ||
| 141 | } | ||
| 142 | setNotificationStatus("disabled"); | ||
| 143 | if (result.canAskAgain) { | ||
| 144 | Alert.alert("Notifications disabled", "Notifications were not enabled."); | ||
| 145 | return; | ||
| 146 | } | 147 | } |
| 148 | return; | ||
| 149 | } | ||
| 150 | if (result.value.type === "granted") { | ||
| 151 | setNotificationStatus("enabled"); | ||
| 147 | Alert.alert( | 152 | Alert.alert( |
| 148 | "Notifications disabled", | 153 | "Notifications enabled", |
| 149 | "Notifications were denied for this app. Open Settings to enable them.", | 154 | "Live Activity notifications are enabled for this device.", |
| 150 | [ | ||
| 151 | { text: "Cancel", style: "cancel" }, | ||
| 152 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | ||
| 153 | ], | ||
| 154 | ); | 155 | ); |
| 155 | } catch (error) { | 156 | return; |
| 157 | } | ||
| 158 | if (result.value.type === "unsupported") { | ||
| 159 | setNotificationStatus("unsupported"); | ||
| 156 | Alert.alert( | 160 | Alert.alert( |
| 157 | "Notifications unavailable", | 161 | "Notifications unavailable", |
| 158 | error instanceof Error ? error.message : "Could not request notification permission.", | 162 | "Live Activity notifications are only available on iOS.", |
| 159 | ); | 163 | ); |
| 164 | return; | ||
| 160 | } | 165 | } |
| 166 | setNotificationStatus("disabled"); | ||
| 167 | if (result.value.canAskAgain) { | ||
| 168 | Alert.alert("Notifications disabled", "Notifications were not enabled."); | ||
| 169 | return; | ||
| 170 | } | ||
| 171 | Alert.alert( | ||
| 172 | "Notifications disabled", | ||
| 173 | "Notifications were denied for this app. Open Settings to enable them.", | ||
| 174 | [ | ||
| 175 | { text: "Cancel", style: "cancel" }, | ||
| 176 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | ||
| 177 | ], | ||
| 178 | ); | ||
| 161 | }, []); | 179 | }, []); |
| 162 | 180 | ||
| 163 | const promptSignIn = useCallback(() => { | 181 | const promptSignIn = useCallback(() => { |
| ... | @@ -178,36 +196,51 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -178,36 +196,51 @@ function ConfiguredSettingsRouteScreen() { |
| 178 | } | 196 | } |
| 179 | 197 | ||
| 180 | setLiveActivityStatus("linking"); | 198 | setLiveActivityStatus("linking"); |
| 181 | try { | 199 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); |
| 182 | const token = await getToken(resolveRelayClerkTokenOptions()); | 200 | if (tokenResult._tag === "Failure") { |
| 183 | if (!token) { | 201 | setLiveActivityStatus("disabled"); |
| 184 | promptSignIn(); | 202 | const error = squashAtomCommandFailure(tokenResult); |
| 185 | setLiveActivityStatus("signed-out"); | 203 | Alert.alert( |
| 186 | return; | 204 | "Live Activities unavailable", |
| 187 | } | 205 | error instanceof Error ? error.message : "Could not enable Live Activity updates.", |
| 206 | ); | ||
| 207 | return; | ||
| 208 | } | ||
| 209 | if (!tokenResult.value) { | ||
| 210 | promptSignIn(); | ||
| 211 | setLiveActivityStatus("signed-out"); | ||
| 212 | return; | ||
| 213 | } | ||
| 188 | 214 | ||
| 189 | await mobileRuntime.runPromise( | 215 | const updateResult = await settleAsyncResult(() => |
| 216 | runtime.runPromiseExit( | ||
| 190 | setLiveActivityUpdatesEnabled({ | 217 | setLiveActivityUpdatesEnabled({ |
| 191 | enabled: true, | 218 | enabled: true, |
| 192 | clerkToken: token, | 219 | clerkToken: tokenResult.value, |
| 193 | connections, | 220 | connections, |
| 194 | }), | 221 | }), |
| 195 | ); | 222 | ), |
| 196 | refreshManagedRelayEnvironments(); | 223 | ); |
| 197 | setLiveActivityStatus("enabled"); | 224 | if (updateResult._tag === "Failure") { |
| 198 | Alert.alert( | ||
| 199 | "Live Activities enabled", | ||
| 200 | environmentCount > 0 | ||
| 201 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` | ||
| 202 | : "Live Activity updates are enabled. Add an environment to start receiving updates.", | ||
| 203 | ); | ||
| 204 | } catch (error) { | ||
| 205 | setLiveActivityStatus("disabled"); | 225 | setLiveActivityStatus("disabled"); |
| 206 | Alert.alert( | 226 | if (!isAtomCommandInterrupted(updateResult)) { |
| 207 | "Live Activities unavailable", | 227 | const error = squashAtomCommandFailure(updateResult); |
| 208 | error instanceof Error ? error.message : "Could not enable Live Activity updates.", | 228 | Alert.alert( |
| 209 | ); | 229 | "Live Activities unavailable", |
| 230 | error instanceof Error ? error.message : "Could not enable Live Activity updates.", | ||
| 231 | ); | ||
| 232 | } | ||
| 233 | return; | ||
| 210 | } | 234 | } |
| 235 | |||
| 236 | refreshManagedRelayEnvironments(); | ||
| 237 | setLiveActivityStatus("enabled"); | ||
| 238 | Alert.alert( | ||
| 239 | "Live Activities enabled", | ||
| 240 | environmentCount > 0 | ||
| 241 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` | ||
| 242 | : "Live Activity updates are enabled. Add an environment to start receiving updates.", | ||
| 243 | ); | ||
| 211 | }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); | 244 | }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); |
| 212 | 245 | ||
| 213 | const handleDeviceNotificationsChange = useCallback( | 246 | const handleDeviceNotificationsChange = useCallback( |
| ... | @@ -234,19 +267,36 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -234,19 +267,36 @@ function ConfiguredSettingsRouteScreen() { |
| 234 | if (!enabled) { | 267 | if (!enabled) { |
| 235 | setLiveActivityStatus("disabled"); | 268 | setLiveActivityStatus("disabled"); |
| 236 | void (async () => { | 269 | void (async () => { |
| 237 | try { | 270 | let token: string | null = null; |
| 238 | const token = isSignedIn ? await getToken(resolveRelayClerkTokenOptions()) : null; | 271 | if (isSignedIn) { |
| 239 | await mobileRuntime.runPromise( | 272 | const tokenResult = await settlePromise(() => |
| 273 | getToken(resolveRelayClerkTokenOptions()), | ||
| 274 | ); | ||
| 275 | if (tokenResult._tag === "Failure") { | ||
| 276 | reportAtomCommandResult(tokenResult, { | ||
| 277 | label: "live activity disable token lookup", | ||
| 278 | }); | ||
| 279 | return; | ||
| 280 | } | ||
| 281 | token = tokenResult.value; | ||
| 282 | } | ||
| 283 | |||
| 284 | const updateResult = await settleAsyncResult(() => | ||
| 285 | runtime.runPromiseExit( | ||
| 240 | setLiveActivityUpdatesEnabled({ | 286 | setLiveActivityUpdatesEnabled({ |
| 241 | enabled: false, | 287 | enabled: false, |
| 242 | clerkToken: token, | 288 | clerkToken: token, |
| 243 | connections, | 289 | connections, |
| 244 | }), | 290 | }), |
| 245 | ); | 291 | ), |
| 246 | refreshManagedRelayEnvironments(); | 292 | ); |
| 247 | } catch { | 293 | if (updateResult._tag === "Failure") { |
| 248 | // The switch is optimistic; a future refresh reconciles relay state. | 294 | reportAtomCommandResult(updateResult, { |
| 295 | label: "live activity disable", | ||
| 296 | }); | ||
| 297 | return; | ||
| 249 | } | 298 | } |
| 299 | refreshManagedRelayEnvironments(); | ||
| 250 | })(); | 300 | })(); |
| 251 | return; | 301 | return; |
| 252 | } | 302 | } |
| ... | @@ -382,15 +432,20 @@ function SettingsRow(props: { | ... | @@ -382,15 +432,20 @@ function SettingsRow(props: { |
| 382 | style={{ opacity: props.disabled ? 0.45 : 1 }} | 432 | style={{ opacity: props.disabled ? 0.45 : 1 }} |
| 383 | > | 433 | > |
| 384 | <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" /> | 434 | <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" /> |
| 385 | <Text className="flex-1 text-[17px] text-foreground">{props.label}</Text> | 435 | <Text className="shrink-0 text-[17px] text-foreground" numberOfLines={1}> |
| 386 | {props.value ? ( | 436 | {props.label} |
| 387 | <Text | 437 | </Text> |
| 388 | className="max-w-[180px] text-right text-[16px] text-foreground-muted" | 438 | <View className="min-w-0 flex-1 items-end"> |
| 389 | numberOfLines={1} | 439 | {props.value ? ( |
| 390 | > | 440 | <Text |
| 391 | {props.value} | 441 | className="max-w-[180px] text-right text-[16px] text-foreground-muted" |
| 392 | </Text> | 442 | ellipsizeMode="middle" |
| 393 | ) : null} | 443 | numberOfLines={1} |
| 444 | > | ||
| 445 | {props.value} | ||
| 446 | </Text> | ||
| 447 | ) : null} | ||
| 448 | </View> | ||
| 394 | <SymbolView | 449 | <SymbolView |
| 395 | name="chevron.right" | 450 | name="chevron.right" |
| 396 | size={16} | 451 | size={16} |
apps/mobile/src/components/ProjectFavicon.tsx+42-21| ... | @@ -1,65 +1,86 @@ | ... | @@ -1,65 +1,86 @@ |
| 1 | import { SymbolView } from "expo-symbols"; | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | import { useState } from "react"; | 2 | import { useState } from "react"; |
| 3 | import { Image, View } from "react-native"; | 3 | import { Image, View } from "react-native"; |
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import { useThemeColor } from "../lib/useThemeColor"; | 5 | import { useThemeColor } from "../lib/useThemeColor"; |
| 6 | import { useAssetUrl } from "../state/assets"; | ||
| 5 | 7 | ||
| 6 | /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ | 8 | /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ |
| 7 | const loadedFaviconUrls = new Set<string>(); | 9 | const loadedFaviconUrls = new Set<string>(); |
| 8 | 10 | ||
| 9 | /* ─── Component ──────────────────────────────────────────────────────── */ | 11 | /* ─── Component ──────────────────────────────────────────────────────── */ |
| 10 | export function ProjectFavicon(props: { | 12 | export function ProjectFavicon(props: { |
| 13 | readonly environmentId: EnvironmentId; | ||
| 11 | readonly size?: number; | 14 | readonly size?: number; |
| 12 | readonly projectTitle: string; | 15 | readonly projectTitle: string; |
| 13 | readonly httpBaseUrl?: string | null; | ||
| 14 | readonly workspaceRoot?: string | null; | 16 | readonly workspaceRoot?: string | null; |
| 15 | readonly bearerToken?: string | null; | ||
| 16 | }) { | 17 | }) { |
| 17 | const size = props.size ?? 42; | 18 | const size = props.size ?? 42; |
| 18 | const iconMuted = useThemeColor("--color-icon-subtle"); | 19 | const faviconUrl = useAssetUrl( |
| 20 | props.environmentId, | ||
| 21 | props.workspaceRoot === null || props.workspaceRoot === undefined | ||
| 22 | ? null | ||
| 23 | : { _tag: "project-favicon", cwd: props.workspaceRoot }, | ||
| 24 | ); | ||
| 25 | |||
| 26 | return ( | ||
| 27 | <ProjectFaviconImage | ||
| 28 | key={faviconUrl} | ||
| 29 | faviconUrl={faviconUrl} | ||
| 30 | projectTitle={props.projectTitle} | ||
| 31 | size={size} | ||
| 32 | /> | ||
| 33 | ); | ||
| 34 | } | ||
| 19 | 35 | ||
| 20 | const faviconUrl = | 36 | function ProjectFaviconImage(props: { |
| 21 | props.httpBaseUrl && props.workspaceRoot | 37 | readonly faviconUrl: string | null; |
| 22 | ? `${props.httpBaseUrl}/api/project-favicon?cwd=${encodeURIComponent(props.workspaceRoot)}` | 38 | readonly projectTitle: string; |
| 23 | : null; | 39 | readonly size: number; |
| 40 | }) { | ||
| 41 | const iconMuted = useThemeColor("--color-icon-subtle"); | ||
| 24 | 42 | ||
| 25 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => | 43 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => |
| 26 | faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading", | 44 | props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", |
| 27 | ); | 45 | ); |
| 28 | 46 | ||
| 29 | const showImage = faviconUrl && status === "loaded"; | 47 | const showImage = props.faviconUrl !== null && status === "loaded"; |
| 30 | 48 | ||
| 31 | return ( | 49 | return ( |
| 32 | <View | 50 | <View |
| 33 | style={{ | 51 | style={{ |
| 34 | width: size, | 52 | width: props.size, |
| 35 | height: size, | 53 | height: props.size, |
| 36 | alignItems: "center", | 54 | alignItems: "center", |
| 37 | justifyContent: "center", | 55 | justifyContent: "center", |
| 38 | }} | 56 | }} |
| 39 | > | 57 | > |
| 40 | {/* Folder icon fallback (matches web's FolderIcon) */} | 58 | {/* Folder icon fallback (matches web's FolderIcon) */} |
| 41 | {!showImage ? ( | 59 | {!showImage ? ( |
| 42 | <SymbolView name="folder.fill" size={size * 0.78} tintColor={iconMuted} type="monochrome" /> | 60 | <SymbolView |
| 61 | name="folder.fill" | ||
| 62 | size={props.size * 0.78} | ||
| 63 | tintColor={iconMuted} | ||
| 64 | type="monochrome" | ||
| 65 | /> | ||
| 43 | ) : null} | 66 | ) : null} |
| 44 | 67 | ||
| 45 | {/* Favicon image (hidden until loaded) */} | 68 | {/* Favicon image (hidden until loaded) */} |
| 46 | {faviconUrl ? ( | 69 | {props.faviconUrl ? ( |
| 47 | <Image | 70 | <Image |
| 48 | source={{ | 71 | source={{ |
| 49 | uri: faviconUrl, | 72 | uri: props.faviconUrl, |
| 50 | ...(props.bearerToken | ||
| 51 | ? { headers: { Authorization: `Bearer ${props.bearerToken}` } } | ||
| 52 | : {}), | ||
| 53 | }} | 73 | }} |
| 74 | accessibilityLabel={`${props.projectTitle} favicon`} | ||
| 54 | style={{ | 75 | style={{ |
| 55 | width: size, | 76 | width: props.size, |
| 56 | height: size, | 77 | height: props.size, |
| 57 | borderRadius: size * 0.16, | 78 | borderRadius: props.size * 0.16, |
| 58 | ...(showImage ? {} : { position: "absolute" as const, opacity: 0 }), | 79 | ...(showImage ? {} : { position: "absolute" as const, opacity: 0 }), |
| 59 | }} | 80 | }} |
| 60 | resizeMode="contain" | 81 | resizeMode="contain" |
| 61 | onLoad={() => { | 82 | onLoad={() => { |
| 62 | if (faviconUrl) loadedFaviconUrls.add(faviconUrl); | 83 | if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); |
| 63 | setStatus("loaded"); | 84 | setStatus("loaded"); |
| 64 | }} | 85 | }} |
| 65 | onError={() => setStatus("error")} | 86 | onError={() => setStatus("error")} |
apps/mobile/src/connection/catalog-store.ts created+122| ... | @@ -0,0 +1,122 @@ | ||
| 1 | import { | ||
| 2 | ConnectionCatalogDocument, | ||
| 3 | type ConnectionCatalogDocument as ConnectionCatalogDocumentType, | ||
| 4 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 5 | } from "@t3tools/client-runtime/platform"; | ||
| 6 | import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Ref from "effect/Ref"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import * as Semaphore from "effect/Semaphore"; | ||
| 12 | |||
| 13 | import { migrateLegacyConnectionCatalog } from "./migration"; | ||
| 14 | |||
| 15 | export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; | ||
| 16 | export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; | ||
| 17 | |||
| 18 | function catalogError(operation: string, cause: unknown) { | ||
| 19 | return new ConnectionTransientError({ | ||
| 20 | reason: "remote-unavailable", | ||
| 21 | message: `Could not ${operation} the local connection catalog: ${String(cause)}`, | ||
| 22 | }); | ||
| 23 | } | ||
| 24 | |||
| 25 | const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { | ||
| 26 | const parsed = yield* Effect.try({ | ||
| 27 | try: () => JSON.parse(raw) as unknown, | ||
| 28 | catch: (cause) => catalogError("decode", cause), | ||
| 29 | }); | ||
| 30 | return yield* Effect.fromResult( | ||
| 31 | Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), | ||
| 32 | ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); | ||
| 33 | }); | ||
| 34 | |||
| 35 | const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( | ||
| 36 | catalog: ConnectionCatalogDocumentType, | ||
| 37 | ) { | ||
| 38 | const encoded = yield* Effect.fromResult( | ||
| 39 | Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), | ||
| 40 | ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); | ||
| 41 | return JSON.stringify(encoded); | ||
| 42 | }); | ||
| 43 | |||
| 44 | interface CatalogStore { | ||
| 45 | readonly read: Effect.Effect<ConnectionCatalogDocumentType, ConnectionTransientError>; | ||
| 46 | readonly update: ( | ||
| 47 | transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, | ||
| 48 | ) => Effect.Effect<void, ConnectionTransientError>; | ||
| 49 | } | ||
| 50 | |||
| 51 | export interface SecureCatalogStorage { | ||
| 52 | readonly getItem: (key: string) => Effect.Effect<string | null, ConnectionTransientError>; | ||
| 53 | readonly setItem: (key: string, value: string) => Effect.Effect<void, ConnectionTransientError>; | ||
| 54 | readonly deleteItem: (key: string) => Effect.Effect<void, ConnectionTransientError>; | ||
| 55 | } | ||
| 56 | |||
| 57 | export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* ( | ||
| 58 | storage: SecureCatalogStorage, | ||
| 59 | ) { | ||
| 60 | const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none()); | ||
| 61 | const lock = yield* Semaphore.make(1); | ||
| 62 | |||
| 63 | const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { | ||
| 64 | const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY); | ||
| 65 | const catalog = | ||
| 66 | legacyRaw === null || legacyRaw.trim() === "" | ||
| 67 | ? EMPTY_CONNECTION_CATALOG_DOCUMENT | ||
| 68 | : yield* migrateLegacyConnectionCatalog(legacyRaw).pipe( | ||
| 69 | Effect.mapError((cause) => catalogError("migrate", cause)), | ||
| 70 | Effect.catch((error) => | ||
| 71 | Effect.logWarning("Discarding corrupt legacy mobile connections", error).pipe( | ||
| 72 | Effect.as(EMPTY_CONNECTION_CATALOG_DOCUMENT), | ||
| 73 | ), | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | if (legacyRaw !== null && legacyRaw.trim() !== "") { | ||
| 77 | const encoded = yield* encodeCatalog(catalog); | ||
| 78 | yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); | ||
| 79 | yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY); | ||
| 80 | } | ||
| 81 | return catalog; | ||
| 82 | }); | ||
| 83 | |||
| 84 | const loadUnlocked = Effect.fn("mobile.connectionStorage.loadCatalog")(function* () { | ||
| 85 | const cached = yield* Ref.get(state); | ||
| 86 | if (Option.isSome(cached)) { | ||
| 87 | return cached.value; | ||
| 88 | } | ||
| 89 | const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY); | ||
| 90 | let catalog: ConnectionCatalogDocumentType; | ||
| 91 | if (raw !== null && raw.trim() !== "") { | ||
| 92 | catalog = yield* decodeCatalog(raw).pipe( | ||
| 93 | Effect.catch((error) => | ||
| 94 | Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( | ||
| 95 | Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), | ||
| 96 | Effect.andThen(loadLegacyCatalog()), | ||
| 97 | ), | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | } else { | ||
| 101 | catalog = yield* loadLegacyCatalog(); | ||
| 102 | } | ||
| 103 | yield* Ref.set(state, Option.some(catalog)); | ||
| 104 | return catalog; | ||
| 105 | }); | ||
| 106 | |||
| 107 | const read = lock.withPermits(1)(loadUnlocked()); | ||
| 108 | const update: CatalogStore["update"] = Effect.fn("mobile.connectionStorage.updateCatalog")( | ||
| 109 | function* (transform) { | ||
| 110 | yield* lock.withPermits(1)( | ||
| 111 | Effect.gen(function* () { | ||
| 112 | const next = transform(yield* loadUnlocked()); | ||
| 113 | const encoded = yield* encodeCatalog(next); | ||
| 114 | yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); | ||
| 115 | yield* Ref.set(state, Option.some(next)); | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | }, | ||
| 119 | ); | ||
| 120 | |||
| 121 | return { read, update } satisfies CatalogStore; | ||
| 122 | }); | ||
apps/mobile/src/connection/catalog.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "./runtime"; | ||
| 4 | |||
| 5 | export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime); | ||
apps/mobile/src/connection/migration.test.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | import { migrateLegacyConnectionCatalog } from "./migration"; | ||
| 6 | |||
| 7 | describe("migrateLegacyConnectionCatalog", () => { | ||
| 8 | it.effect("migrates bearer and relay-managed connections into the new catalog", () => | ||
| 9 | Effect.gen(function* () { | ||
| 10 | const bearerEnvironmentId = EnvironmentId.make("bearer-environment"); | ||
| 11 | const relayEnvironmentId = EnvironmentId.make("relay-environment"); | ||
| 12 | const catalog = yield* migrateLegacyConnectionCatalog( | ||
| 13 | JSON.stringify({ | ||
| 14 | connections: [ | ||
| 15 | { | ||
| 16 | environmentId: bearerEnvironmentId, | ||
| 17 | environmentLabel: "Local Mac", | ||
| 18 | pairingUrl: "https://local.example.test/pair", | ||
| 19 | displayUrl: "https://local.example.test", | ||
| 20 | httpBaseUrl: "https://local.example.test", | ||
| 21 | wsBaseUrl: "wss://local.example.test", | ||
| 22 | bearerToken: "bearer-token", | ||
| 23 | authenticationMethod: "bearer", | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | environmentId: relayEnvironmentId, | ||
| 27 | environmentLabel: "Cloud Mac", | ||
| 28 | pairingUrl: "https://relay.example.test", | ||
| 29 | displayUrl: "https://relay.example.test", | ||
| 30 | httpBaseUrl: "https://relay.example.test", | ||
| 31 | wsBaseUrl: "wss://relay.example.test", | ||
| 32 | bearerToken: null, | ||
| 33 | authenticationMethod: "dpop", | ||
| 34 | relayManaged: true, | ||
| 35 | }, | ||
| 36 | ], | ||
| 37 | }), | ||
| 38 | ); | ||
| 39 | |||
| 40 | expect(catalog.targets).toHaveLength(2); | ||
| 41 | expect( | ||
| 42 | catalog.targets.find((target) => target.environmentId === bearerEnvironmentId)?._tag, | ||
| 43 | ).toBe("BearerConnectionTarget"); | ||
| 44 | expect( | ||
| 45 | catalog.targets.find((target) => target.environmentId === relayEnvironmentId)?._tag, | ||
| 46 | ).toBe("RelayConnectionTarget"); | ||
| 47 | expect(catalog.profiles).toHaveLength(1); | ||
| 48 | expect(catalog.credentials).toHaveLength(1); | ||
| 49 | expect(catalog.credentials[0]?.credential).toMatchObject({ | ||
| 50 | _tag: "BearerConnectionCredential", | ||
| 51 | token: "bearer-token", | ||
| 52 | }); | ||
| 53 | }), | ||
| 54 | ); | ||
| 55 | |||
| 56 | it.effect("drops invalid legacy bearer entries without credentials", () => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const catalog = yield* migrateLegacyConnectionCatalog( | ||
| 59 | JSON.stringify({ | ||
| 60 | connections: [ | ||
| 61 | { | ||
| 62 | environmentId: EnvironmentId.make("invalid-bearer"), | ||
| 63 | environmentLabel: "Invalid", | ||
| 64 | pairingUrl: "https://invalid.example.test/pair", | ||
| 65 | displayUrl: "https://invalid.example.test", | ||
| 66 | httpBaseUrl: "https://invalid.example.test", | ||
| 67 | wsBaseUrl: "wss://invalid.example.test", | ||
| 68 | bearerToken: null, | ||
| 69 | authenticationMethod: "bearer", | ||
| 70 | }, | ||
| 71 | ], | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | |||
| 75 | expect(catalog.targets).toEqual([]); | ||
| 76 | }), | ||
| 77 | ); | ||
| 78 | }); | ||
apps/mobile/src/connection/migration.ts created+110| ... | @@ -0,0 +1,110 @@ | ||
| 1 | import { | ||
| 2 | BearerConnectionCredential, | ||
| 3 | BearerConnectionProfile, | ||
| 4 | BearerConnectionRegistration, | ||
| 5 | RelayConnectionRegistration, | ||
| 6 | RelayConnectionTarget, | ||
| 7 | BearerConnectionTarget, | ||
| 8 | } from "@t3tools/client-runtime/connection"; | ||
| 9 | import { | ||
| 10 | type ConnectionCatalogDocument, | ||
| 11 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 12 | registerConnectionInCatalog, | ||
| 13 | } from "@t3tools/client-runtime/platform"; | ||
| 14 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 15 | import * as Effect from "effect/Effect"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | |||
| 18 | const LegacySavedRemoteConnection = Schema.Struct({ | ||
| 19 | environmentId: EnvironmentId, | ||
| 20 | environmentLabel: Schema.String, | ||
| 21 | pairingUrl: Schema.String, | ||
| 22 | displayUrl: Schema.String, | ||
| 23 | httpBaseUrl: Schema.String, | ||
| 24 | wsBaseUrl: Schema.String, | ||
| 25 | bearerToken: Schema.NullOr(Schema.String), | ||
| 26 | authenticationMethod: Schema.optionalKey(Schema.Literals(["bearer", "dpop"])), | ||
| 27 | dpopAccessToken: Schema.optionalKey(Schema.String), | ||
| 28 | relayManaged: Schema.optionalKey(Schema.Literal(true)), | ||
| 29 | }); | ||
| 30 | |||
| 31 | const LegacyConnectionDocument = Schema.Struct({ | ||
| 32 | connections: Schema.optionalKey(Schema.Array(LegacySavedRemoteConnection)), | ||
| 33 | }); | ||
| 34 | const decodeLegacyConnectionDocument = Schema.decodeUnknownEffect(LegacyConnectionDocument); | ||
| 35 | |||
| 36 | export class LegacyConnectionMigrationError extends Schema.TaggedErrorClass<LegacyConnectionMigrationError>()( | ||
| 37 | "LegacyConnectionMigrationError", | ||
| 38 | { | ||
| 39 | message: Schema.String, | ||
| 40 | }, | ||
| 41 | ) {} | ||
| 42 | |||
| 43 | function isRelayManaged(connection: typeof LegacySavedRemoteConnection.Type): boolean { | ||
| 44 | return connection.relayManaged === true || connection.authenticationMethod === "dpop"; | ||
| 45 | } | ||
| 46 | |||
| 47 | function migrateConnection( | ||
| 48 | document: ConnectionCatalogDocument, | ||
| 49 | connection: typeof LegacySavedRemoteConnection.Type, | ||
| 50 | ): ConnectionCatalogDocument { | ||
| 51 | if (isRelayManaged(connection)) { | ||
| 52 | return registerConnectionInCatalog( | ||
| 53 | document, | ||
| 54 | new RelayConnectionRegistration({ | ||
| 55 | target: new RelayConnectionTarget({ | ||
| 56 | environmentId: connection.environmentId, | ||
| 57 | label: connection.environmentLabel, | ||
| 58 | }), | ||
| 59 | }), | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | |||
| 63 | if (connection.bearerToken === null || connection.bearerToken.trim() === "") { | ||
| 64 | return document; | ||
| 65 | } | ||
| 66 | |||
| 67 | const connectionId = `bearer:${connection.environmentId}`; | ||
| 68 | return registerConnectionInCatalog( | ||
| 69 | document, | ||
| 70 | new BearerConnectionRegistration({ | ||
| 71 | target: new BearerConnectionTarget({ | ||
| 72 | environmentId: connection.environmentId, | ||
| 73 | label: connection.environmentLabel, | ||
| 74 | connectionId, | ||
| 75 | }), | ||
| 76 | profile: new BearerConnectionProfile({ | ||
| 77 | connectionId, | ||
| 78 | environmentId: connection.environmentId, | ||
| 79 | label: connection.environmentLabel, | ||
| 80 | httpBaseUrl: connection.httpBaseUrl, | ||
| 81 | wsBaseUrl: connection.wsBaseUrl, | ||
| 82 | }), | ||
| 83 | credential: new BearerConnectionCredential({ | ||
| 84 | token: connection.bearerToken, | ||
| 85 | }), | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | export const migrateLegacyConnectionCatalog = Effect.fn( | ||
| 91 | "mobile.connectionMigration.migrateCatalog", | ||
| 92 | )(function* (raw: string) { | ||
| 93 | const parsed = yield* Effect.try({ | ||
| 94 | try: () => JSON.parse(raw) as unknown, | ||
| 95 | catch: (cause) => | ||
| 96 | new LegacyConnectionMigrationError({ | ||
| 97 | message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`, | ||
| 98 | }), | ||
| 99 | }); | ||
| 100 | const legacy = yield* decodeLegacyConnectionDocument(parsed).pipe( | ||
| 101 | Effect.mapError( | ||
| 102 | (cause) => | ||
| 103 | new LegacyConnectionMigrationError({ | ||
| 104 | message: `Could not decode the legacy mobile connection catalog: ${String(cause)}`, | ||
| 105 | }), | ||
| 106 | ), | ||
| 107 | ); | ||
| 108 | |||
| 109 | return (legacy.connections ?? []).reduce(migrateConnection, EMPTY_CONNECTION_CATALOG_DOCUMENT); | ||
| 110 | }); | ||
apps/mobile/src/connection/onboarding.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | import { ConnectionOnboarding } from "@t3tools/client-runtime/connection"; | ||
| 2 | import { | ||
| 3 | createAtomCommandScheduler, | ||
| 4 | createRuntimeCommand, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | |||
| 9 | import { connectionAtomRuntime } from "./runtime"; | ||
| 10 | |||
| 11 | const onboardingScheduler = createAtomCommandScheduler(); | ||
| 12 | |||
| 13 | export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, { | ||
| 14 | label: "mobile:connection:connect-pairing-url", | ||
| 15 | scheduler: onboardingScheduler, | ||
| 16 | concurrency: { mode: "singleFlight", key: (pairingUrl: string) => pairingUrl }, | ||
| 17 | execute: (pairingUrl: string) => | ||
| 18 | ConnectionOnboarding.pipe( | ||
| 19 | Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })), | ||
| 20 | ), | ||
| 21 | }); | ||
| 22 | |||
| 23 | export const updateBearerConnection = createRuntimeCommand(connectionAtomRuntime, { | ||
| 24 | label: "mobile:connection:update-bearer", | ||
| 25 | scheduler: onboardingScheduler, | ||
| 26 | concurrency: { | ||
| 27 | mode: "serial", | ||
| 28 | key: (input: { readonly environmentId: EnvironmentId }) => input.environmentId, | ||
| 29 | }, | ||
| 30 | execute: (input: { | ||
| 31 | readonly environmentId: EnvironmentId; | ||
| 32 | readonly label: string; | ||
| 33 | readonly httpBaseUrl: string; | ||
| 34 | }) => ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.updateBearer(input))), | ||
| 35 | }); | ||
apps/mobile/src/connection/platform.ts created+200| ... | @@ -0,0 +1,200 @@ | ||
| 1 | import { | ||
| 2 | ClientPresentation, | ||
| 3 | CloudSession, | ||
| 4 | EnvironmentOwnedDataCleanup, | ||
| 5 | PlatformConnectionSource, | ||
| 6 | RelayDeviceIdentity, | ||
| 7 | SshEnvironmentGateway, | ||
| 8 | } from "@t3tools/client-runtime/platform"; | ||
| 9 | import { | ||
| 10 | ConnectionBlockedError, | ||
| 11 | ConnectionTransientError, | ||
| 12 | ConnectionWakeups, | ||
| 13 | Connectivity, | ||
| 14 | } from "@t3tools/client-runtime/connection"; | ||
| 15 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; | ||
| 16 | import { AuthStandardClientScopes } from "@t3tools/contracts"; | ||
| 17 | import * as Context from "effect/Context"; | ||
| 18 | import * as Effect from "effect/Effect"; | ||
| 19 | import * as Layer from "effect/Layer"; | ||
| 20 | import * as Option from "effect/Option"; | ||
| 21 | import * as Queue from "effect/Queue"; | ||
| 22 | import * as Stream from "effect/Stream"; | ||
| 23 | import * as Network from "expo-network"; | ||
| 24 | import { AppState } from "react-native"; | ||
| 25 | |||
| 26 | import { authClientMetadata } from "../lib/authClientMetadata"; | ||
| 27 | import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; | ||
| 28 | import { appAtomRegistry } from "../state/atom-registry"; | ||
| 29 | import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; | ||
| 30 | import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; | ||
| 31 | import { connectionStorageLayer } from "./storage"; | ||
| 32 | |||
| 33 | function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { | ||
| 34 | if (state.isConnected === false || state.isInternetReachable === false) { | ||
| 35 | return "offline"; | ||
| 36 | } | ||
| 37 | if (state.isConnected === true) { | ||
| 38 | return "online"; | ||
| 39 | } | ||
| 40 | return "unknown"; | ||
| 41 | } | ||
| 42 | |||
| 43 | const connectivityLayer = Layer.succeed( | ||
| 44 | Connectivity, | ||
| 45 | Connectivity.of({ | ||
| 46 | status: Effect.tryPromise({ | ||
| 47 | try: () => Network.getNetworkStateAsync(), | ||
| 48 | catch: () => undefined, | ||
| 49 | }).pipe( | ||
| 50 | Effect.match({ | ||
| 51 | onFailure: () => "unknown" as const, | ||
| 52 | onSuccess: networkStatus, | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | changes: Stream.callback((queue) => | ||
| 56 | Effect.acquireRelease( | ||
| 57 | Effect.sync(() => | ||
| 58 | Network.addNetworkStateListener((state) => { | ||
| 59 | Queue.offerUnsafe(queue, networkStatus(state)); | ||
| 60 | }), | ||
| 61 | ), | ||
| 62 | (subscription) => Effect.sync(() => subscription.remove()), | ||
| 63 | ).pipe(Effect.asVoid), | ||
| 64 | ), | ||
| 65 | }), | ||
| 66 | ); | ||
| 67 | |||
| 68 | const wakeupsLayer = Layer.succeed( | ||
| 69 | ConnectionWakeups, | ||
| 70 | ConnectionWakeups.of({ | ||
| 71 | changes: Stream.merge( | ||
| 72 | Stream.callback<"application-active">((queue) => | ||
| 73 | Effect.acquireRelease( | ||
| 74 | Effect.sync(() => | ||
| 75 | AppState.addEventListener("change", (state) => { | ||
| 76 | if (state === "active") { | ||
| 77 | Queue.offerUnsafe(queue, "application-active"); | ||
| 78 | } | ||
| 79 | }), | ||
| 80 | ), | ||
| 81 | (subscription) => Effect.sync(() => subscription.remove()), | ||
| 82 | ).pipe(Effect.asVoid), | ||
| 83 | ), | ||
| 84 | managedRelayAccountChanges(appAtomRegistry).pipe( | ||
| 85 | Stream.map(() => "credentials-changed" as const), | ||
| 86 | ), | ||
| 87 | ), | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | |||
| 91 | const capabilitiesLayer = Layer.succeedContext( | ||
| 92 | Context.make( | ||
| 93 | CloudSession, | ||
| 94 | CloudSession.of({ | ||
| 95 | clerkToken: Effect.gen(function* () { | ||
| 96 | const session = appAtomRegistry.get(managedRelaySessionAtom); | ||
| 97 | if (session === null) { | ||
| 98 | return yield* new ConnectionBlockedError({ | ||
| 99 | reason: "authentication", | ||
| 100 | message: "Sign in to T3 Cloud to connect this environment.", | ||
| 101 | }); | ||
| 102 | } | ||
| 103 | const token = yield* session.readClerkToken().pipe( | ||
| 104 | Effect.mapError( | ||
| 105 | (error) => | ||
| 106 | new ConnectionTransientError({ | ||
| 107 | reason: "network", | ||
| 108 | message: error.message, | ||
| 109 | }), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | if (token === null) { | ||
| 113 | return yield* new ConnectionBlockedError({ | ||
| 114 | reason: "authentication", | ||
| 115 | message: "The T3 Cloud session is unavailable.", | ||
| 116 | }); | ||
| 117 | } | ||
| 118 | return token; | ||
| 119 | }), | ||
| 120 | }), | ||
| 121 | ).pipe( | ||
| 122 | Context.add( | ||
| 123 | RelayDeviceIdentity, | ||
| 124 | RelayDeviceIdentity.of({ | ||
| 125 | deviceId: Effect.tryPromise({ | ||
| 126 | try: () => loadOrCreateAgentAwarenessDeviceId(), | ||
| 127 | catch: (cause) => | ||
| 128 | new ConnectionTransientError({ | ||
| 129 | reason: "remote-unavailable", | ||
| 130 | message: `Could not load the mobile device identity: ${String(cause)}`, | ||
| 131 | }), | ||
| 132 | }).pipe(Effect.map(Option.some)), | ||
| 133 | }), | ||
| 134 | ), | ||
| 135 | Context.add( | ||
| 136 | ClientPresentation, | ||
| 137 | ClientPresentation.of({ | ||
| 138 | metadata: authClientMetadata(), | ||
| 139 | scopes: AuthStandardClientScopes, | ||
| 140 | }), | ||
| 141 | ), | ||
| 142 | Context.add( | ||
| 143 | SshEnvironmentGateway, | ||
| 144 | SshEnvironmentGateway.of({ | ||
| 145 | provision: () => | ||
| 146 | Effect.fail( | ||
| 147 | new ConnectionBlockedError({ | ||
| 148 | reason: "unsupported", | ||
| 149 | message: "SSH environments are only available in the desktop app.", | ||
| 150 | }), | ||
| 151 | ), | ||
| 152 | prepare: () => | ||
| 153 | Effect.fail( | ||
| 154 | new ConnectionBlockedError({ | ||
| 155 | reason: "unsupported", | ||
| 156 | message: "SSH environments are only available in the desktop app.", | ||
| 157 | }), | ||
| 158 | ), | ||
| 159 | disconnect: () => Effect.void, | ||
| 160 | }), | ||
| 161 | ), | ||
| 162 | ), | ||
| 163 | ); | ||
| 164 | |||
| 165 | const platformConnectionSourceLayer = Layer.succeed( | ||
| 166 | PlatformConnectionSource, | ||
| 167 | PlatformConnectionSource.of({ | ||
| 168 | registrations: Stream.empty, | ||
| 169 | }), | ||
| 170 | ); | ||
| 171 | |||
| 172 | const environmentOwnedDataCleanupLayer = Layer.succeed( | ||
| 173 | EnvironmentOwnedDataCleanup, | ||
| 174 | EnvironmentOwnedDataCleanup.of({ | ||
| 175 | clear: (environmentId) => | ||
| 176 | Effect.all( | ||
| 177 | [ | ||
| 178 | Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), | ||
| 179 | Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), | ||
| 180 | ], | ||
| 181 | { concurrency: "unbounded", discard: true }, | ||
| 182 | ).pipe( | ||
| 183 | Effect.catch((cause) => | ||
| 184 | Effect.logWarning("Could not clear mobile environment-owned data.", { | ||
| 185 | environmentId, | ||
| 186 | cause, | ||
| 187 | }), | ||
| 188 | ), | ||
| 189 | ), | ||
| 190 | }), | ||
| 191 | ); | ||
| 192 | |||
| 193 | export const connectionPlatformLayer = Layer.mergeAll( | ||
| 194 | connectionStorageLayer, | ||
| 195 | connectivityLayer, | ||
| 196 | wakeupsLayer, | ||
| 197 | capabilitiesLayer, | ||
| 198 | platformConnectionSourceLayer, | ||
| 199 | environmentOwnedDataCleanupLayer, | ||
| 200 | ); | ||
apps/mobile/src/connection/runtime.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { runtimeContextLayer } from "../lib/runtime"; | ||
| 6 | import { connectionPlatformLayer } from "./platform"; | ||
| 7 | |||
| 8 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( | ||
| 9 | Layer.provide(runtimeContextLayer), | ||
| 10 | ); | ||
| 11 | |||
| 12 | export const connectionLayer = clientConnectionLayer.pipe( | ||
| 13 | Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), | ||
| 14 | ); | ||
| 15 | |||
| 16 | export const connectionAtomRuntime = Atom.runtime(connectionLayer); | ||
apps/mobile/src/connection/storage.test.ts created+84| ... | @@ -0,0 +1,84 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | CONNECTION_CATALOG_KEY, | ||
| 6 | LEGACY_CONNECTIONS_KEY, | ||
| 7 | makeCatalogStore, | ||
| 8 | type SecureCatalogStorage, | ||
| 9 | } from "./catalog-store"; | ||
| 10 | |||
| 11 | function makeStorage(initial: Readonly<Record<string, string>>) { | ||
| 12 | const values = new Map(Object.entries(initial)); | ||
| 13 | const deleted: Array<string> = []; | ||
| 14 | const storage: SecureCatalogStorage = { | ||
| 15 | getItem: (key) => Effect.sync(() => values.get(key) ?? null), | ||
| 16 | setItem: (key, value) => | ||
| 17 | Effect.sync(() => { | ||
| 18 | values.set(key, value); | ||
| 19 | }), | ||
| 20 | deleteItem: (key) => | ||
| 21 | Effect.sync(() => { | ||
| 22 | deleted.push(key); | ||
| 23 | values.delete(key); | ||
| 24 | }), | ||
| 25 | }; | ||
| 26 | return { deleted, storage, values }; | ||
| 27 | } | ||
| 28 | |||
| 29 | describe("mobile connection catalog storage", () => { | ||
| 30 | it.effect("recovers from a corrupt current catalog", () => | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const memory = makeStorage({ | ||
| 33 | [CONNECTION_CATALOG_KEY]: "{not-json", | ||
| 34 | }); | ||
| 35 | const catalog = yield* makeCatalogStore(memory.storage); | ||
| 36 | |||
| 37 | expect((yield* catalog.read).targets).toEqual([]); | ||
| 38 | expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); | ||
| 39 | }), | ||
| 40 | ); | ||
| 41 | |||
| 42 | it.effect("replaces and removes a corrupt legacy catalog", () => | ||
| 43 | Effect.gen(function* () { | ||
| 44 | const memory = makeStorage({ | ||
| 45 | [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), | ||
| 46 | }); | ||
| 47 | const catalog = yield* makeCatalogStore(memory.storage); | ||
| 48 | |||
| 49 | expect((yield* catalog.read).targets).toEqual([]); | ||
| 50 | expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); | ||
| 51 | expect(memory.values.has(CONNECTION_CATALOG_KEY)).toBe(true); | ||
| 52 | }), | ||
| 53 | ); | ||
| 54 | |||
| 55 | it.effect("falls back to valid legacy data when the current catalog is corrupt", () => | ||
| 56 | Effect.gen(function* () { | ||
| 57 | const memory = makeStorage({ | ||
| 58 | [CONNECTION_CATALOG_KEY]: "{not-json", | ||
| 59 | [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ | ||
| 60 | connections: [ | ||
| 61 | { | ||
| 62 | environmentId: "legacy-environment", | ||
| 63 | environmentLabel: "Legacy", | ||
| 64 | pairingUrl: "https://legacy.example.test/pair", | ||
| 65 | displayUrl: "https://legacy.example.test", | ||
| 66 | httpBaseUrl: "https://legacy.example.test", | ||
| 67 | wsBaseUrl: "wss://legacy.example.test", | ||
| 68 | bearerToken: "legacy-token", | ||
| 69 | authenticationMethod: "bearer", | ||
| 70 | }, | ||
| 71 | ], | ||
| 72 | }), | ||
| 73 | }); | ||
| 74 | const catalog = yield* makeCatalogStore(memory.storage); | ||
| 75 | |||
| 76 | expect((yield* catalog.read).targets).toHaveLength(1); | ||
| 77 | expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]); | ||
| 78 | |||
| 79 | yield* catalog.update((document) => document); | ||
| 80 | expect(memory.values.has(CONNECTION_CATALOG_KEY)).toBe(true); | ||
| 81 | expect(memory.values.has(LEGACY_CONNECTIONS_KEY)).toBe(false); | ||
| 82 | }), | ||
| 83 | ); | ||
| 84 | }); | ||
apps/mobile/src/connection/storage.ts created+432| ... | @@ -0,0 +1,432 @@ | ||
| 1 | import { | ||
| 2 | ConnectionPersistenceError, | ||
| 3 | ConnectionRegistrationStore, | ||
| 4 | ConnectionTargetStore, | ||
| 5 | EnvironmentCacheStore, | ||
| 6 | registerConnectionInCatalog, | ||
| 7 | removeConnectionFromCatalog, | ||
| 8 | removeCatalogValue, | ||
| 9 | replaceCatalogValue, | ||
| 10 | } from "@t3tools/client-runtime/platform"; | ||
| 11 | import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; | ||
| 12 | import { | ||
| 13 | ConnectionCredentialStore, | ||
| 14 | ConnectionProfileStore, | ||
| 15 | ConnectionTransientError, | ||
| 16 | } from "@t3tools/client-runtime/connection"; | ||
| 17 | import { | ||
| 18 | EnvironmentId, | ||
| 19 | OrchestrationThread, | ||
| 20 | OrchestrationShellSnapshot, | ||
| 21 | ThreadId, | ||
| 22 | } from "@t3tools/contracts"; | ||
| 23 | import * as Context from "effect/Context"; | ||
| 24 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as Layer from "effect/Layer"; | ||
| 26 | import * as Option from "effect/Option"; | ||
| 27 | import * as Schema from "effect/Schema"; | ||
| 28 | import * as SecureStore from "expo-secure-store"; | ||
| 29 | |||
| 30 | import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; | ||
| 31 | |||
| 32 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | ||
| 33 | const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; | ||
| 34 | const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; | ||
| 35 | const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | ||
| 36 | const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; | ||
| 37 | |||
| 38 | const StoredShellSnapshot = Schema.Struct({ | ||
| 39 | schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), | ||
| 40 | environmentId: EnvironmentId, | ||
| 41 | snapshot: OrchestrationShellSnapshot, | ||
| 42 | }); | ||
| 43 | |||
| 44 | const StoredThreadSnapshot = Schema.Struct({ | ||
| 45 | schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), | ||
| 46 | environmentId: EnvironmentId, | ||
| 47 | threadId: ThreadId, | ||
| 48 | thread: OrchestrationThread, | ||
| 49 | }); | ||
| 50 | |||
| 51 | const LegacyStoredShellSnapshot = Schema.Struct({ | ||
| 52 | schemaVersion: Schema.Literal(1), | ||
| 53 | environmentId: EnvironmentId, | ||
| 54 | snapshotReceivedAt: Schema.String, | ||
| 55 | snapshot: OrchestrationShellSnapshot, | ||
| 56 | }); | ||
| 57 | |||
| 58 | function catalogError(operation: string, cause: unknown) { | ||
| 59 | return new ConnectionTransientError({ | ||
| 60 | reason: "remote-unavailable", | ||
| 61 | message: `Could not ${operation} the local connection catalog: ${String(cause)}`, | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | |||
| 65 | function shellPersistenceError( | ||
| 66 | operation: | ||
| 67 | | "load-shell" | ||
| 68 | | "save-shell" | ||
| 69 | | "load-thread" | ||
| 70 | | "save-thread" | ||
| 71 | | "remove-thread" | ||
| 72 | | "clear-environment", | ||
| 73 | cause: unknown, | ||
| 74 | ) { | ||
| 75 | return new ConnectionPersistenceError({ | ||
| 76 | operation, | ||
| 77 | message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, | ||
| 78 | }); | ||
| 79 | } | ||
| 80 | |||
| 81 | function threadSnapshotFileName(threadId: ThreadId): string { | ||
| 82 | return `${encodeURIComponent(threadId)}.json`; | ||
| 83 | } | ||
| 84 | |||
| 85 | const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( | ||
| 86 | function* ( | ||
| 87 | environmentId: EnvironmentId, | ||
| 88 | operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment", | ||
| 89 | ) { | ||
| 90 | return yield* Effect.tryPromise({ | ||
| 91 | try: async () => { | ||
| 92 | const { Directory, Paths } = await import("expo-file-system"); | ||
| 93 | const directory = new Directory( | ||
| 94 | Paths.document, | ||
| 95 | THREAD_SNAPSHOT_CACHE_DIRECTORY, | ||
| 96 | encodeURIComponent(environmentId), | ||
| 97 | ); | ||
| 98 | if (operation !== "clear-environment") { | ||
| 99 | directory.create({ idempotent: true, intermediates: true }); | ||
| 100 | } | ||
| 101 | return directory; | ||
| 102 | }, | ||
| 103 | catch: (cause) => shellPersistenceError(operation, cause), | ||
| 104 | }); | ||
| 105 | }, | ||
| 106 | ); | ||
| 107 | |||
| 108 | const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* ( | ||
| 109 | environmentId: EnvironmentId, | ||
| 110 | threadId: ThreadId, | ||
| 111 | operation: "load-thread" | "save-thread" | "remove-thread", | ||
| 112 | ) { | ||
| 113 | const { File } = yield* Effect.promise(() => import("expo-file-system")); | ||
| 114 | return new File( | ||
| 115 | yield* threadSnapshotDirectory(environmentId, operation), | ||
| 116 | threadSnapshotFileName(threadId), | ||
| 117 | ); | ||
| 118 | }); | ||
| 119 | |||
| 120 | function targetPersistenceError( | ||
| 121 | operation: "list-targets" | "register-connection" | "remove-connection", | ||
| 122 | error: ConnectionTransientError, | ||
| 123 | ) { | ||
| 124 | return new ConnectionPersistenceError({ | ||
| 125 | operation, | ||
| 126 | message: error.message, | ||
| 127 | }); | ||
| 128 | } | ||
| 129 | |||
| 130 | const secureCatalogStorage: SecureCatalogStorage = { | ||
| 131 | getItem: (key) => | ||
| 132 | Effect.tryPromise({ | ||
| 133 | try: () => SecureStore.getItemAsync(key), | ||
| 134 | catch: (cause) => catalogError("load", cause), | ||
| 135 | }), | ||
| 136 | setItem: (key, value) => | ||
| 137 | Effect.tryPromise({ | ||
| 138 | try: () => SecureStore.setItemAsync(key, value), | ||
| 139 | catch: (cause) => catalogError("save", cause), | ||
| 140 | }), | ||
| 141 | deleteItem: (key) => | ||
| 142 | Effect.tryPromise({ | ||
| 143 | try: () => SecureStore.deleteItemAsync(key), | ||
| 144 | catch: (cause) => catalogError("delete", cause), | ||
| 145 | }), | ||
| 146 | }; | ||
| 147 | |||
| 148 | function shellSnapshotFileName(environmentId: EnvironmentId): string { | ||
| 149 | return `${encodeURIComponent(environmentId)}.json`; | ||
| 150 | } | ||
| 151 | |||
| 152 | const shellSnapshotFileInDirectory = Effect.fn( | ||
| 153 | "mobile.connectionStorage.shellSnapshotFileInDirectory", | ||
| 154 | )(function* ( | ||
| 155 | environmentId: EnvironmentId, | ||
| 156 | operation: "load-shell" | "save-shell" | "clear-environment", | ||
| 157 | directoryName: string, | ||
| 158 | ) { | ||
| 159 | return yield* Effect.tryPromise({ | ||
| 160 | try: async () => { | ||
| 161 | const { Directory, File, Paths } = await import("expo-file-system"); | ||
| 162 | const directory = new Directory(Paths.document, directoryName); | ||
| 163 | directory.create({ idempotent: true, intermediates: true }); | ||
| 164 | return new File(directory, shellSnapshotFileName(environmentId)); | ||
| 165 | }, | ||
| 166 | catch: (cause) => shellPersistenceError(operation, cause), | ||
| 167 | }); | ||
| 168 | }); | ||
| 169 | |||
| 170 | const shellSnapshotFile = ( | ||
| 171 | environmentId: EnvironmentId, | ||
| 172 | operation: "load-shell" | "save-shell" | "clear-environment", | ||
| 173 | ) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY); | ||
| 174 | |||
| 175 | const legacyShellSnapshotFile = ( | ||
| 176 | environmentId: EnvironmentId, | ||
| 177 | operation: "load-shell" | "clear-environment", | ||
| 178 | ) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY); | ||
| 179 | |||
| 180 | export const connectionStorageLayer = Layer.effectContext( | ||
| 181 | Effect.gen(function* () { | ||
| 182 | const catalog = yield* makeCatalogStore(secureCatalogStorage); | ||
| 183 | |||
| 184 | const targetStore = ConnectionTargetStore.of({ | ||
| 185 | list: catalog.read.pipe( | ||
| 186 | Effect.map((document) => document.targets), | ||
| 187 | Effect.mapError((error) => targetPersistenceError("list-targets", error)), | ||
| 188 | ), | ||
| 189 | }); | ||
| 190 | const registrationStore = ConnectionRegistrationStore.of({ | ||
| 191 | register: (registration) => | ||
| 192 | catalog | ||
| 193 | .update((document) => registerConnectionInCatalog(document, registration)) | ||
| 194 | .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), | ||
| 195 | remove: (target) => | ||
| 196 | catalog | ||
| 197 | .update((document) => removeConnectionFromCatalog(document, target)) | ||
| 198 | .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), | ||
| 199 | }); | ||
| 200 | const profileStore = ConnectionProfileStore.of({ | ||
| 201 | get: (connectionId) => | ||
| 202 | catalog.read.pipe( | ||
| 203 | Effect.map((document) => | ||
| 204 | Option.fromUndefinedOr( | ||
| 205 | document.profiles.find((candidate) => candidate.connectionId === connectionId), | ||
| 206 | ), | ||
| 207 | ), | ||
| 208 | ), | ||
| 209 | put: (profile) => | ||
| 210 | catalog.update((document) => ({ | ||
| 211 | ...document, | ||
| 212 | profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile), | ||
| 213 | })), | ||
| 214 | remove: (connectionId) => | ||
| 215 | catalog.update((document) => ({ | ||
| 216 | ...document, | ||
| 217 | profiles: removeCatalogValue( | ||
| 218 | document.profiles, | ||
| 219 | (value) => value.connectionId, | ||
| 220 | connectionId, | ||
| 221 | ), | ||
| 222 | })), | ||
| 223 | }); | ||
| 224 | const credentialStore = ConnectionCredentialStore.of({ | ||
| 225 | get: (connectionId) => | ||
| 226 | catalog.read.pipe( | ||
| 227 | Effect.map((document) => | ||
| 228 | Option.fromUndefinedOr( | ||
| 229 | document.credentials.find((entry) => entry.connectionId === connectionId)?.credential, | ||
| 230 | ), | ||
| 231 | ), | ||
| 232 | ), | ||
| 233 | put: (connectionId, credential) => | ||
| 234 | catalog.update((document) => ({ | ||
| 235 | ...document, | ||
| 236 | credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, { | ||
| 237 | connectionId, | ||
| 238 | credential, | ||
| 239 | }), | ||
| 240 | })), | ||
| 241 | remove: (connectionId) => | ||
| 242 | catalog.update((document) => ({ | ||
| 243 | ...document, | ||
| 244 | credentials: removeCatalogValue( | ||
| 245 | document.credentials, | ||
| 246 | (value) => value.connectionId, | ||
| 247 | connectionId, | ||
| 248 | ), | ||
| 249 | })), | ||
| 250 | }); | ||
| 251 | const remoteTokenStore = RemoteDpopAccessTokenStore.of({ | ||
| 252 | get: (environmentId) => | ||
| 253 | catalog.read.pipe( | ||
| 254 | Effect.map((document) => | ||
| 255 | Option.fromUndefinedOr( | ||
| 256 | document.remoteDpopTokens.find((token) => token.environmentId === environmentId), | ||
| 257 | ), | ||
| 258 | ), | ||
| 259 | ), | ||
| 260 | put: (token) => | ||
| 261 | catalog.update((document) => ({ | ||
| 262 | ...document, | ||
| 263 | remoteDpopTokens: replaceCatalogValue( | ||
| 264 | document.remoteDpopTokens, | ||
| 265 | (value) => value.environmentId, | ||
| 266 | token, | ||
| 267 | ), | ||
| 268 | })), | ||
| 269 | remove: (environmentId) => | ||
| 270 | catalog.update((document) => ({ | ||
| 271 | ...document, | ||
| 272 | remoteDpopTokens: removeCatalogValue( | ||
| 273 | document.remoteDpopTokens, | ||
| 274 | (value) => value.environmentId, | ||
| 275 | environmentId, | ||
| 276 | ), | ||
| 277 | })), | ||
| 278 | }); | ||
| 279 | const cacheStore = EnvironmentCacheStore.of({ | ||
| 280 | loadShell: (environmentId) => | ||
| 281 | Effect.gen(function* () { | ||
| 282 | const file = yield* shellSnapshotFile(environmentId, "load-shell"); | ||
| 283 | if (file.exists) { | ||
| 284 | const raw = yield* Effect.tryPromise({ | ||
| 285 | try: () => file.text(), | ||
| 286 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 287 | }); | ||
| 288 | const parsed = yield* Effect.try({ | ||
| 289 | try: () => JSON.parse(raw) as unknown, | ||
| 290 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 291 | }); | ||
| 292 | const stored = yield* Effect.fromResult( | ||
| 293 | Schema.decodeUnknownResult(StoredShellSnapshot)(parsed), | ||
| 294 | ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); | ||
| 295 | return stored.environmentId === environmentId | ||
| 296 | ? Option.some(stored.snapshot) | ||
| 297 | : Option.none(); | ||
| 298 | } | ||
| 299 | |||
| 300 | const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell"); | ||
| 301 | if (!legacyFile.exists) { | ||
| 302 | return Option.none(); | ||
| 303 | } | ||
| 304 | const legacyRaw = yield* Effect.tryPromise({ | ||
| 305 | try: () => legacyFile.text(), | ||
| 306 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 307 | }); | ||
| 308 | const legacyParsed = yield* Effect.try({ | ||
| 309 | try: () => JSON.parse(legacyRaw) as unknown, | ||
| 310 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 311 | }); | ||
| 312 | const legacyStored = yield* Effect.fromResult( | ||
| 313 | Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), | ||
| 314 | ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); | ||
| 315 | return legacyStored.environmentId === environmentId | ||
| 316 | ? Option.some(legacyStored.snapshot) | ||
| 317 | : Option.none(); | ||
| 318 | }), | ||
| 319 | saveShell: (environmentId, snapshot) => | ||
| 320 | Effect.gen(function* () { | ||
| 321 | const file = yield* shellSnapshotFile(environmentId, "save-shell"); | ||
| 322 | const stored = { | ||
| 323 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | ||
| 324 | environmentId, | ||
| 325 | snapshot, | ||
| 326 | } as const; | ||
| 327 | const encoded = yield* Effect.fromResult( | ||
| 328 | Schema.encodeUnknownResult(StoredShellSnapshot)(stored), | ||
| 329 | ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); | ||
| 330 | yield* Effect.try({ | ||
| 331 | try: () => { | ||
| 332 | if (!file.exists) { | ||
| 333 | file.create({ intermediates: true, overwrite: true }); | ||
| 334 | } | ||
| 335 | file.write(JSON.stringify(encoded)); | ||
| 336 | }, | ||
| 337 | catch: (cause) => shellPersistenceError("save-shell", cause), | ||
| 338 | }); | ||
| 339 | }), | ||
| 340 | loadThread: (environmentId, threadId) => | ||
| 341 | Effect.gen(function* () { | ||
| 342 | const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); | ||
| 343 | if (!file.exists) { | ||
| 344 | return Option.none(); | ||
| 345 | } | ||
| 346 | const raw = yield* Effect.tryPromise({ | ||
| 347 | try: () => file.text(), | ||
| 348 | catch: (cause) => shellPersistenceError("load-thread", cause), | ||
| 349 | }); | ||
| 350 | const parsed = yield* Effect.try({ | ||
| 351 | try: () => JSON.parse(raw) as unknown, | ||
| 352 | catch: (cause) => shellPersistenceError("load-thread", cause), | ||
| 353 | }); | ||
| 354 | const stored = yield* Effect.fromResult( | ||
| 355 | Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed), | ||
| 356 | ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause))); | ||
| 357 | return stored.environmentId === environmentId && stored.threadId === threadId | ||
| 358 | ? Option.some(stored.thread) | ||
| 359 | : Option.none(); | ||
| 360 | }), | ||
| 361 | saveThread: (environmentId, thread) => | ||
| 362 | Effect.gen(function* () { | ||
| 363 | const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread"); | ||
| 364 | const encoded = yield* Effect.fromResult( | ||
| 365 | Schema.encodeUnknownResult(StoredThreadSnapshot)({ | ||
| 366 | schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, | ||
| 367 | environmentId, | ||
| 368 | threadId: thread.id, | ||
| 369 | thread, | ||
| 370 | }), | ||
| 371 | ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); | ||
| 372 | yield* Effect.try({ | ||
| 373 | try: () => { | ||
| 374 | if (!file.exists) { | ||
| 375 | file.create({ intermediates: true, overwrite: true }); | ||
| 376 | } | ||
| 377 | file.write(JSON.stringify(encoded)); | ||
| 378 | }, | ||
| 379 | catch: (cause) => shellPersistenceError("save-thread", cause), | ||
| 380 | }); | ||
| 381 | }), | ||
| 382 | removeThread: (environmentId, threadId) => | ||
| 383 | Effect.gen(function* () { | ||
| 384 | const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); | ||
| 385 | if (file.exists) { | ||
| 386 | file.delete(); | ||
| 387 | } | ||
| 388 | }).pipe( | ||
| 389 | Effect.mapError((cause) => | ||
| 390 | cause._tag === "ConnectionPersistenceError" | ||
| 391 | ? cause | ||
| 392 | : shellPersistenceError("remove-thread", cause), | ||
| 393 | ), | ||
| 394 | ), | ||
| 395 | clear: (environmentId) => | ||
| 396 | Effect.gen(function* () { | ||
| 397 | const file = yield* shellSnapshotFile(environmentId, "clear-environment"); | ||
| 398 | if (file.exists) { | ||
| 399 | yield* Effect.try({ | ||
| 400 | try: () => file.delete(), | ||
| 401 | catch: (cause) => shellPersistenceError("clear-environment", cause), | ||
| 402 | }); | ||
| 403 | } | ||
| 404 | const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); | ||
| 405 | if (legacyFile.exists) { | ||
| 406 | yield* Effect.try({ | ||
| 407 | try: () => legacyFile.delete(), | ||
| 408 | catch: (cause) => shellPersistenceError("clear-environment", cause), | ||
| 409 | }); | ||
| 410 | } | ||
| 411 | const threadDirectory = yield* threadSnapshotDirectory( | ||
| 412 | environmentId, | ||
| 413 | "clear-environment", | ||
| 414 | ); | ||
| 415 | if (threadDirectory.exists) { | ||
| 416 | yield* Effect.try({ | ||
| 417 | try: () => threadDirectory.delete(), | ||
| 418 | catch: (cause) => shellPersistenceError("clear-environment", cause), | ||
| 419 | }); | ||
| 420 | } | ||
| 421 | }), | ||
| 422 | }); | ||
| 423 | |||
| 424 | return Context.make(ConnectionTargetStore, targetStore).pipe( | ||
| 425 | Context.add(ConnectionRegistrationStore, registrationStore), | ||
| 426 | Context.add(ConnectionProfileStore, profileStore), | ||
| 427 | Context.add(ConnectionCredentialStore, credentialStore), | ||
| 428 | Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), | ||
| 429 | Context.add(EnvironmentCacheStore, cacheStore), | ||
| 430 | ); | ||
| 431 | }), | ||
| 432 | ); | ||
apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+55-59| ... | @@ -2,7 +2,8 @@ import { beforeEach, vi } from "vite-plus/test"; | ... | @@ -2,7 +2,8 @@ import { beforeEach, vi } from "vite-plus/test"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | import { ManagedRelayClient } from "@t3tools/client-runtime"; | 5 | import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; |
| 6 | import * as Layer from "effect/Layer"; | ||
| 6 | import { HttpClient } from "effect/unstable/http"; | 7 | import { HttpClient } from "effect/unstable/http"; |
| 7 | 8 | ||
| 8 | import type { SavedRemoteConnection } from "../../lib/connection"; | 9 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| ... | @@ -33,90 +34,85 @@ const connection: SavedRemoteConnection = { | ... | @@ -33,90 +34,85 @@ const connection: SavedRemoteConnection = { |
| 33 | bearerToken: "local-bearer", | 34 | bearerToken: "local-bearer", |
| 34 | }; | 35 | }; |
| 35 | 36 | ||
| 36 | const runWithHttpClient = <A, E>( | 37 | const testLayer = Layer.mergeAll( |
| 37 | effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient>, | 38 | Layer.succeed(ManagedRelayClient, null as never), |
| 38 | ): Promise<A> => | 39 | Layer.succeed( |
| 39 | Effect.runPromise( | 40 | HttpClient.HttpClient, |
| 40 | effect.pipe( | 41 | HttpClient.make(() => Effect.die("unexpected HTTP request")), |
| 41 | Effect.provideService(ManagedRelayClient, null as never), | 42 | ), |
| 42 | Effect.provideService( | 43 | ); |
| 43 | HttpClient.HttpClient, | ||
| 44 | HttpClient.make(() => Effect.die("unexpected HTTP request")), | ||
| 45 | ), | ||
| 46 | ), | ||
| 47 | ); | ||
| 48 | 44 | ||
| 49 | describe("liveActivityPreferences", () => { | 45 | describe("liveActivityPreferences", () => { |
| 50 | beforeEach(() => { | 46 | beforeEach(() => { |
| 51 | vi.clearAllMocks(); | 47 | vi.clearAllMocks(); |
| 52 | }); | 48 | }); |
| 53 | 49 | ||
| 54 | it("pushes disabled Live Activity preferences to relay registrations", async () => { | 50 | it.effect("pushes disabled Live Activity preferences to relay registrations", () => |
| 55 | await runWithHttpClient( | 51 | Effect.gen(function* () { |
| 56 | setLiveActivityUpdatesEnabled({ | 52 | yield* setLiveActivityUpdatesEnabled({ |
| 57 | enabled: false, | 53 | enabled: false, |
| 58 | clerkToken: "clerk-token", | 54 | clerkToken: "clerk-token", |
| 59 | connections: [connection], | 55 | connections: [connection], |
| 60 | }), | 56 | }); |
| 61 | ); | ||
| 62 | |||
| 63 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); | ||
| 64 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | ||
| 65 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | ||
| 66 | clerkToken: "clerk-token", | ||
| 67 | connection, | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | 57 | ||
| 71 | it("pushes enabled Live Activity preferences to relay registrations", async () => { | 58 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); |
| 72 | await runWithHttpClient( | 59 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); |
| 73 | setLiveActivityUpdatesEnabled({ | 60 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ |
| 61 | clerkToken: "clerk-token", | ||
| 62 | connection, | ||
| 63 | }); | ||
| 64 | }).pipe(Effect.provide(testLayer)), | ||
| 65 | ); | ||
| 66 | |||
| 67 | it.effect("pushes enabled Live Activity preferences to relay registrations", () => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | yield* setLiveActivityUpdatesEnabled({ | ||
| 74 | enabled: true, | 70 | enabled: true, |
| 75 | clerkToken: "clerk-token", | 71 | clerkToken: "clerk-token", |
| 76 | connections: [connection], | 72 | connections: [connection], |
| 77 | }), | 73 | }); |
| 78 | ); | ||
| 79 | |||
| 80 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); | ||
| 81 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | ||
| 82 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | ||
| 83 | clerkToken: "clerk-token", | ||
| 84 | connection, | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | 74 | ||
| 88 | it("keeps local preferences refreshable when signed out", async () => { | 75 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); |
| 89 | await runWithHttpClient( | 76 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); |
| 90 | setLiveActivityUpdatesEnabled({ | 77 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ |
| 78 | clerkToken: "clerk-token", | ||
| 79 | connection, | ||
| 80 | }); | ||
| 81 | }).pipe(Effect.provide(testLayer)), | ||
| 82 | ); | ||
| 83 | |||
| 84 | it.effect("keeps local preferences refreshable when signed out", () => | ||
| 85 | Effect.gen(function* () { | ||
| 86 | yield* setLiveActivityUpdatesEnabled({ | ||
| 91 | enabled: false, | 87 | enabled: false, |
| 92 | clerkToken: null, | 88 | clerkToken: null, |
| 93 | connections: [connection], | 89 | connections: [connection], |
| 94 | }), | 90 | }); |
| 95 | ); | ||
| 96 | 91 | ||
| 97 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); | 92 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); |
| 98 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | 93 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); |
| 99 | expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); | 94 | expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); |
| 100 | }); | 95 | }).pipe(Effect.provide(testLayer)), |
| 96 | ); | ||
| 101 | 97 | ||
| 102 | it("does not try to re-link managed relay connections without bearer credentials", async () => { | 98 | it.effect("does not try to re-link managed relay connections without bearer credentials", () => { |
| 103 | const managedConnection: SavedRemoteConnection = { | 99 | const managedConnection: SavedRemoteConnection = { |
| 104 | ...connection, | 100 | ...connection, |
| 105 | bearerToken: null, | 101 | bearerToken: null, |
| 106 | }; | 102 | }; |
| 107 | 103 | ||
| 108 | await runWithHttpClient( | 104 | return Effect.gen(function* () { |
| 109 | setLiveActivityUpdatesEnabled({ | 105 | yield* setLiveActivityUpdatesEnabled({ |
| 110 | enabled: true, | 106 | enabled: true, |
| 111 | clerkToken: "clerk-token", | 107 | clerkToken: "clerk-token", |
| 112 | connections: [connection, managedConnection], | 108 | connections: [connection, managedConnection], |
| 113 | }), | 109 | }); |
| 114 | ); | 110 | |
| 115 | 111 | expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); | |
| 116 | expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); | 112 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ |
| 117 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | 113 | clerkToken: "clerk-token", |
| 118 | clerkToken: "clerk-token", | 114 | connection, |
| 119 | connection, | 115 | }); |
| 120 | }); | 116 | }).pipe(Effect.provide(testLayer)); |
| 121 | }); | 117 | }); |
| 122 | }); | 118 | }); |
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import { HttpClient } from "effect/unstable/http"; | 2 | import { HttpClient } from "effect/unstable/http"; |
| 3 | import { ManagedRelayClient } from "@t3tools/client-runtime"; | 3 | import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; |
| 4 | 4 | ||
| 5 | import type { SavedRemoteConnection } from "../../lib/connection"; | 5 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 6 | import { savePreferencesPatch } from "../../lib/storage"; | 6 | import { savePreferencesPatch } from "../../lib/storage"; |
apps/mobile/src/features/agent-awareness/registrationPayload.ts+2-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; | 1 | import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; |
| 2 | 2 | ||
| 3 | import type { MobilePreferences } from "../../lib/storage"; | 3 | import type { Preferences } from "../../lib/storage"; |
| 4 | 4 | ||
| 5 | export function makeRelayDeviceRegistrationRequest(input: { | 5 | export function makeRelayDeviceRegistrationRequest(input: { |
| 6 | readonly deviceId: string; | 6 | readonly deviceId: string; |
| ... | @@ -10,7 +10,7 @@ export function makeRelayDeviceRegistrationRequest(input: { | ... | @@ -10,7 +10,7 @@ export function makeRelayDeviceRegistrationRequest(input: { |
| 10 | readonly pushToken?: string; | 10 | readonly pushToken?: string; |
| 11 | readonly pushToStartToken?: string; | 11 | readonly pushToStartToken?: string; |
| 12 | readonly notificationsEnabled: boolean; | 12 | readonly notificationsEnabled: boolean; |
| 13 | readonly preferences: MobilePreferences; | 13 | readonly preferences: Preferences; |
| 14 | }): RelayDeviceRegistrationRequest { | 14 | }): RelayDeviceRegistrationRequest { |
| 15 | const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; | 15 | const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; |
| 16 | return { | 16 | return { |
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+156-82| ... | @@ -6,15 +6,16 @@ import { beforeEach, vi } from "vite-plus/test"; | ... | @@ -6,15 +6,16 @@ import { beforeEach, vi } from "vite-plus/test"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import Constants from "expo-constants"; | 7 | import Constants from "expo-constants"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Exit from "effect/Exit"; | ||
| 9 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 10 | import { FetchHttpClient } from "effect/unstable/http"; | 11 | import { FetchHttpClient } from "effect/unstable/http"; |
| 11 | import type { ManagedRelayClient } from "@t3tools/client-runtime"; | 12 | import { type ManagedRelayClient } from "@t3tools/client-runtime/relay"; |
| 12 | 13 | ||
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; | 14 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 14 | import { verifyDpopProof } from "@t3tools/shared/dpop"; | 15 | import { verifyDpopProof } from "@t3tools/shared/dpop"; |
| 15 | import type { SavedRemoteConnection } from "../../lib/connection"; | 16 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 16 | import { mobileCryptoLayer } from "../cloud/dpop"; | 17 | import { cryptoLayer } from "../cloud/dpop"; |
| 17 | import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer"; | 18 | import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; |
| 18 | import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; | 19 | import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; |
| 19 | import { | 20 | import { |
| 20 | __resetAgentAwarenessRemoteRegistrationForTest, | 21 | __resetAgentAwarenessRemoteRegistrationForTest, |
| ... | @@ -33,6 +34,12 @@ const secureStore = vi.hoisted(() => new Map<string, string>()); | ... | @@ -33,6 +34,12 @@ const secureStore = vi.hoisted(() => new Map<string, string>()); |
| 33 | const widgetMocks = vi.hoisted(() => ({ | 34 | const widgetMocks = vi.hoisted(() => ({ |
| 34 | getInstances: vi.fn(() => []), | 35 | getInstances: vi.fn(() => []), |
| 35 | })); | 36 | })); |
| 37 | const backgroundRuntime = vi.hoisted(() => ({ | ||
| 38 | pending: [] as Array<{ | ||
| 39 | readonly operation: unknown; | ||
| 40 | readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void; | ||
| 41 | }>, | ||
| 42 | })); | ||
| 36 | 43 | ||
| 37 | vi.mock("expo-constants", () => ({ | 44 | vi.mock("expo-constants", () => ({ |
| 38 | default: { | 45 | default: { |
| ... | @@ -95,17 +102,11 @@ vi.mock("react-native", () => ({ | ... | @@ -95,17 +102,11 @@ vi.mock("react-native", () => ({ |
| 95 | })); | 102 | })); |
| 96 | 103 | ||
| 97 | vi.mock("../../lib/runtime", () => ({ | 104 | vi.mock("../../lib/runtime", () => ({ |
| 98 | mobileRuntime: { | 105 | runtime: { |
| 99 | runPromise: <A, E>(operation: Effect.Effect<A, E>) => | 106 | runPromiseExit: (operation: unknown) => |
| 100 | Effect.runPromise( | 107 | new Promise((resolve) => { |
| 101 | operation.pipe( | 108 | backgroundRuntime.pending.push({ operation, resolve }); |
| 102 | Effect.provide( | 109 | }), |
| 103 | mobileManagedRelayClientLayer("https://relay.example.test").pipe( | ||
| 104 | Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), | ||
| 105 | ), | ||
| 106 | ), | ||
| 107 | ), | ||
| 108 | ), | ||
| 109 | }, | 110 | }, |
| 110 | })); | 111 | })); |
| 111 | 112 | ||
| ... | @@ -138,34 +139,40 @@ function savedConnection(): SavedRemoteConnection { | ... | @@ -138,34 +139,40 @@ function savedConnection(): SavedRemoteConnection { |
| 138 | }; | 139 | }; |
| 139 | } | 140 | } |
| 140 | 141 | ||
| 141 | const runRegistrationEffect = <A, E>(effect: Effect.Effect<A, E, ManagedRelayClient>): Promise<A> => | 142 | const relayTestLayer = managedRelayClientLayer("https://relay.example.test").pipe( |
| 142 | Effect.runPromise( | 143 | Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), |
| 143 | effect.pipe( | 144 | ); |
| 144 | Effect.provide( | 145 | |
| 145 | mobileManagedRelayClientLayer("https://relay.example.test").pipe( | 146 | const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundOperations")( |
| 146 | Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), | 147 | function* () { |
| 147 | ), | 148 | let idlePasses = 0; |
| 148 | ), | 149 | for (;;) { |
| 149 | ), | 150 | yield* Effect.promise(() => Promise.resolve()); |
| 150 | ); | 151 | const pending = backgroundRuntime.pending.shift(); |
| 151 | 152 | if (!pending) { | |
| 152 | async function waitForFetchCalls( | 153 | idlePasses++; |
| 153 | fetchMock: ReturnType<typeof vi.fn>, | 154 | if (idlePasses >= 3) { |
| 154 | count: number, | 155 | return; |
| 155 | ): Promise<void> { | 156 | } |
| 156 | for (let attempt = 0; attempt < 20; attempt += 1) { | 157 | continue; |
| 157 | if (fetchMock.mock.calls.length >= count) { | 158 | } |
| 158 | return; | 159 | idlePasses = 0; |
| 160 | const exit = yield* Effect.exit( | ||
| 161 | pending.operation as Effect.Effect<unknown, unknown, ManagedRelayClient>, | ||
| 162 | ); | ||
| 163 | yield* Effect.sync(() => { | ||
| 164 | pending.resolve(exit); | ||
| 165 | }); | ||
| 159 | } | 166 | } |
| 160 | await new Promise((resolve) => setTimeout(resolve, 0)); | 167 | }, |
| 161 | } | 168 | ); |
| 162 | } | ||
| 163 | 169 | ||
| 164 | describe("makeRelayDeviceRegistrationRequest", () => { | 170 | describe("makeRelayDeviceRegistrationRequest", () => { |
| 165 | beforeEach(() => { | 171 | beforeEach(() => { |
| 166 | vi.unstubAllGlobals(); | 172 | vi.unstubAllGlobals(); |
| 167 | vi.stubGlobal("__DEV__", false); | 173 | vi.stubGlobal("__DEV__", false); |
| 168 | secureStore.clear(); | 174 | secureStore.clear(); |
| 175 | backgroundRuntime.pending.length = 0; | ||
| 169 | Constants.expoConfig!.extra = {}; | 176 | Constants.expoConfig!.extra = {}; |
| 170 | __resetAgentAwarenessRemoteRegistrationForTest(); | 177 | __resetAgentAwarenessRemoteRegistrationForTest(); |
| 171 | widgetMocks.getInstances.mockReset(); | 178 | widgetMocks.getInstances.mockReset(); |
| ... | @@ -243,7 +250,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -243,7 +250,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 243 | expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); | 250 | expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); |
| 244 | }); | 251 | }); |
| 245 | 252 | ||
| 246 | it("registers at most one listener while a Live Activity push token is pending", async () => { | 253 | it.effect("registers at most one listener while a Live Activity push token is pending", () => { |
| 247 | registerAgentAwarenessConnection(savedConnection()); | 254 | registerAgentAwarenessConnection(savedConnection()); |
| 248 | const addPushTokenListener = vi.fn(); | 255 | const addPushTokenListener = vi.fn(); |
| 249 | const activity = { | 256 | const activity = { |
| ... | @@ -251,56 +258,64 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -251,56 +258,64 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 251 | addPushTokenListener, | 258 | addPushTokenListener, |
| 252 | }; | 259 | }; |
| 253 | 260 | ||
| 254 | await expect( | 261 | return Effect.gen(function* () { |
| 255 | runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), | 262 | expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); |
| 256 | ).resolves.toBe(false); | 263 | expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); |
| 257 | await expect( | ||
| 258 | runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), | ||
| 259 | ).resolves.toBe(false); | ||
| 260 | 264 | ||
| 261 | expect(activity.getPushToken).toHaveBeenCalledTimes(2); | 265 | expect(activity.getPushToken).toHaveBeenCalledTimes(2); |
| 262 | expect(addPushTokenListener).toHaveBeenCalledTimes(1); | 266 | expect(addPushTokenListener).toHaveBeenCalledTimes(1); |
| 267 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 263 | }); | 268 | }); |
| 264 | 269 | ||
| 265 | it("reports Live Activity token registration as skipped when relay auth is unavailable", async () => { | 270 | it.effect( |
| 266 | registerAgentAwarenessConnection(savedConnection()); | 271 | "reports Live Activity token registration as skipped when relay auth is unavailable", |
| 267 | const activity = { | 272 | () => { |
| 268 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), | 273 | registerAgentAwarenessConnection(savedConnection()); |
| 269 | addPushTokenListener: vi.fn(), | 274 | const activity = { |
| 270 | }; | 275 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), |
| 276 | addPushTokenListener: vi.fn(), | ||
| 277 | }; | ||
| 271 | 278 | ||
| 272 | await expect( | 279 | return Effect.gen(function* () { |
| 273 | runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), | 280 | expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); |
| 274 | ).resolves.toBe(false); | 281 | }).pipe(Effect.provide(relayTestLayer)); |
| 275 | }); | 282 | }, |
| 283 | ); | ||
| 276 | 284 | ||
| 277 | it("registers APNS-started Live Activities for relay updates without mutating them locally", async () => { | 285 | it.effect( |
| 278 | const activity = { | 286 | "registers APNS-started Live Activities for relay updates without mutating them locally", |
| 279 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), | 287 | () => { |
| 280 | addPushTokenListener: vi.fn(), | 288 | const activity = { |
| 281 | start: vi.fn(), | 289 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), |
| 282 | update: vi.fn(), | 290 | addPushTokenListener: vi.fn(), |
| 283 | end: vi.fn(), | 291 | start: vi.fn(), |
| 284 | }; | 292 | update: vi.fn(), |
| 285 | widgetMocks.getInstances.mockReturnValue([activity] as never); | 293 | end: vi.fn(), |
| 286 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | 294 | }; |
| 295 | widgetMocks.getInstances.mockReturnValue([activity] as never); | ||
| 296 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 287 | 297 | ||
| 288 | await runRegistrationEffect(refreshActiveLiveActivityRemoteRegistration()); | 298 | return Effect.gen(function* () { |
| 299 | yield* refreshActiveLiveActivityRemoteRegistration(); | ||
| 289 | 300 | ||
| 290 | expect(activity.getPushToken).toHaveBeenCalled(); | 301 | expect(activity.getPushToken).toHaveBeenCalled(); |
| 291 | expect(activity.start).not.toHaveBeenCalled(); | 302 | expect(activity.start).not.toHaveBeenCalled(); |
| 292 | expect(activity.update).not.toHaveBeenCalled(); | 303 | expect(activity.update).not.toHaveBeenCalled(); |
| 293 | expect(activity.end).not.toHaveBeenCalled(); | 304 | expect(activity.end).not.toHaveBeenCalled(); |
| 294 | }); | 305 | }).pipe(Effect.provide(relayTestLayer)); |
| 306 | }, | ||
| 307 | ); | ||
| 295 | 308 | ||
| 296 | it("refreshes APNs registration for connected environments after settings changes", async () => { | 309 | it.effect("refreshes APNs registration for connected environments after settings changes", () => { |
| 297 | registerAgentAwarenessConnection(savedConnection()); | 310 | registerAgentAwarenessConnection(savedConnection()); |
| 298 | await new Promise((resolve) => setTimeout(resolve, 0)); | 311 | return Effect.gen(function* () { |
| 299 | vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); | 312 | yield* runBackgroundOperations(); |
| 313 | vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); | ||
| 300 | 314 | ||
| 301 | await runRegistrationEffect(refreshAgentAwarenessRegistration()); | 315 | yield* refreshAgentAwarenessRegistration(); |
| 302 | 316 | ||
| 303 | expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); | 317 | expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); |
| 318 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 304 | }); | 319 | }); |
| 305 | 320 | ||
| 306 | it.effect("registers the APNs device when cloud auth becomes available", () => { | 321 | it.effect("registers the APNs device when cloud auth becomes available", () => { |
| ... | @@ -330,7 +345,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -330,7 +345,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 330 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | 345 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); |
| 331 | 346 | ||
| 332 | return Effect.gen(function* () { | 347 | return Effect.gen(function* () { |
| 333 | yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); | 348 | yield* runBackgroundOperations(); |
| 334 | 349 | ||
| 335 | expect(fetchMock).toHaveBeenCalledTimes(2); | 350 | expect(fetchMock).toHaveBeenCalledTimes(2); |
| 336 | const [request, init] = fetchMock.mock.calls[1] as unknown as [ | 351 | const [request, init] = fetchMock.mock.calls[1] as unknown as [ |
| ... | @@ -357,7 +372,65 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -357,7 +372,65 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 357 | nowEpochSeconds: proofIat(dpop), | 372 | nowEpochSeconds: proofIat(dpop), |
| 358 | }), | 373 | }), |
| 359 | ).toMatchObject({ ok: true }); | 374 | ).toMatchObject({ ok: true }); |
| 375 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 376 | }); | ||
| 377 | |||
| 378 | it.effect("coalesces simultaneous sign-in and environment connection registrations", () => { | ||
| 379 | const fetchMock = vi.fn((request: RequestInfo | URL) => { | ||
| 380 | const url = request instanceof Request ? request.url : String(request); | ||
| 381 | return Promise.resolve( | ||
| 382 | Response.json( | ||
| 383 | url.endsWith("/v1/client/dpop-token") | ||
| 384 | ? { | ||
| 385 | access_token: "relay-dpop-token", | ||
| 386 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 387 | token_type: "DPoP", | ||
| 388 | expires_in: 300, | ||
| 389 | scope: "mobile:registration", | ||
| 390 | } | ||
| 391 | : { ok: true }, | ||
| 392 | ), | ||
| 393 | ); | ||
| 360 | }); | 394 | }); |
| 395 | vi.stubGlobal("fetch", fetchMock); | ||
| 396 | Constants.expoConfig!.extra = { | ||
| 397 | relay: { | ||
| 398 | url: "https://relay.example.test/", | ||
| 399 | }, | ||
| 400 | }; | ||
| 401 | |||
| 402 | vi.mocked(Notifications.getPermissionsAsync).mockClear(); | ||
| 403 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 404 | registerAgentAwarenessConnection(savedConnection()); | ||
| 405 | |||
| 406 | return Effect.gen(function* () { | ||
| 407 | yield* runBackgroundOperations(); | ||
| 408 | expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1); | ||
| 409 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 410 | }); | ||
| 411 | |||
| 412 | it.effect("continues queued device registration after a failed auth lookup", () => { | ||
| 413 | Constants.expoConfig!.extra = { | ||
| 414 | relay: { | ||
| 415 | url: "https://relay.example.test/", | ||
| 416 | }, | ||
| 417 | }; | ||
| 418 | |||
| 419 | const tokenProvider = vi | ||
| 420 | .fn<() => Promise<string | null>>() | ||
| 421 | .mockRejectedValueOnce(new Error("auth unavailable")) | ||
| 422 | .mockResolvedValue("clerk-token-user-a"); | ||
| 423 | setAgentAwarenessRelayTokenProvider(tokenProvider); | ||
| 424 | const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; | ||
| 425 | expect(tokenListener).toBeDefined(); | ||
| 426 | tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never); | ||
| 427 | |||
| 428 | return Effect.gen(function* () { | ||
| 429 | yield* runBackgroundOperations(); | ||
| 430 | |||
| 431 | expect(backgroundRuntime.pending).toHaveLength(0); | ||
| 432 | expect(tokenProvider).toHaveBeenCalledTimes(2); | ||
| 433 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 361 | }); | 434 | }); |
| 362 | 435 | ||
| 363 | it("only registers again when the authenticated identity changes", () => { | 436 | it("only registers again when the authenticated identity changes", () => { |
| ... | @@ -367,7 +440,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -367,7 +440,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 367 | expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true); | 440 | expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true); |
| 368 | }); | 441 | }); |
| 369 | 442 | ||
| 370 | it("registers rotated APNs tokens without rereading the native token", async () => { | 443 | it.effect("registers rotated APNs tokens without rereading the native token", () => { |
| 371 | const fetchMock = vi.fn((request: RequestInfo | URL) => { | 444 | const fetchMock = vi.fn((request: RequestInfo | URL) => { |
| 372 | const url = request instanceof Request ? request.url : String(request); | 445 | const url = request instanceof Request ? request.url : String(request); |
| 373 | return Promise.resolve( | 446 | return Promise.resolve( |
| ... | @@ -398,9 +471,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -398,9 +471,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 398 | expect(tokenListener).toBeDefined(); | 471 | expect(tokenListener).toBeDefined(); |
| 399 | tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never); | 472 | tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never); |
| 400 | 473 | ||
| 401 | await new Promise((resolve) => setTimeout(resolve, 0)); | 474 | return Effect.gen(function* () { |
| 402 | 475 | yield* runBackgroundOperations(); | |
| 403 | expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); | 476 | expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); |
| 477 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 404 | }); | 478 | }); |
| 405 | 479 | ||
| 406 | it.effect( | 480 | it.effect( |
| ... | @@ -432,13 +506,13 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -432,13 +506,13 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 432 | registerAgentAwarenessConnection(savedConnection()); | 506 | registerAgentAwarenessConnection(savedConnection()); |
| 433 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | 507 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); |
| 434 | return Effect.gen(function* () { | 508 | return Effect.gen(function* () { |
| 435 | yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); | 509 | yield* runBackgroundOperations(); |
| 436 | fetchMock.mockClear(); | 510 | fetchMock.mockClear(); |
| 437 | 511 | ||
| 438 | unregisterAgentAwarenessConnection(savedConnection().environmentId); | 512 | unregisterAgentAwarenessConnection(savedConnection().environmentId); |
| 439 | 513 | ||
| 440 | expect(fetchMock).not.toHaveBeenCalled(); | 514 | expect(fetchMock).not.toHaveBeenCalled(); |
| 441 | }); | 515 | }).pipe(Effect.provide(relayTestLayer)); |
| 442 | }, | 516 | }, |
| 443 | ); | 517 | ); |
| 444 | }); | 518 | }); |
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+159-20| ... | @@ -8,10 +8,16 @@ import { | ... | @@ -8,10 +8,16 @@ import { |
| 8 | type RelayDeviceRegistrationRequest, | 8 | type RelayDeviceRegistrationRequest, |
| 9 | type RelayLiveActivityRegistrationRequest, | 9 | type RelayLiveActivityRegistrationRequest, |
| 10 | } from "@t3tools/contracts/relay"; | 10 | } from "@t3tools/contracts/relay"; |
| 11 | import { ManagedRelayClient } from "@t3tools/client-runtime"; | 11 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; |
| 12 | import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; | ||
| 13 | import { | ||
| 14 | isAtomCommandInterrupted, | ||
| 15 | settleAsyncResult, | ||
| 16 | squashAtomCommandFailure, | ||
| 17 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 12 | 18 | ||
| 13 | import type { SavedRemoteConnection } from "../../lib/connection"; | 19 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 14 | import { mobileRuntime } from "../../lib/runtime"; | 20 | import { runtime } from "../../lib/runtime"; |
| 15 | import { | 21 | import { |
| 16 | loadAgentAwarenessDeviceId, | 22 | loadAgentAwarenessDeviceId, |
| 17 | loadOrCreateAgentAwarenessDeviceId, | 23 | loadOrCreateAgentAwarenessDeviceId, |
| ... | @@ -29,6 +35,20 @@ let pushTokenSubscription: { remove: () => void } | null = null; | ... | @@ -29,6 +35,20 @@ let pushTokenSubscription: { remove: () => void } | null = null; |
| 29 | let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null; | 35 | let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null; |
| 30 | let relayTokenProvider: (() => Promise<string | null>) | null = null; | 36 | let relayTokenProvider: (() => Promise<string | null>) | null = null; |
| 31 | let relayTokenProviderIdentity: string | null = null; | 37 | let relayTokenProviderIdentity: string | null = null; |
| 38 | let deviceRegistrationGeneration = 0; | ||
| 39 | let activeDeviceRegistration: { | ||
| 40 | readonly input: DeviceRegistrationInput; | ||
| 41 | operation: Promise<void>; | ||
| 42 | } | null = null; | ||
| 43 | let pendingDeviceRegistration: { | ||
| 44 | readonly input: DeviceRegistrationInput; | ||
| 45 | readonly context: string; | ||
| 46 | } | null = null; | ||
| 47 | |||
| 48 | interface DeviceRegistrationInput { | ||
| 49 | readonly pushToStartToken?: string; | ||
| 50 | readonly observedPushToken?: string; | ||
| 51 | } | ||
| 32 | 52 | ||
| 33 | export function normalizeAgentAwarenessRelayBaseUrl( | 53 | export function normalizeAgentAwarenessRelayBaseUrl( |
| 34 | value: string | null | undefined, | 54 | value: string | null | undefined, |
| ... | @@ -68,6 +88,11 @@ export function setAgentAwarenessRelayTokenProvider( | ... | @@ -68,6 +88,11 @@ export function setAgentAwarenessRelayTokenProvider( |
| 68 | const isExistingIdentity = | 88 | const isExistingIdentity = |
| 69 | provider !== null && | 89 | provider !== null && |
| 70 | !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity); | 90 | !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity); |
| 91 | if (!isExistingIdentity) { | ||
| 92 | deviceRegistrationGeneration++; | ||
| 93 | activeDeviceRegistration = null; | ||
| 94 | pendingDeviceRegistration = null; | ||
| 95 | } | ||
| 71 | relayTokenProvider = provider; | 96 | relayTokenProvider = provider; |
| 72 | relayTokenProviderIdentity = provider ? (identity ?? null) : null; | 97 | relayTokenProviderIdentity = provider ? (identity ?? null) : null; |
| 73 | if (!provider) { | 98 | if (!provider) { |
| ... | @@ -90,7 +115,7 @@ export function setAgentAwarenessRelayTokenProvider( | ... | @@ -90,7 +115,7 @@ export function setAgentAwarenessRelayTokenProvider( |
| 90 | if (isExistingIdentity) { | 115 | if (isExistingIdentity) { |
| 91 | return; | 116 | return; |
| 92 | } | 117 | } |
| 93 | runRegistrationInBackground(registerDevice(), "device registration after cloud sign-in failed"); | 118 | enqueueDeviceRegistration({}, "device registration after cloud sign-in failed"); |
| 94 | } | 119 | } |
| 95 | 120 | ||
| 96 | function iosMajorVersion(): number { | 121 | function iosMajorVersion(): number { |
| ... | @@ -149,20 +174,41 @@ const relayToken = Effect.gen(function* () { | ... | @@ -149,20 +174,41 @@ const relayToken = Effect.gen(function* () { |
| 149 | 174 | ||
| 150 | function registerDeviceWithRelay( | 175 | function registerDeviceWithRelay( |
| 151 | body: RelayDeviceRegistrationRequest, | 176 | body: RelayDeviceRegistrationRequest, |
| 177 | expectedGeneration: number, | ||
| 152 | ): Effect.Effect<void, unknown, ManagedRelayClient> { | 178 | ): Effect.Effect<void, unknown, ManagedRelayClient> { |
| 153 | return Effect.gen(function* () { | 179 | return Effect.gen(function* () { |
| 180 | if (expectedGeneration !== deviceRegistrationGeneration) { | ||
| 181 | logRegistrationDebug("device registration cancelled before relay request", { | ||
| 182 | expectedGeneration, | ||
| 183 | currentGeneration: deviceRegistrationGeneration, | ||
| 184 | }); | ||
| 185 | return; | ||
| 186 | } | ||
| 154 | if (!readRelayConfig()) return; | 187 | if (!readRelayConfig()) return; |
| 155 | const token = yield* relayToken; | 188 | const token = yield* relayToken; |
| 189 | if (expectedGeneration !== deviceRegistrationGeneration) { | ||
| 190 | logRegistrationDebug("device registration cancelled after auth lookup", { | ||
| 191 | expectedGeneration, | ||
| 192 | currentGeneration: deviceRegistrationGeneration, | ||
| 193 | }); | ||
| 194 | return; | ||
| 195 | } | ||
| 156 | if (!token) { | 196 | if (!token) { |
| 157 | logRegistrationDebug("relay device registration skipped; user is not signed in"); | 197 | logRegistrationDebug("relay device registration skipped; user is not signed in"); |
| 158 | return; | 198 | return; |
| 159 | } | 199 | } |
| 160 | 200 | ||
| 161 | const client = yield* ManagedRelayClient; | 201 | const client = yield* ManagedRelayClient; |
| 202 | logRegistrationDebug("relay device registration request started", { | ||
| 203 | expectedGeneration, | ||
| 204 | }); | ||
| 162 | yield* client.registerDevice({ | 205 | yield* client.registerDevice({ |
| 163 | clerkToken: token, | 206 | clerkToken: token, |
| 164 | payload: body, | 207 | payload: body, |
| 165 | }); | 208 | }); |
| 209 | logRegistrationDebug("relay device registration request completed", { | ||
| 210 | expectedGeneration, | ||
| 211 | }); | ||
| 166 | }); | 212 | }); |
| 167 | } | 213 | } |
| 168 | 214 | ||
| ... | @@ -213,10 +259,11 @@ function logRegistrationError(context: string, error: unknown): void { | ... | @@ -213,10 +259,11 @@ function logRegistrationError(context: string, error: unknown): void { |
| 213 | if (!__DEV__) { | 259 | if (!__DEV__) { |
| 214 | return; | 260 | return; |
| 215 | } | 261 | } |
| 216 | console.warn( | 262 | console.warn(`[agent-awareness] ${context}`, { |
| 217 | `[agent-awareness] ${context}`, | 263 | message: error instanceof Error ? error.message : String(error), |
| 218 | error instanceof Error ? error.message : String(error), | 264 | traceId: findErrorTraceId(error), |
| 219 | ); | 265 | error, |
| 266 | }); | ||
| 220 | } | 267 | } |
| 221 | 268 | ||
| 222 | function logRegistrationDebug(context: string, details?: unknown): void { | 269 | function logRegistrationDebug(context: string, details?: unknown): void { |
| ... | @@ -230,20 +277,107 @@ function runRegistrationInBackground( | ... | @@ -230,20 +277,107 @@ function runRegistrationInBackground( |
| 230 | operation: Effect.Effect<unknown, unknown, ManagedRelayClient>, | 277 | operation: Effect.Effect<unknown, unknown, ManagedRelayClient>, |
| 231 | context: string, | 278 | context: string, |
| 232 | ): void { | 279 | ): void { |
| 233 | void mobileRuntime.runPromise(operation).catch((error: unknown) => { | 280 | void (async () => { |
| 234 | logRegistrationError(context, error); | 281 | const result = await settleAsyncResult(() => runtime.runPromiseExit(operation)); |
| 282 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 283 | logRegistrationError(context, squashAtomCommandFailure(result)); | ||
| 284 | } | ||
| 285 | })(); | ||
| 286 | } | ||
| 287 | |||
| 288 | function mergeDeviceRegistrationInput( | ||
| 289 | current: DeviceRegistrationInput, | ||
| 290 | next: DeviceRegistrationInput, | ||
| 291 | ): DeviceRegistrationInput { | ||
| 292 | return { | ||
| 293 | ...((next.pushToStartToken ?? current.pushToStartToken) | ||
| 294 | ? { pushToStartToken: next.pushToStartToken ?? current.pushToStartToken } | ||
| 295 | : {}), | ||
| 296 | ...((next.observedPushToken ?? current.observedPushToken) | ||
| 297 | ? { observedPushToken: next.observedPushToken ?? current.observedPushToken } | ||
| 298 | : {}), | ||
| 299 | }; | ||
| 300 | } | ||
| 301 | |||
| 302 | function registrationAddsInformation( | ||
| 303 | current: DeviceRegistrationInput, | ||
| 304 | next: DeviceRegistrationInput, | ||
| 305 | ): boolean { | ||
| 306 | return ( | ||
| 307 | (next.pushToStartToken !== undefined && next.pushToStartToken !== current.pushToStartToken) || | ||
| 308 | (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken) | ||
| 309 | ); | ||
| 310 | } | ||
| 311 | |||
| 312 | function startPendingDeviceRegistration(): void { | ||
| 313 | if (activeDeviceRegistration || !pendingDeviceRegistration) { | ||
| 314 | return; | ||
| 315 | } | ||
| 316 | |||
| 317 | const next = pendingDeviceRegistration; | ||
| 318 | pendingDeviceRegistration = null; | ||
| 319 | const generation = deviceRegistrationGeneration; | ||
| 320 | logRegistrationDebug("device registration started", { | ||
| 321 | generation, | ||
| 322 | hasObservedPushToken: next.input.observedPushToken !== undefined, | ||
| 323 | hasPushToStartToken: next.input.pushToStartToken !== undefined, | ||
| 235 | }); | 324 | }); |
| 325 | const registration = { | ||
| 326 | input: next.input, | ||
| 327 | operation: Promise.resolve(), | ||
| 328 | }; | ||
| 329 | activeDeviceRegistration = registration; | ||
| 330 | registration.operation = (async () => { | ||
| 331 | const result = await settleAsyncResult(() => | ||
| 332 | runtime.runPromiseExit(registerDevice(next.input, generation)), | ||
| 333 | ); | ||
| 334 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 335 | logRegistrationError(next.context, squashAtomCommandFailure(result)); | ||
| 336 | } | ||
| 337 | logRegistrationDebug("device registration finished", { generation }); | ||
| 338 | if (activeDeviceRegistration === registration) { | ||
| 339 | activeDeviceRegistration = null; | ||
| 340 | } | ||
| 341 | startPendingDeviceRegistration(); | ||
| 342 | })(); | ||
| 236 | } | 343 | } |
| 237 | 344 | ||
| 238 | function registerDevice(input?: { | 345 | function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: string): void { |
| 239 | readonly pushToStartToken?: string; | 346 | if ( |
| 240 | readonly observedPushToken?: string; | 347 | activeDeviceRegistration && |
| 241 | }): Effect.Effect<void, unknown, ManagedRelayClient> { | 348 | !registrationAddsInformation(activeDeviceRegistration.input, input) |
| 349 | ) { | ||
| 350 | logRegistrationDebug("device registration coalesced with active request", { | ||
| 351 | generation: deviceRegistrationGeneration, | ||
| 352 | }); | ||
| 353 | return; | ||
| 354 | } | ||
| 355 | |||
| 356 | logRegistrationDebug("device registration enqueued", { | ||
| 357 | generation: deviceRegistrationGeneration, | ||
| 358 | hasActiveRegistration: activeDeviceRegistration !== null, | ||
| 359 | hasPendingRegistration: pendingDeviceRegistration !== null, | ||
| 360 | }); | ||
| 361 | pendingDeviceRegistration = pendingDeviceRegistration | ||
| 362 | ? { | ||
| 363 | input: mergeDeviceRegistrationInput(pendingDeviceRegistration.input, input), | ||
| 364 | context, | ||
| 365 | } | ||
| 366 | : { input, context }; | ||
| 367 | startPendingDeviceRegistration(); | ||
| 368 | } | ||
| 369 | |||
| 370 | function registerDevice( | ||
| 371 | input: DeviceRegistrationInput = {}, | ||
| 372 | expectedGeneration = deviceRegistrationGeneration, | ||
| 373 | ): Effect.Effect<void, unknown, ManagedRelayClient> { | ||
| 242 | return Effect.gen(function* () { | 374 | return Effect.gen(function* () { |
| 243 | if (!canRegisterRemoteLiveActivities()) { | 375 | if (!canRegisterRemoteLiveActivities()) { |
| 376 | logRegistrationDebug("device registration skipped; platform does not support it"); | ||
| 244 | return; | 377 | return; |
| 245 | } | 378 | } |
| 246 | 379 | ||
| 380 | logRegistrationDebug("device registration loading local state", { expectedGeneration }); | ||
| 247 | const [deviceId, preferences] = yield* Effect.all([ | 381 | const [deviceId, preferences] = yield* Effect.all([ |
| 248 | Effect.tryPromise({ | 382 | Effect.tryPromise({ |
| 249 | try: () => loadOrCreateAgentAwarenessDeviceId(), | 383 | try: () => loadOrCreateAgentAwarenessDeviceId(), |
| ... | @@ -255,6 +389,10 @@ function registerDevice(input?: { | ... | @@ -255,6 +389,10 @@ function registerDevice(input?: { |
| 255 | }), | 389 | }), |
| 256 | ]); | 390 | ]); |
| 257 | const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); | 391 | const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); |
| 392 | logRegistrationDebug("device registration local state ready", { | ||
| 393 | expectedGeneration, | ||
| 394 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, | ||
| 395 | }); | ||
| 258 | yield* registerDeviceWithRelay( | 396 | yield* registerDeviceWithRelay( |
| 259 | makeRelayDeviceRegistrationRequest({ | 397 | makeRelayDeviceRegistrationRequest({ |
| 260 | deviceId, | 398 | deviceId, |
| ... | @@ -266,6 +404,7 @@ function registerDevice(input?: { | ... | @@ -266,6 +404,7 @@ function registerDevice(input?: { |
| 266 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, | 404 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, |
| 267 | preferences, | 405 | preferences, |
| 268 | }), | 406 | }), |
| 407 | expectedGeneration, | ||
| 269 | ); | 408 | ); |
| 270 | }); | 409 | }); |
| 271 | } | 410 | } |
| ... | @@ -277,10 +416,7 @@ function registerDeviceForCurrentUser( | ... | @@ -277,10 +416,7 @@ function registerDeviceForCurrentUser( |
| 277 | } | 416 | } |
| 278 | 417 | ||
| 279 | function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void { | 418 | function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void { |
| 280 | runRegistrationInBackground( | 419 | enqueueDeviceRegistration({ pushToStartToken }, "push-to-start token registration failed"); |
| 281 | registerDeviceForCurrentUser(pushToStartToken), | ||
| 282 | "push-to-start token registration failed", | ||
| 283 | ); | ||
| 284 | } | 420 | } |
| 285 | 421 | ||
| 286 | function ensurePushToStartListener(): void { | 422 | function ensurePushToStartListener(): void { |
| ... | @@ -303,8 +439,8 @@ function ensurePushTokenListener(): void { | ... | @@ -303,8 +439,8 @@ function ensurePushTokenListener(): void { |
| 303 | 439 | ||
| 304 | pushTokenSubscription = Notifications.addPushTokenListener((token) => { | 440 | pushTokenSubscription = Notifications.addPushTokenListener((token) => { |
| 305 | if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) { | 441 | if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) { |
| 306 | runRegistrationInBackground( | 442 | enqueueDeviceRegistration( |
| 307 | registerDevice({ observedPushToken: token.data.trim() }), | 443 | { observedPushToken: token.data.trim() }, |
| 308 | "native APNs token rotation registration failed", | 444 | "native APNs token rotation registration failed", |
| 309 | ); | 445 | ); |
| 310 | } | 446 | } |
| ... | @@ -319,7 +455,7 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti | ... | @@ -319,7 +455,7 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti |
| 319 | environmentConnections.set(connection.environmentId, connection); | 455 | environmentConnections.set(connection.environmentId, connection); |
| 320 | ensurePushToStartListener(); | 456 | ensurePushToStartListener(); |
| 321 | ensurePushTokenListener(); | 457 | ensurePushTokenListener(); |
| 322 | runRegistrationInBackground(registerDevice(), "device registration failed"); | 458 | enqueueDeviceRegistration({}, "device registration failed"); |
| 323 | runRegistrationInBackground( | 459 | runRegistrationInBackground( |
| 324 | refreshActiveLiveActivityRemoteRegistration(), | 460 | refreshActiveLiveActivityRemoteRegistration(), |
| 325 | "active live activity registration after environment connection failed", | 461 | "active live activity registration after environment connection failed", |
| ... | @@ -372,6 +508,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { | ... | @@ -372,6 +508,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { |
| 372 | } | 508 | } |
| 373 | relayTokenProvider = null; | 509 | relayTokenProvider = null; |
| 374 | relayTokenProviderIdentity = null; | 510 | relayTokenProviderIdentity = null; |
| 511 | deviceRegistrationGeneration++; | ||
| 512 | activeDeviceRegistration = null; | ||
| 513 | pendingDeviceRegistration = null; | ||
| 375 | } | 514 | } |
| 376 | 515 | ||
| 377 | export function unregisterAgentAwarenessDeviceForCurrentUser( | 516 | export function unregisterAgentAwarenessDeviceForCurrentUser( |
apps/mobile/src/features/cloud/CloudAuthProvider.test.ts created+60| ... | @@ -0,0 +1,60 @@ | ||
| 1 | import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; | ||
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 5 | import { activateCloudRelayAccount, deactivateCloudRelayAccount } from "./CloudAuthProvider"; | ||
| 6 | import { setAgentAwarenessRelayTokenProvider } from "../agent-awareness/remoteRegistration"; | ||
| 7 | |||
| 8 | vi.mock("@clerk/expo", () => ({ | ||
| 9 | ClerkProvider: vi.fn(), | ||
| 10 | useAuth: vi.fn(), | ||
| 11 | })); | ||
| 12 | |||
| 13 | vi.mock("@clerk/expo/token-cache", () => ({ | ||
| 14 | tokenCache: {}, | ||
| 15 | })); | ||
| 16 | |||
| 17 | vi.mock("../../lib/runtime", () => ({ | ||
| 18 | runtime: { | ||
| 19 | runPromiseExit: vi.fn(), | ||
| 20 | }, | ||
| 21 | })); | ||
| 22 | |||
| 23 | vi.mock("../../connection/catalog", () => ({ | ||
| 24 | environmentCatalog: { | ||
| 25 | removeRelayEnvironments: {}, | ||
| 26 | }, | ||
| 27 | })); | ||
| 28 | |||
| 29 | vi.mock("./publicConfig", () => ({ | ||
| 30 | resolveCloudPublicConfig: vi.fn(() => ({ | ||
| 31 | clerk: { publishableKey: null }, | ||
| 32 | relay: { url: null }, | ||
| 33 | })), | ||
| 34 | resolveRelayClerkTokenOptions: vi.fn(), | ||
| 35 | })); | ||
| 36 | |||
| 37 | vi.mock("../agent-awareness/remoteRegistration", () => ({ | ||
| 38 | setAgentAwarenessRelayTokenProvider: vi.fn(), | ||
| 39 | unregisterAgentAwarenessDeviceForCurrentUser: vi.fn(), | ||
| 40 | })); | ||
| 41 | |||
| 42 | afterEach(() => { | ||
| 43 | deactivateCloudRelayAccount(); | ||
| 44 | vi.clearAllMocks(); | ||
| 45 | }); | ||
| 46 | |||
| 47 | describe("CloudAuthProvider relay account isolation", () => { | ||
| 48 | it("clears relay and agent-awareness credentials before cleanup can fail", async () => { | ||
| 49 | const tokenProvider = async () => "account-1-token"; | ||
| 50 | activateCloudRelayAccount("account-1", tokenProvider); | ||
| 51 | expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1"); | ||
| 52 | |||
| 53 | deactivateCloudRelayAccount(); | ||
| 54 | const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined); | ||
| 55 | |||
| 56 | expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull(); | ||
| 57 | expect(vi.mocked(setAgentAwarenessRelayTokenProvider)).toHaveBeenLastCalledWith(null); | ||
| 58 | await cleanup; | ||
| 59 | }); | ||
| 60 | }); | ||
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+112-27| ... | @@ -1,63 +1,149 @@ | ... | @@ -1,63 +1,149 @@ |
| 1 | import { ClerkProvider, useAuth } from "@clerk/expo"; | 1 | import { ClerkProvider, useAuth } from "@clerk/expo"; |
| 2 | import { tokenCache } from "@clerk/expo/token-cache"; | 2 | import { tokenCache } from "@clerk/expo/token-cache"; |
| 3 | import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; | 3 | import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay"; |
| 4 | import { | ||
| 5 | reportAtomCommandResult, | ||
| 6 | settleAsyncResult, | ||
| 7 | settlePromise, | ||
| 8 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 4 | import { type ReactNode, useEffect, useRef } from "react"; | 10 | import { type ReactNode, useEffect, useRef } from "react"; |
| 5 | 11 | ||
| 6 | import { mobileRuntime } from "../../lib/runtime"; | 12 | import { environmentCatalog } from "../../connection/catalog"; |
| 13 | import { runtime } from "../../lib/runtime"; | ||
| 7 | import { appAtomRegistry } from "../../state/atom-registry"; | 14 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 8 | import { | 16 | import { |
| 9 | setAgentAwarenessRelayTokenProvider, | 17 | setAgentAwarenessRelayTokenProvider, |
| 10 | unregisterAgentAwarenessDeviceForCurrentUser, | 18 | unregisterAgentAwarenessDeviceForCurrentUser, |
| 11 | } from "../agent-awareness/remoteRegistration"; | 19 | } from "../agent-awareness/remoteRegistration"; |
| 12 | import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; | 20 | import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; |
| 13 | 21 | ||
| 22 | function resetManagedRelayTokenCache() { | ||
| 23 | return settleAsyncResult(() => | ||
| 24 | runtime.runPromiseExit( | ||
| 25 | ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), | ||
| 26 | ), | ||
| 27 | ); | ||
| 28 | } | ||
| 29 | |||
| 30 | export function deactivateCloudRelayAccount(): void { | ||
| 31 | setAgentAwarenessRelayTokenProvider(null); | ||
| 32 | setManagedRelaySession(appAtomRegistry, null); | ||
| 33 | } | ||
| 34 | |||
| 35 | export function activateCloudRelayAccount( | ||
| 36 | accountId: string, | ||
| 37 | tokenProvider: () => Promise<string | null>, | ||
| 38 | ): void { | ||
| 39 | setAgentAwarenessRelayTokenProvider(tokenProvider, accountId); | ||
| 40 | setManagedRelaySession(appAtomRegistry, { | ||
| 41 | accountId, | ||
| 42 | readClerkToken: tokenProvider, | ||
| 43 | }); | ||
| 44 | } | ||
| 45 | |||
| 14 | function CloudAuthBridge(props: { readonly children: ReactNode }) { | 46 | function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 15 | const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); | 47 | const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); |
| 48 | const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { | ||
| 49 | reportFailure: false, | ||
| 50 | reportDefect: false, | ||
| 51 | }); | ||
| 16 | const previousTokenProviderRef = useRef<{ | 52 | const previousTokenProviderRef = useRef<{ |
| 17 | readonly userId: string; | 53 | readonly userId: string; |
| 18 | readonly provider: () => Promise<string | null>; | 54 | readonly provider: () => Promise<string | null>; |
| 19 | } | null>(null); | 55 | } | null>(null); |
| 56 | const observedAccountRef = useRef<string | null | undefined>(undefined); | ||
| 57 | const accountTransitionRef = useRef<Promise<void> | null>(null); | ||
| 20 | 58 | ||
| 21 | useEffect(() => { | 59 | useEffect(() => { |
| 60 | let cancelled = false; | ||
| 22 | if (!isLoaded) { | 61 | if (!isLoaded) { |
| 23 | return; | 62 | return; |
| 24 | } | 63 | } |
| 64 | |||
| 65 | const previousObservedAccount = observedAccountRef.current; | ||
| 66 | const nextAccount = isSignedIn && userId ? userId : null; | ||
| 67 | observedAccountRef.current = nextAccount; | ||
| 68 | |||
| 69 | const queueAccountCleanup = ( | ||
| 70 | previous: { | ||
| 71 | readonly userId: string; | ||
| 72 | readonly provider: () => Promise<string | null>; | ||
| 73 | } | null, | ||
| 74 | ) => { | ||
| 75 | const previousTransition = accountTransitionRef.current ?? Promise.resolve(); | ||
| 76 | accountTransitionRef.current = previousTransition.then(async () => { | ||
| 77 | const cleanup = [ | ||
| 78 | resetManagedRelayTokenCache(), | ||
| 79 | removeRelayEnvironments(), | ||
| 80 | ...(previous | ||
| 81 | ? [ | ||
| 82 | settleAsyncResult(() => | ||
| 83 | runtime.runPromiseExit( | ||
| 84 | unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), | ||
| 85 | ), | ||
| 86 | ), | ||
| 87 | ] | ||
| 88 | : []), | ||
| 89 | ]; | ||
| 90 | const results = await Promise.all(cleanup); | ||
| 91 | for (const result of results) { | ||
| 92 | reportAtomCommandResult(result, { label: "cloud account cleanup" }); | ||
| 93 | } | ||
| 94 | }); | ||
| 95 | return accountTransitionRef.current; | ||
| 96 | }; | ||
| 97 | |||
| 25 | if (!isSignedIn || !userId) { | 98 | if (!isSignedIn || !userId) { |
| 26 | const previous = previousTokenProviderRef.current; | 99 | const previous = previousTokenProviderRef.current; |
| 27 | previousTokenProviderRef.current = null; | 100 | previousTokenProviderRef.current = null; |
| 28 | if (previous) { | 101 | deactivateCloudRelayAccount(); |
| 29 | void mobileRuntime | 102 | if (previousObservedAccount !== null) { |
| 30 | .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) | 103 | void queueAccountCleanup(previous); |
| 31 | .catch(() => undefined); | ||
| 32 | } | 104 | } |
| 33 | setAgentAwarenessRelayTokenProvider(null); | ||
| 34 | setManagedRelaySession(appAtomRegistry, null); | ||
| 35 | return; | 105 | return; |
| 36 | } | 106 | } |
| 37 | 107 | ||
| 38 | const previous = previousTokenProviderRef.current; | 108 | const previous = previousTokenProviderRef.current; |
| 39 | if (previous && previous.userId !== userId) { | ||
| 40 | void mobileRuntime | ||
| 41 | .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) | ||
| 42 | .catch(() => undefined); | ||
| 43 | } | ||
| 44 | const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); | 109 | const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); |
| 45 | previousTokenProviderRef.current = { userId, provider: tokenProvider }; | 110 | const activateSession = () => { |
| 46 | setAgentAwarenessRelayTokenProvider(tokenProvider, userId); | 111 | if (cancelled) { |
| 47 | setManagedRelaySession( | 112 | return; |
| 48 | appAtomRegistry, | 113 | } |
| 49 | createManagedRelaySession({ | 114 | previousTokenProviderRef.current = { userId, provider: tokenProvider }; |
| 50 | accountId: userId, | 115 | activateCloudRelayAccount(userId, tokenProvider); |
| 51 | readClerkToken: tokenProvider, | 116 | }; |
| 52 | }), | 117 | const activateAfterTransition = (transition: Promise<void>) => { |
| 53 | ); | 118 | void (async () => { |
| 54 | }, [getToken, isLoaded, isSignedIn, userId]); | 119 | const result = await settlePromise(async () => { |
| 120 | await transition; | ||
| 121 | activateSession(); | ||
| 122 | }); | ||
| 123 | reportAtomCommandResult(result, { label: "cloud account activation" }); | ||
| 124 | })(); | ||
| 125 | }; | ||
| 126 | if ( | ||
| 127 | previousObservedAccount !== undefined && | ||
| 128 | previousObservedAccount !== null && | ||
| 129 | previousObservedAccount !== userId | ||
| 130 | ) { | ||
| 131 | previousTokenProviderRef.current = null; | ||
| 132 | deactivateCloudRelayAccount(); | ||
| 133 | activateAfterTransition(queueAccountCleanup(previous)); | ||
| 134 | } else { | ||
| 135 | activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); | ||
| 136 | } | ||
| 137 | |||
| 138 | return () => { | ||
| 139 | cancelled = true; | ||
| 140 | }; | ||
| 141 | }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]); | ||
| 55 | 142 | ||
| 56 | useEffect( | 143 | useEffect( |
| 57 | () => () => { | 144 | () => () => { |
| 58 | previousTokenProviderRef.current = null; | 145 | previousTokenProviderRef.current = null; |
| 59 | setAgentAwarenessRelayTokenProvider(null); | 146 | deactivateCloudRelayAccount(); |
| 60 | setManagedRelaySession(appAtomRegistry, null); | ||
| 61 | }, | 147 | }, |
| 62 | [], | 148 | [], |
| 63 | ); | 149 | ); |
| ... | @@ -72,8 +158,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) { | ... | @@ -72,8 +158,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) { |
| 72 | 158 | ||
| 73 | useEffect(() => { | 159 | useEffect(() => { |
| 74 | if (!publishableKey || !relayUrl) { | 160 | if (!publishableKey || !relayUrl) { |
| 75 | setAgentAwarenessRelayTokenProvider(null); | 161 | deactivateCloudRelayAccount(); |
| 76 | setManagedRelaySession(appAtomRegistry, null); | ||
| 77 | } | 162 | } |
| 78 | }, [publishableKey, relayUrl]); | 163 | }, [publishableKey, relayUrl]); |
| 79 | 164 |
apps/mobile/src/features/cloud/cloudDebugLog.ts created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | export function isCloudDebugEnabled(): boolean { | ||
| 2 | return ( | ||
| 3 | (typeof __DEV__ !== "undefined" && __DEV__) || | ||
| 4 | (typeof globalThis !== "undefined" && | ||
| 5 | (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true) | ||
| 6 | ); | ||
| 7 | } | ||
| 8 | |||
| 9 | export function cloudDebugLog(event: string, data?: Record<string, unknown>): void { | ||
| 10 | if (!isCloudDebugEnabled()) { | ||
| 11 | return; | ||
| 12 | } | ||
| 13 | if (data) { | ||
| 14 | console.log(`[t3-cloud] ${event}`, data); | ||
| 15 | } else { | ||
| 16 | console.log(`[t3-cloud] ${event}`); | ||
| 17 | } | ||
| 18 | } | ||
apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts created+88| ... | @@ -0,0 +1,88 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation"; | ||
| 6 | |||
| 7 | function relayStatus( | ||
| 8 | status: RelayEnvironmentStatusResponse["status"], | ||
| 9 | error?: string, | ||
| 10 | traceId?: string, | ||
| 11 | ): RelayEnvironmentStatusResponse { | ||
| 12 | return { | ||
| 13 | environmentId: EnvironmentId.make("environment-cloud"), | ||
| 14 | endpoint: { | ||
| 15 | httpBaseUrl: "https://cloud.example.test/", | ||
| 16 | wsBaseUrl: "wss://cloud.example.test/ws", | ||
| 17 | providerKind: "cloudflare_tunnel", | ||
| 18 | }, | ||
| 19 | status, | ||
| 20 | checkedAt: "2026-06-05T16:49:11.000Z", | ||
| 21 | ...(error ? { error } : {}), | ||
| 22 | ...(traceId ? { traceId } : {}), | ||
| 23 | }; | ||
| 24 | } | ||
| 25 | |||
| 26 | describe("available cloud environment presentation", () => { | ||
| 27 | it("presents an online unsaved environment as available, not connected", () => { | ||
| 28 | expect( | ||
| 29 | availableCloudEnvironmentPresentation({ | ||
| 30 | isStatusPending: false, | ||
| 31 | status: relayStatus("online"), | ||
| 32 | statusError: null, | ||
| 33 | statusErrorTraceId: null, | ||
| 34 | }), | ||
| 35 | ).toEqual({ | ||
| 36 | connectionError: null, | ||
| 37 | connectionErrorTraceId: null, | ||
| 38 | connectionState: "available", | ||
| 39 | statusText: "Available · Relay online", | ||
| 40 | }); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("keeps relay status checks distinct from connection attempts", () => { | ||
| 44 | expect( | ||
| 45 | availableCloudEnvironmentPresentation({ | ||
| 46 | isStatusPending: true, | ||
| 47 | status: null, | ||
| 48 | statusError: null, | ||
| 49 | statusErrorTraceId: null, | ||
| 50 | }), | ||
| 51 | ).toEqual({ | ||
| 52 | connectionError: null, | ||
| 53 | connectionErrorTraceId: null, | ||
| 54 | connectionState: "available", | ||
| 55 | statusText: "Available · Checking relay status...", | ||
| 56 | }); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("surfaces an offline relay as an error", () => { | ||
| 60 | expect( | ||
| 61 | availableCloudEnvironmentPresentation({ | ||
| 62 | isStatusPending: false, | ||
| 63 | status: relayStatus("offline", "Tunnel is unavailable.", "trace-offline"), | ||
| 64 | statusError: null, | ||
| 65 | statusErrorTraceId: null, | ||
| 66 | }), | ||
| 67 | ).toEqual({ | ||
| 68 | connectionError: "Tunnel is unavailable.", | ||
| 69 | connectionErrorTraceId: "trace-offline", | ||
| 70 | connectionState: "error", | ||
| 71 | statusText: "Tunnel is unavailable.", | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("preserves trace metadata for relay request failures", () => { | ||
| 76 | expect( | ||
| 77 | availableCloudEnvironmentPresentation({ | ||
| 78 | isStatusPending: false, | ||
| 79 | status: null, | ||
| 80 | statusError: "Could not get relay environment status.", | ||
| 81 | statusErrorTraceId: "trace-status", | ||
| 82 | }), | ||
| 83 | ).toMatchObject({ | ||
| 84 | connectionError: "Could not get relay environment status.", | ||
| 85 | connectionErrorTraceId: "trace-status", | ||
| 86 | }); | ||
| 87 | }); | ||
| 88 | }); | ||
apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; | ||
| 2 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 3 | |||
| 4 | export interface AvailableCloudEnvironmentPresentation { | ||
| 5 | readonly connectionError: string | null; | ||
| 6 | readonly connectionErrorTraceId: string | null; | ||
| 7 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 8 | readonly statusText: string; | ||
| 9 | } | ||
| 10 | |||
| 11 | export function availableCloudEnvironmentPresentation(input: { | ||
| 12 | readonly isStatusPending: boolean; | ||
| 13 | readonly status: RelayEnvironmentStatusResponse | null; | ||
| 14 | readonly statusError: string | null; | ||
| 15 | readonly statusErrorTraceId: string | null; | ||
| 16 | }): AvailableCloudEnvironmentPresentation { | ||
| 17 | if (input.status?.status === "online") { | ||
| 18 | return { | ||
| 19 | connectionError: null, | ||
| 20 | connectionErrorTraceId: null, | ||
| 21 | connectionState: "available", | ||
| 22 | statusText: "Available · Relay online", | ||
| 23 | }; | ||
| 24 | } | ||
| 25 | |||
| 26 | if (input.status?.status === "offline") { | ||
| 27 | const connectionError = input.status.error ?? "Relay is offline."; | ||
| 28 | return { | ||
| 29 | connectionError, | ||
| 30 | connectionErrorTraceId: input.status.traceId ?? null, | ||
| 31 | connectionState: "error", | ||
| 32 | statusText: connectionError, | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | if (input.statusError) { | ||
| 37 | return { | ||
| 38 | connectionError: input.statusError, | ||
| 39 | connectionErrorTraceId: input.statusErrorTraceId, | ||
| 40 | connectionState: "error", | ||
| 41 | statusText: input.statusError, | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | return { | ||
| 46 | connectionError: null, | ||
| 47 | connectionErrorTraceId: null, | ||
| 48 | connectionState: "available", | ||
| 49 | statusText: input.isStatusPending | ||
| 50 | ? "Available · Checking relay status..." | ||
| 51 | : "Available · Relay status unknown", | ||
| 52 | }; | ||
| 53 | } | ||
apps/mobile/src/features/cloud/dpop.test.ts+6-6| ... | @@ -12,7 +12,7 @@ import { | ... | @@ -12,7 +12,7 @@ import { |
| 12 | createDpopProof, | 12 | createDpopProof, |
| 13 | generateDpopProofKeyPair, | 13 | generateDpopProofKeyPair, |
| 14 | loadOrCreateDpopProofKeyPair, | 14 | loadOrCreateDpopProofKeyPair, |
| 15 | mobileCryptoLayer, | 15 | cryptoLayer, |
| 16 | } from "./dpop"; | 16 | } from "./dpop"; |
| 17 | 17 | ||
| 18 | vi.mock("expo-crypto", () => ({ | 18 | vi.mock("expo-crypto", () => ({ |
| ... | @@ -75,7 +75,7 @@ describe("mobile DPoP", () => { | ... | @@ -75,7 +75,7 @@ describe("mobile DPoP", () => { |
| 75 | expect(Buffer.from(digest).toString("hex")).toBe( | 75 | expect(Buffer.from(digest).toString("hex")).toBe( |
| 76 | NodeCrypto.createHash("sha256").update("typed-array").digest("hex"), | 76 | NodeCrypto.createHash("sha256").update("typed-array").digest("hex"), |
| 77 | ); | 77 | ); |
| 78 | }).pipe(Effect.provide(mobileCryptoLayer)), | 78 | }).pipe(Effect.provide(cryptoLayer)), |
| 79 | ); | 79 | ); |
| 80 | 80 | ||
| 81 | it.effect("persists and reuses the installation proof key", () => | 81 | it.effect("persists and reuses the installation proof key", () => |
| ... | @@ -86,7 +86,7 @@ describe("mobile DPoP", () => { | ... | @@ -86,7 +86,7 @@ describe("mobile DPoP", () => { |
| 86 | 86 | ||
| 87 | expect(second.thumbprint).toBe(first.thumbprint); | 87 | expect(second.thumbprint).toBe(first.thumbprint); |
| 88 | expect(second.privateJwk).toEqual(first.privateJwk); | 88 | expect(second.privateJwk).toEqual(first.privateJwk); |
| 89 | }).pipe(Effect.provide(mobileCryptoLayer)), | 89 | }).pipe(Effect.provide(cryptoLayer)), |
| 90 | ); | 90 | ); |
| 91 | 91 | ||
| 92 | it.effect("rejects malformed persisted proof keys", () => | 92 | it.effect("rejects malformed persisted proof keys", () => |
| ... | @@ -96,7 +96,7 @@ describe("mobile DPoP", () => { | ... | @@ -96,7 +96,7 @@ describe("mobile DPoP", () => { |
| 96 | const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); | 96 | const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); |
| 97 | 97 | ||
| 98 | expect(error.message).toBe("Stored DPoP proof key is invalid."); | 98 | expect(error.message).toBe("Stored DPoP proof key is invalid."); |
| 99 | }).pipe(Effect.provide(mobileCryptoLayer)), | 99 | }).pipe(Effect.provide(cryptoLayer)), |
| 100 | ); | 100 | ); |
| 101 | 101 | ||
| 102 | it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () => | 102 | it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () => |
| ... | @@ -135,7 +135,7 @@ describe("mobile DPoP", () => { | ... | @@ -135,7 +135,7 @@ describe("mobile DPoP", () => { |
| 135 | nowEpochSeconds: proofIat(bootstrap.proof), | 135 | nowEpochSeconds: proofIat(bootstrap.proof), |
| 136 | }), | 136 | }), |
| 137 | ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); | 137 | ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); |
| 138 | }).pipe(Effect.provide(mobileCryptoLayer)), | 138 | }).pipe(Effect.provide(cryptoLayer)), |
| 139 | ); | 139 | ); |
| 140 | 140 | ||
| 141 | it.effect("signs DPoP proofs with RFC 9449 htu normalization", () => | 141 | it.effect("signs DPoP proofs with RFC 9449 htu normalization", () => |
| ... | @@ -161,6 +161,6 @@ describe("mobile DPoP", () => { | ... | @@ -161,6 +161,6 @@ describe("mobile DPoP", () => { |
| 161 | nowEpochSeconds: proofIat(proof.proof), | 161 | nowEpochSeconds: proofIat(proof.proof), |
| 162 | }), | 162 | }), |
| 163 | ).toMatchObject({ ok: true }); | 163 | ).toMatchObject({ ok: true }); |
| 164 | }).pipe(Effect.provide(mobileCryptoLayer)), | 164 | }).pipe(Effect.provide(cryptoLayer)), |
| 165 | ); | 165 | ); |
| 166 | }); | 166 | }); |
apps/mobile/src/features/cloud/dpop.ts+1-1| ... | @@ -70,7 +70,7 @@ function toExpoDigestAlgorithm( | ... | @@ -70,7 +70,7 @@ function toExpoDigestAlgorithm( |
| 70 | } | 70 | } |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | export const mobileCryptoLayer = Layer.succeed( | 73 | export const cryptoLayer = Layer.succeed( |
| 74 | Crypto.Crypto, | 74 | Crypto.Crypto, |
| 75 | Crypto.make({ | 75 | Crypto.make({ |
| 76 | randomBytes: ExpoCrypto.getRandomBytes, | 76 | randomBytes: ExpoCrypto.getRandomBytes, |
apps/mobile/src/features/cloud/linkEnvironment.test.ts+11-5| ... | @@ -8,8 +8,8 @@ import { | ... | @@ -8,8 +8,8 @@ import { |
| 8 | managedRelayClientLayer, | 8 | managedRelayClientLayer, |
| 9 | ManagedRelayClient, | 9 | ManagedRelayClient, |
| 10 | ManagedRelayDpopSigner, | 10 | ManagedRelayDpopSigner, |
| 11 | remoteHttpClientLayer, | 11 | } from "@t3tools/client-runtime/relay"; |
| 12 | } from "@t3tools/client-runtime"; | 12 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; |
| 13 | import { HttpClient } from "effect/unstable/http"; | 13 | import { HttpClient } from "effect/unstable/http"; |
| 14 | 14 | ||
| 15 | import { | 15 | import { |
| ... | @@ -55,6 +55,8 @@ const savedConnection = { | ... | @@ -55,6 +55,8 @@ const savedConnection = { |
| 55 | bearerToken: "local-bearer", | 55 | bearerToken: "local-bearer", |
| 56 | }; | 56 | }; |
| 57 | 57 | ||
| 58 | const stableClerkToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.test"; | ||
| 59 | |||
| 58 | const createProofMock = vi.fn( | 60 | const createProofMock = vi.fn( |
| 59 | (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => | 61 | (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => |
| 60 | Effect.succeed(`dpop:${input.method}:${input.url}`), | 62 | Effect.succeed(`dpop:${input.method}:${input.url}`), |
| ... | @@ -352,7 +354,7 @@ describe("mobile cloud link environment client", () => { | ... | @@ -352,7 +354,7 @@ describe("mobile cloud link environment client", () => { |
| 352 | }); | 354 | }); |
| 353 | vi.stubGlobal("fetch", fetchMock); | 355 | vi.stubGlobal("fetch", fetchMock); |
| 354 | 356 | ||
| 355 | yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" })); | 357 | yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: stableClerkToken })); |
| 356 | 358 | ||
| 357 | expect( | 359 | expect( |
| 358 | fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), | 360 | fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), |
| ... | @@ -425,9 +427,11 @@ describe("mobile cloud link environment client", () => { | ... | @@ -425,9 +427,11 @@ describe("mobile cloud link environment client", () => { |
| 425 | 427 | ||
| 426 | yield* withCloudServices( | 428 | yield* withCloudServices( |
| 427 | Effect.gen(function* () { | 429 | Effect.gen(function* () { |
| 428 | const records = yield* listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }); | 430 | const records = yield* listCloudEnvironmentsWithStatus({ |
| 431 | clerkToken: stableClerkToken, | ||
| 432 | }); | ||
| 429 | yield* connectCloudEnvironment({ | 433 | yield* connectCloudEnvironment({ |
| 430 | clerkToken: "clerk-token", | 434 | clerkToken: stableClerkToken, |
| 431 | environment: records[0]!.environment, | 435 | environment: records[0]!.environment, |
| 432 | }); | 436 | }); |
| 433 | }), | 437 | }), |
| ... | @@ -658,6 +662,7 @@ describe("mobile cloud link environment client", () => { | ... | @@ -658,6 +662,7 @@ describe("mobile cloud link environment client", () => { |
| 658 | _tag: "CloudEnvironmentLinkError", | 662 | _tag: "CloudEnvironmentLinkError", |
| 659 | message: | 663 | message: |
| 660 | "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", | 664 | "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", |
| 665 | traceId: "trace-test", | ||
| 661 | }); | 666 | }); |
| 662 | expect(fetchMock).toHaveBeenCalledTimes(3); | 667 | expect(fetchMock).toHaveBeenCalledTimes(3); |
| 663 | }), | 668 | }), |
| ... | @@ -1003,6 +1008,7 @@ describe("mobile cloud link environment client", () => { | ... | @@ -1003,6 +1008,7 @@ describe("mobile cloud link environment client", () => { |
| 1003 | _tag: "CloudEnvironmentLinkError", | 1008 | _tag: "CloudEnvironmentLinkError", |
| 1004 | message: | 1009 | message: |
| 1005 | "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", | 1010 | "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", |
| 1011 | traceId: "trace-connect", | ||
| 1006 | }); | 1012 | }); |
| 1007 | }), | 1013 | }), |
| 1008 | ); | 1014 | ); |
apps/mobile/src/features/cloud/linkEnvironment.ts+35-38| ... | @@ -16,22 +16,23 @@ import { | ... | @@ -16,22 +16,23 @@ import { |
| 16 | type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, | 16 | type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, |
| 17 | RelayEnvironmentConnectScope, | 17 | RelayEnvironmentConnectScope, |
| 18 | RelayEnvironmentStatusScope, | 18 | RelayEnvironmentStatusScope, |
| 19 | RelayProtectedError, | ||
| 20 | type RelayDpopAccessTokenScope, | 19 | type RelayDpopAccessTokenScope, |
| 21 | type RelayProtectedError as RelayProtectedErrorType, | 20 | type RelayProtectedError as RelayProtectedErrorType, |
| 22 | type RelayClientEnvironmentRecord, | 21 | type RelayClientEnvironmentRecord, |
| 23 | type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, | 22 | type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, |
| 24 | type RelayManagedEndpointProviderKind, | 23 | type RelayManagedEndpointProviderKind, |
| 25 | } from "@t3tools/contracts/relay"; | 24 | } from "@t3tools/contracts/relay"; |
| 25 | import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; | ||
| 26 | import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; | ||
| 27 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; | ||
| 26 | import { | 28 | import { |
| 27 | exchangeRemoteDpopAccessToken, | ||
| 28 | fetchRemoteEnvironmentDescriptor, | ||
| 29 | makeEnvironmentHttpApiClient, | ||
| 30 | ManagedRelayClient, | 29 | ManagedRelayClient, |
| 30 | type ManagedRelayClientError, | ||
| 31 | ManagedRelayDpopSigner, | 31 | ManagedRelayDpopSigner, |
| 32 | } from "@t3tools/client-runtime"; | 32 | } from "@t3tools/client-runtime/relay"; |
| 33 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; | ||
| 33 | 34 | ||
| 34 | import { mobileAuthClientMetadata } from "../../lib/authClientMetadata"; | 35 | import { authClientMetadata } from "../../lib/authClientMetadata"; |
| 35 | import type { SavedRemoteConnection } from "../../lib/connection"; | 36 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 36 | import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; | 37 | import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; |
| 37 | import { resolveCloudPublicConfig } from "./publicConfig"; | 38 | import { resolveCloudPublicConfig } from "./publicConfig"; |
| ... | @@ -56,6 +57,7 @@ function readRelayUrl(): string | null { | ... | @@ -56,6 +57,7 @@ function readRelayUrl(): string | null { |
| 56 | export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ | 57 | export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ |
| 57 | readonly message: string; | 58 | readonly message: string; |
| 58 | readonly cause?: unknown; | 59 | readonly cause?: unknown; |
| 60 | readonly traceId?: string; | ||
| 59 | }> {} | 61 | }> {} |
| 60 | 62 | ||
| 61 | export interface CloudEnvironmentRecordWithStatus { | 63 | export interface CloudEnvironmentRecordWithStatus { |
| ... | @@ -64,7 +66,6 @@ export interface CloudEnvironmentRecordWithStatus { | ... | @@ -64,7 +66,6 @@ export interface CloudEnvironmentRecordWithStatus { |
| 64 | readonly statusError: string | null; | 66 | readonly statusError: string | null; |
| 65 | } | 67 | } |
| 66 | 68 | ||
| 67 | const isRelayProtectedError = Schema.is(RelayProtectedError); | ||
| 68 | const isEnvironmentCloudApiError = Schema.is( | 69 | const isEnvironmentCloudApiError = Schema.is( |
| 69 | Schema.Union([ | 70 | Schema.Union([ |
| 70 | EnvironmentHttpBadRequestError, | 71 | EnvironmentHttpBadRequestError, |
| ... | @@ -82,11 +83,13 @@ const MANAGED_ENDPOINT_PROVIDER_KIND = | ... | @@ -82,11 +83,13 @@ const MANAGED_ENDPOINT_PROVIDER_KIND = |
| 82 | function cloudEnvironmentLinkError(message: string) { | 83 | function cloudEnvironmentLinkError(message: string) { |
| 83 | return (cause: unknown) => { | 84 | return (cause: unknown) => { |
| 84 | const environmentError = findEnvironmentCloudApiError(cause); | 85 | const environmentError = findEnvironmentCloudApiError(cause); |
| 86 | const traceId = findErrorTraceId(cause); | ||
| 85 | return new CloudEnvironmentLinkError({ | 87 | return new CloudEnvironmentLinkError({ |
| 86 | message: environmentError | 88 | message: environmentError |
| 87 | ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` | 89 | ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` |
| 88 | : withDevCause(message, cause), | 90 | : withDevCause(message, cause), |
| 89 | cause, | 91 | cause, |
| 92 | ...(traceId === null ? {} : { traceId }), | ||
| 90 | }); | 93 | }); |
| 91 | }; | 94 | }; |
| 92 | } | 95 | } |
| ... | @@ -148,31 +151,22 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { | ... | @@ -148,31 +151,22 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { |
| 148 | case "RelayAgentActivityPublishProofInvalidError": | 151 | case "RelayAgentActivityPublishProofInvalidError": |
| 149 | return `Relay rejected the agent activity publish proof (${error.reason}).`; | 152 | return `Relay rejected the agent activity publish proof (${error.reason}).`; |
| 150 | case "RelayInternalError": | 153 | case "RelayInternalError": |
| 151 | return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; | 154 | return `Relay encountered an internal error (${error.reason}).`; |
| 152 | } | 155 | } |
| 153 | } | 156 | } |
| 154 | 157 | ||
| 155 | function decodedRelayClientError(message: string) { | 158 | function decodedRelayClientError(message: string) { |
| 156 | return (cause: unknown) => { | 159 | return (cause: ManagedRelayClientError) => { |
| 157 | const relayError = findRelayProtectedError(cause); | 160 | const relayError = cause.relayError; |
| 158 | const detail = relayError ? relayProtectedErrorMessage(relayError) : null; | 161 | const detail = relayError ? relayProtectedErrorMessage(relayError) : null; |
| 159 | return new CloudEnvironmentLinkError({ | 162 | return new CloudEnvironmentLinkError({ |
| 160 | message: detail ? `${message}: ${detail}` : message, | 163 | message: detail ? `${message}: ${detail}` : message, |
| 161 | cause, | 164 | cause, |
| 165 | ...(cause.traceId ? { traceId: cause.traceId } : {}), | ||
| 162 | }); | 166 | }); |
| 163 | }; | 167 | }; |
| 164 | } | 168 | } |
| 165 | 169 | ||
| 166 | function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { | ||
| 167 | if (isRelayProtectedError(cause)) { | ||
| 168 | return cause; | ||
| 169 | } | ||
| 170 | if (typeof cause !== "object" || cause === null) { | ||
| 171 | return null; | ||
| 172 | } | ||
| 173 | return "cause" in cause ? findRelayProtectedError(cause.cause) : null; | ||
| 174 | } | ||
| 175 | |||
| 176 | function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { | 170 | function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { |
| 177 | if (isEnvironmentCloudApiError(cause)) { | 171 | if (isEnvironmentCloudApiError(cause)) { |
| 178 | return cause; | 172 | return cause; |
| ... | @@ -462,23 +456,26 @@ export function listCloudEnvironmentsWithStatus(input: { | ... | @@ -462,23 +456,26 @@ export function listCloudEnvironmentsWithStatus(input: { |
| 462 | }); | 456 | }); |
| 463 | } | 457 | } |
| 464 | 458 | ||
| 465 | function connectRelayManagedEnvironment(input: { | 459 | const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( |
| 466 | readonly clerkToken: string; | 460 | function* () { |
| 467 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | 461 | return yield* Effect.tryPromise({ |
| 468 | readonly expectedEnvironment?: RelayClientEnvironmentRecord; | ||
| 469 | }): Effect.Effect< | ||
| 470 | SavedRemoteConnection, | ||
| 471 | CloudEnvironmentLinkError, | ||
| 472 | HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner | ||
| 473 | > { | ||
| 474 | return Effect.gen(function* () { | ||
| 475 | const relayUrl = yield* requireRelayUrl(); | ||
| 476 | const relayClient = yield* ManagedRelayClient; | ||
| 477 | |||
| 478 | const deviceId = yield* Effect.tryPromise({ | ||
| 479 | try: () => loadOrCreateAgentAwarenessDeviceId(), | 462 | try: () => loadOrCreateAgentAwarenessDeviceId(), |
| 480 | catch: cloudEnvironmentLinkError("Could not load the mobile device id."), | 463 | catch: cloudEnvironmentLinkError("Could not load the mobile device id."), |
| 481 | }); | 464 | }); |
| 465 | }, | ||
| 466 | ); | ||
| 467 | |||
| 468 | const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")( | ||
| 469 | function* (input: { | ||
| 470 | readonly clerkToken: string; | ||
| 471 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 472 | readonly expectedEnvironment?: RelayClientEnvironmentRecord; | ||
| 473 | }) { | ||
| 474 | yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId }); | ||
| 475 | const relayUrl = yield* requireRelayUrl(); | ||
| 476 | const relayClient = yield* ManagedRelayClient; | ||
| 477 | |||
| 478 | const deviceId = yield* loadAgentAwarenessDeviceId(); | ||
| 482 | const connect = yield* relayClient | 479 | const connect = yield* relayClient |
| 483 | .connectEnvironment({ | 480 | .connectEnvironment({ |
| 484 | clerkToken: input.clerkToken, | 481 | clerkToken: input.clerkToken, |
| ... | @@ -528,7 +525,7 @@ function connectRelayManagedEnvironment(input: { | ... | @@ -528,7 +525,7 @@ function connectRelayManagedEnvironment(input: { |
| 528 | httpBaseUrl: connect.endpoint.httpBaseUrl, | 525 | httpBaseUrl: connect.endpoint.httpBaseUrl, |
| 529 | credential: connect.credential, | 526 | credential: connect.credential, |
| 530 | dpopProof: bootstrapDpop, | 527 | dpopProof: bootstrapDpop, |
| 531 | clientMetadata: mobileAuthClientMetadata(), | 528 | clientMetadata: authClientMetadata(), |
| 532 | }).pipe( | 529 | }).pipe( |
| 533 | Effect.mapError( | 530 | Effect.mapError( |
| 534 | cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), | 531 | cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), |
| ... | @@ -548,9 +545,9 @@ function connectRelayManagedEnvironment(input: { | ... | @@ -548,9 +545,9 @@ function connectRelayManagedEnvironment(input: { |
| 548 | authenticationMethod: "dpop", | 545 | authenticationMethod: "dpop", |
| 549 | dpopAccessToken: bootstrap.access_token, | 546 | dpopAccessToken: bootstrap.access_token, |
| 550 | relayManaged: true, | 547 | relayManaged: true, |
| 551 | }; | 548 | } satisfies SavedRemoteConnection; |
| 552 | }); | 549 | }, |
| 553 | } | 550 | ); |
| 554 | 551 | ||
| 555 | export function connectCloudEnvironment(input: { | 552 | export function connectCloudEnvironment(input: { |
| 556 | readonly clerkToken: string; | 553 | readonly clerkToken: string; |
apps/mobile/src/features/cloud/managedRelayLayer.ts+23-19| ... | @@ -1,42 +1,46 @@ | ... | @@ -1,42 +1,46 @@ |
| 1 | import { | 1 | import { |
| 2 | managedRelayClientLayer, | 2 | managedRelayClientLayer as makeManagedRelayClientLayer, |
| 3 | ManagedRelayDpopSigner, | 3 | ManagedRelayDpopSigner, |
| 4 | ManagedRelayDpopSignerError, | 4 | ManagedRelayDpopSignerError, |
| 5 | } from "@t3tools/client-runtime"; | 5 | } from "@t3tools/client-runtime/relay"; |
| 6 | import { RelayMobileClientId } from "@t3tools/contracts/relay"; | 6 | import { RelayMobileClientId } from "@t3tools/contracts/relay"; |
| 7 | import * as Crypto from "effect/Crypto"; | 7 | import * as Crypto from "effect/Crypto"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 10 | 10 | ||
| 11 | import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; | 11 | import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; |
| 12 | import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; | ||
| 12 | 13 | ||
| 13 | const mobileRelayDpopSignerLayer = Layer.effect( | 14 | const relayDpopSignerLayer = Layer.effect( |
| 14 | ManagedRelayDpopSigner, | 15 | ManagedRelayDpopSigner, |
| 15 | Effect.gen(function* () { | 16 | Effect.gen(function* () { |
| 16 | const crypto = yield* Crypto.Crypto; | 17 | const crypto = yield* Crypto.Crypto; |
| 18 | const loadProofKey = yield* Effect.cached( | ||
| 19 | loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), | ||
| 20 | ); | ||
| 17 | return ManagedRelayDpopSigner.of({ | 21 | return ManagedRelayDpopSigner.of({ |
| 18 | thumbprint: Effect.suspend(() => | 22 | thumbprint: loadProofKey.pipe( |
| 19 | loadOrCreateDpopProofKeyPair().pipe( | 23 | Effect.map((proofKey) => proofKey.thumbprint), |
| 20 | Effect.provideService(Crypto.Crypto, crypto), | 24 | Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), |
| 21 | Effect.map((proofKey) => proofKey.thumbprint), | 25 | Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"), |
| 22 | Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), | ||
| 23 | ), | ||
| 24 | ), | 26 | ), |
| 25 | createProof: (input) => | 27 | createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")( |
| 26 | Effect.gen(function* () { | 28 | function* (input) { |
| 27 | const proofKey = yield* loadOrCreateDpopProofKeyPair().pipe( | 29 | const proofKey = yield* loadProofKey; |
| 28 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 29 | ); | ||
| 30 | return yield* createDpopProof({ ...input, proofKey }).pipe( | 30 | return yield* createDpopProof({ ...input, proofKey }).pipe( |
| 31 | Effect.provideService(Crypto.Crypto, crypto), | 31 | Effect.provideService(Crypto.Crypto, crypto), |
| 32 | Effect.map((proof) => proof.proof), | 32 | Effect.map((proof) => proof.proof), |
| 33 | ); | 33 | ); |
| 34 | }).pipe(Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause }))), | 34 | }, |
| 35 | Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), | ||
| 36 | ), | ||
| 35 | }); | 37 | }); |
| 36 | }), | 38 | }), |
| 37 | ); | 39 | ); |
| 38 | 40 | ||
| 39 | export const mobileManagedRelayClientLayer = (relayUrl: string) => | 41 | export const managedRelayClientLayer = (relayUrl: string) => |
| 40 | managedRelayClientLayer({ relayUrl, clientId: RelayMobileClientId }).pipe( | 42 | makeManagedRelayClientLayer({ |
| 41 | Layer.provideMerge(mobileRelayDpopSignerLayer), | 43 | relayUrl, |
| 42 | ); | 44 | clientId: RelayMobileClientId, |
| 45 | accessTokenStore: managedRelayAccessTokenStore, | ||
| 46 | }).pipe(Layer.provideMerge(relayDpopSignerLayer)); |
apps/mobile/src/features/cloud/managedRelayState.ts+30-7| ... | @@ -3,20 +3,24 @@ import { | ... | @@ -3,20 +3,24 @@ import { |
| 3 | createManagedRelayQueryManager, | 3 | createManagedRelayQueryManager, |
| 4 | managedRelaySessionAtom, | 4 | managedRelaySessionAtom, |
| 5 | readManagedRelaySnapshotState, | 5 | readManagedRelaySnapshotState, |
| 6 | } from "@t3tools/client-runtime"; | 6 | } from "@t3tools/client-runtime/relay"; |
| 7 | import type { | 7 | import type { |
| 8 | RelayClientEnvironmentRecord, | 8 | RelayClientEnvironmentRecord, |
| 9 | RelayEnvironmentStatusResponse, | 9 | RelayEnvironmentStatusResponse, |
| 10 | } from "@t3tools/contracts/relay"; | 10 | } from "@t3tools/contracts/relay"; |
| 11 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 11 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 12 | import { useCallback } from "react"; | 12 | import { useCallback, useEffect } from "react"; |
| 13 | 13 | ||
| 14 | import { mobileRuntimeContextLayer } from "../../lib/runtime"; | 14 | import { runtimeContextLayer } from "../../lib/runtime"; |
| 15 | import { appAtomRegistry } from "../../state/atom-registry"; | 15 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 16 | import { cloudDebugLog } from "./cloudDebugLog"; | ||
| 16 | 17 | ||
| 17 | const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer); | 18 | const managedRelayAtomRuntime = Atom.runtime(runtimeContextLayer); |
| 18 | 19 | ||
| 19 | export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); | 20 | export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime, { |
| 21 | onQueryEvent: (event) => | ||
| 22 | cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), | ||
| 23 | }); | ||
| 20 | 24 | ||
| 21 | const EMPTY_ENVIRONMENTS_ATOM = Atom.make( | 25 | const EMPTY_ENVIRONMENTS_ATOM = Atom.make( |
| 22 | AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]), | 26 | AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]), |
| ... | @@ -33,6 +37,15 @@ export function useManagedRelayEnvironments() { | ... | @@ -33,6 +37,15 @@ export function useManagedRelayEnvironments() { |
| 33 | ? managedRelayQueryManager.environmentsAtom(accountId) | 37 | ? managedRelayQueryManager.environmentsAtom(accountId) |
| 34 | : EMPTY_ENVIRONMENTS_ATOM; | 38 | : EMPTY_ENVIRONMENTS_ATOM; |
| 35 | const result = useAtomValue(atom); | 39 | const result = useAtomValue(atom); |
| 40 | const snapshot = readManagedRelaySnapshotState(result); | ||
| 41 | useEffect(() => { | ||
| 42 | if (snapshot.error) { | ||
| 43 | console.error("[t3-cloud] Relay environment listing failed", { | ||
| 44 | message: snapshot.error, | ||
| 45 | traceId: snapshot.errorTraceId, | ||
| 46 | }); | ||
| 47 | } | ||
| 48 | }, [snapshot.error, snapshot.errorTraceId]); | ||
| 36 | const refresh = useCallback(() => { | 49 | const refresh = useCallback(() => { |
| 37 | if (accountId) { | 50 | if (accountId) { |
| 38 | managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); | 51 | managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); |
| ... | @@ -40,7 +53,7 @@ export function useManagedRelayEnvironments() { | ... | @@ -40,7 +53,7 @@ export function useManagedRelayEnvironments() { |
| 40 | }, [accountId]); | 53 | }, [accountId]); |
| 41 | 54 | ||
| 42 | return { | 55 | return { |
| 43 | ...readManagedRelaySnapshotState(result), | 56 | ...snapshot, |
| 44 | accountId, | 57 | accountId, |
| 45 | refresh, | 58 | refresh, |
| 46 | }; | 59 | }; |
| ... | @@ -53,6 +66,16 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron | ... | @@ -53,6 +66,16 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron |
| 53 | ? managedRelayQueryManager.environmentStatusAtom({ accountId, environment }) | 66 | ? managedRelayQueryManager.environmentStatusAtom({ accountId, environment }) |
| 54 | : EMPTY_ENVIRONMENT_STATUS_ATOM; | 67 | : EMPTY_ENVIRONMENT_STATUS_ATOM; |
| 55 | const result = useAtomValue(atom); | 68 | const result = useAtomValue(atom); |
| 69 | const snapshot = readManagedRelaySnapshotState(result); | ||
| 70 | useEffect(() => { | ||
| 71 | if (snapshot.error) { | ||
| 72 | console.error("[t3-cloud] Relay environment status failed", { | ||
| 73 | environmentId: environment.environmentId, | ||
| 74 | message: snapshot.error, | ||
| 75 | traceId: snapshot.errorTraceId, | ||
| 76 | }); | ||
| 77 | } | ||
| 78 | }, [environment.environmentId, snapshot.error, snapshot.errorTraceId]); | ||
| 56 | const refresh = useCallback(() => { | 79 | const refresh = useCallback(() => { |
| 57 | if (accountId) { | 80 | if (accountId) { |
| 58 | managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, { | 81 | managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, { |
| ... | @@ -63,7 +86,7 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron | ... | @@ -63,7 +86,7 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron |
| 63 | }, [accountId, environment]); | 86 | }, [accountId, environment]); |
| 64 | 87 | ||
| 65 | return { | 88 | return { |
| 66 | ...readManagedRelaySnapshotState(result), | 89 | ...snapshot, |
| 67 | accountId, | 90 | accountId, |
| 68 | refresh, | 91 | refresh, |
| 69 | }; | 92 | }; |
apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts created+51| ... | @@ -0,0 +1,51 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | const secureStore = vi.hoisted(() => new Map<string, string>()); | ||
| 6 | |||
| 7 | vi.mock("expo-secure-store", () => ({ | ||
| 8 | getItemAsync: vi.fn((key: string) => Promise.resolve(secureStore.get(key) ?? null)), | ||
| 9 | setItemAsync: vi.fn((key: string, value: string) => { | ||
| 10 | secureStore.set(key, value); | ||
| 11 | return Promise.resolve(); | ||
| 12 | }), | ||
| 13 | deleteItemAsync: vi.fn((key: string) => { | ||
| 14 | secureStore.delete(key); | ||
| 15 | return Promise.resolve(); | ||
| 16 | }), | ||
| 17 | })); | ||
| 18 | |||
| 19 | import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; | ||
| 20 | |||
| 21 | it.effect("round-trips and clears persisted managed relay access tokens", () => | ||
| 22 | Effect.gen(function* () { | ||
| 23 | secureStore.clear(); | ||
| 24 | const entries = [ | ||
| 25 | { | ||
| 26 | accountId: "user-1", | ||
| 27 | clientId: "t3-mobile", | ||
| 28 | relayUrl: "https://relay.example.test", | ||
| 29 | thumbprint: "thumbprint", | ||
| 30 | scopes: ["environment:connect"], | ||
| 31 | accessToken: "access-token", | ||
| 32 | expiresAtMillis: 1_800_000, | ||
| 33 | }, | ||
| 34 | ] as const; | ||
| 35 | |||
| 36 | yield* managedRelayAccessTokenStore.save(entries); | ||
| 37 | expect(yield* managedRelayAccessTokenStore.load).toEqual(entries); | ||
| 38 | |||
| 39 | yield* managedRelayAccessTokenStore.clear; | ||
| 40 | expect(yield* managedRelayAccessTokenStore.load).toEqual([]); | ||
| 41 | }), | ||
| 42 | ); | ||
| 43 | |||
| 44 | it.effect("falls back to an empty cache when persisted data is invalid", () => | ||
| 45 | Effect.gen(function* () { | ||
| 46 | secureStore.clear(); | ||
| 47 | secureStore.set("t3code.cloud.relay-access-tokens", "not-json"); | ||
| 48 | |||
| 49 | expect(yield* managedRelayAccessTokenStore.load).toEqual([]); | ||
| 50 | }), | ||
| 51 | ); | ||
apps/mobile/src/features/cloud/managedRelayTokenStore.ts created+107| ... | @@ -0,0 +1,107 @@ | ||
| 1 | import { | ||
| 2 | type ManagedRelayAccessTokenCacheEntry, | ||
| 3 | type ManagedRelayAccessTokenStore, | ||
| 4 | } from "@t3tools/client-runtime/relay"; | ||
| 5 | import * as Data from "effect/Data"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as SecureStore from "expo-secure-store"; | ||
| 9 | |||
| 10 | const MANAGED_RELAY_TOKEN_CACHE_KEY = "t3code.cloud.relay-access-tokens"; | ||
| 11 | const MANAGED_RELAY_TOKEN_CACHE_VERSION = 1; | ||
| 12 | |||
| 13 | const ManagedRelayAccessTokenCacheEntrySchema = Schema.Struct({ | ||
| 14 | accountId: Schema.String, | ||
| 15 | clientId: Schema.Literals(["t3-mobile", "t3-web"]), | ||
| 16 | relayUrl: Schema.String, | ||
| 17 | thumbprint: Schema.String, | ||
| 18 | scopes: Schema.Array( | ||
| 19 | Schema.Literals(["environment:connect", "environment:status", "mobile:registration"]), | ||
| 20 | ), | ||
| 21 | accessToken: Schema.String, | ||
| 22 | expiresAtMillis: Schema.Number, | ||
| 23 | }); | ||
| 24 | |||
| 25 | const ManagedRelayAccessTokenCacheSchema = Schema.fromJsonString( | ||
| 26 | Schema.Struct({ | ||
| 27 | version: Schema.Literal(MANAGED_RELAY_TOKEN_CACHE_VERSION), | ||
| 28 | entries: Schema.Array(ManagedRelayAccessTokenCacheEntrySchema), | ||
| 29 | }), | ||
| 30 | ); | ||
| 31 | |||
| 32 | const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect( | ||
| 33 | ManagedRelayAccessTokenCacheSchema, | ||
| 34 | ); | ||
| 35 | const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema); | ||
| 36 | |||
| 37 | export class ManagedRelayTokenStoreError extends Data.TaggedError("ManagedRelayTokenStoreError")<{ | ||
| 38 | readonly message: string; | ||
| 39 | readonly cause: unknown; | ||
| 40 | }> {} | ||
| 41 | |||
| 42 | const storeError = | ||
| 43 | (message: string) => | ||
| 44 | (cause: unknown): ManagedRelayTokenStoreError => | ||
| 45 | new ManagedRelayTokenStoreError({ message, cause }); | ||
| 46 | |||
| 47 | function logStoreFailure(operation: string) { | ||
| 48 | return (error: ManagedRelayTokenStoreError) => | ||
| 49 | Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe( | ||
| 50 | Effect.annotateLogs({ | ||
| 51 | errorTag: error._tag, | ||
| 52 | message: error.message, | ||
| 53 | }), | ||
| 54 | ); | ||
| 55 | } | ||
| 56 | |||
| 57 | const loadManagedRelayAccessTokens = Effect.tryPromise({ | ||
| 58 | try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), | ||
| 59 | catch: storeError("Could not read persisted relay access tokens."), | ||
| 60 | }).pipe( | ||
| 61 | Effect.flatMap((encoded) => | ||
| 62 | encoded === null | ||
| 63 | ? Effect.succeed<ReadonlyArray<ManagedRelayAccessTokenCacheEntry>>([]) | ||
| 64 | : decodeManagedRelayAccessTokenCache(encoded).pipe( | ||
| 65 | Effect.map((cache) => cache.entries), | ||
| 66 | Effect.mapError(storeError("Persisted relay access tokens are invalid.")), | ||
| 67 | ), | ||
| 68 | ), | ||
| 69 | ); | ||
| 70 | |||
| 71 | const saveManagedRelayAccessTokens = (entries: ReadonlyArray<ManagedRelayAccessTokenCacheEntry>) => | ||
| 72 | encodeManagedRelayAccessTokenCache({ | ||
| 73 | version: MANAGED_RELAY_TOKEN_CACHE_VERSION, | ||
| 74 | entries, | ||
| 75 | }).pipe( | ||
| 76 | Effect.mapError(storeError("Could not encode relay access tokens.")), | ||
| 77 | Effect.flatMap((encoded) => | ||
| 78 | Effect.tryPromise({ | ||
| 79 | try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded), | ||
| 80 | catch: storeError("Could not persist relay access tokens."), | ||
| 81 | }), | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | |||
| 85 | const clearManagedRelayAccessTokens = Effect.tryPromise({ | ||
| 86 | try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), | ||
| 87 | catch: storeError("Could not clear persisted relay access tokens."), | ||
| 88 | }); | ||
| 89 | |||
| 90 | export const managedRelayAccessTokenStore: ManagedRelayAccessTokenStore = { | ||
| 91 | load: loadManagedRelayAccessTokens.pipe( | ||
| 92 | Effect.tapError(logStoreFailure("load")), | ||
| 93 | Effect.orElseSucceed(() => []), | ||
| 94 | Effect.withSpan("mobile.managedRelayTokenStore.load"), | ||
| 95 | ), | ||
| 96 | save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) => | ||
| 97 | saveManagedRelayAccessTokens(entries).pipe( | ||
| 98 | Effect.tapError(logStoreFailure("save")), | ||
| 99 | Effect.ignore, | ||
| 100 | ), | ||
| 101 | ), | ||
| 102 | clear: clearManagedRelayAccessTokens.pipe( | ||
| 103 | Effect.tapError(logStoreFailure("clear")), | ||
| 104 | Effect.ignore, | ||
| 105 | Effect.withSpan("mobile.managedRelayTokenStore.clear"), | ||
| 106 | ), | ||
| 107 | }; | ||
apps/mobile/src/features/cloud/publicConfig.test.ts+4-4| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; | 3 | import { hasTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; |
| 4 | 4 | ||
| 5 | vi.mock("expo-constants", () => ({ | 5 | vi.mock("expo-constants", () => ({ |
| 6 | default: { | 6 | default: { |
| ... | @@ -94,9 +94,9 @@ describe("resolveCloudPublicConfig", () => { | ... | @@ -94,9 +94,9 @@ describe("resolveCloudPublicConfig", () => { |
| 94 | }); | 94 | }); |
| 95 | 95 | ||
| 96 | it("keeps tracing disabled unless every public tracing value is configured", () => { | 96 | it("keeps tracing disabled unless every public tracing value is configured", () => { |
| 97 | expect(hasMobileTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); | 97 | expect(hasTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); |
| 98 | expect( | 98 | expect( |
| 99 | hasMobileTracingPublicConfig( | 99 | hasTracingPublicConfig( |
| 100 | resolveCloudPublicConfig({ | 100 | resolveCloudPublicConfig({ |
| 101 | observability: { | 101 | observability: { |
| 102 | tracesUrl: "https://api.axiom.co/v1/traces", | 102 | tracesUrl: "https://api.axiom.co/v1/traces", |
| ... | @@ -106,7 +106,7 @@ describe("resolveCloudPublicConfig", () => { | ... | @@ -106,7 +106,7 @@ describe("resolveCloudPublicConfig", () => { |
| 106 | ), | 106 | ), |
| 107 | ).toBe(false); | 107 | ).toBe(false); |
| 108 | expect( | 108 | expect( |
| 109 | hasMobileTracingPublicConfig( | 109 | hasTracingPublicConfig( |
| 110 | resolveCloudPublicConfig({ | 110 | resolveCloudPublicConfig({ |
| 111 | observability: { | 111 | observability: { |
| 112 | tracesUrl: "https://api.axiom.co/v1/traces", | 112 | tracesUrl: "https://api.axiom.co/v1/traces", |
apps/mobile/src/features/cloud/publicConfig.ts+3-3| ... | @@ -70,13 +70,13 @@ type Configured<T> = { | ... | @@ -70,13 +70,13 @@ type Configured<T> = { |
| 70 | readonly [Key in keyof T]: NonNullable<T[Key]>; | 70 | readonly [Key in keyof T]: NonNullable<T[Key]>; |
| 71 | }; | 71 | }; |
| 72 | 72 | ||
| 73 | type MobileTracingPublicConfig = Omit<CloudPublicConfig, "observability"> & { | 73 | type TracingPublicConfig = Omit<CloudPublicConfig, "observability"> & { |
| 74 | readonly observability: Configured<CloudPublicConfig["observability"]>; | 74 | readonly observability: Configured<CloudPublicConfig["observability"]>; |
| 75 | }; | 75 | }; |
| 76 | 76 | ||
| 77 | export function hasMobileTracingPublicConfig( | 77 | export function hasTracingPublicConfig( |
| 78 | config: CloudPublicConfig = resolveCloudPublicConfig(), | 78 | config: CloudPublicConfig = resolveCloudPublicConfig(), |
| 79 | ): config is MobileTracingPublicConfig { | 79 | ): config is TracingPublicConfig { |
| 80 | return Boolean( | 80 | return Boolean( |
| 81 | config.observability.tracesUrl && | 81 | config.observability.tracesUrl && |
| 82 | config.observability.tracesDataset && | 82 | config.observability.tracesDataset && |
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+57-34| ... | @@ -1,31 +1,26 @@ | ... | @@ -1,31 +1,26 @@ |
| 1 | import { SymbolView } from "expo-symbols"; | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; | ||
| 3 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | import * as Cause from "effect/Cause"; | ||
| 6 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | import { useCallback, useState } from "react"; | 7 | import { useCallback, useState } from "react"; |
| 4 | import { Pressable, View } from "react-native"; | 8 | import { Alert, Pressable, View } from "react-native"; |
| 5 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | 9 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 6 | import { useThemeColor } from "../../lib/useThemeColor"; | 10 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 7 | 11 | ||
| 8 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 12 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 13 | import { cn } from "../../lib/cn"; | ||
| 14 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 9 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | 15 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 10 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; | 16 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| 11 | 17 | ||
| 12 | function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { | 18 | function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { |
| 13 | if (environment.connectionError) { | 19 | return connectionStatusText({ |
| 14 | return null; | 20 | phase: environment.connectionState, |
| 15 | } | 21 | error: environment.connectionError, |
| 16 | 22 | traceId: environment.connectionErrorTraceId, | |
| 17 | switch (environment.connectionState) { | 23 | }); |
| 18 | case "ready": | ||
| 19 | return "Connected"; | ||
| 20 | case "connecting": | ||
| 21 | return "Connecting"; | ||
| 22 | case "reconnecting": | ||
| 23 | return "Reconnecting"; | ||
| 24 | case "disconnected": | ||
| 25 | return null; | ||
| 26 | case "idle": | ||
| 27 | return null; | ||
| 28 | } | ||
| 29 | } | 24 | } |
| 30 | 25 | ||
| 31 | export function ConnectionEnvironmentRow(props: { | 26 | export function ConnectionEnvironmentRow(props: { |
| ... | @@ -37,7 +32,7 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -37,7 +32,7 @@ export function ConnectionEnvironmentRow(props: { |
| 37 | readonly onUpdate: ( | 32 | readonly onUpdate: ( |
| 38 | environmentId: EnvironmentId, | 33 | environmentId: EnvironmentId, |
| 39 | updates: { readonly label: string; readonly displayUrl: string }, | 34 | updates: { readonly label: string; readonly displayUrl: string }, |
| 40 | ) => void; | 35 | ) => Promise<AtomCommandResult<unknown, unknown>>; |
| 41 | }) { | 36 | }) { |
| 42 | const [label, setLabel] = useState(props.environment.environmentLabel); | 37 | const [label, setLabel] = useState(props.environment.environmentLabel); |
| 43 | const [url, setUrl] = useState(props.environment.displayUrl); | 38 | const [url, setUrl] = useState(props.environment.displayUrl); |
| ... | @@ -47,13 +42,25 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -47,13 +42,25 @@ export function ConnectionEnvironmentRow(props: { |
| 47 | const primaryFg = useThemeColor("--color-primary-foreground"); | 42 | const primaryFg = useThemeColor("--color-primary-foreground"); |
| 48 | const dangerFg = useThemeColor("--color-danger-foreground"); | 43 | const dangerFg = useThemeColor("--color-danger-foreground"); |
| 49 | const statusLabel = connectionStatusLabel(props.environment); | 44 | const statusLabel = connectionStatusLabel(props.environment); |
| 50 | 45 | const statusTraceId = props.environment.connectionErrorTraceId; | |
| 51 | const handleSave = useCallback(() => { | 46 | const hasConnectionFailure = props.environment.connectionError !== null; |
| 52 | props.onUpdate(props.environment.environmentId, { | 47 | const isRetrying = |
| 48 | props.environment.connectionState === "connecting" || | ||
| 49 | props.environment.connectionState === "reconnecting"; | ||
| 50 | const handleSave = useCallback(async () => { | ||
| 51 | const result = await props.onUpdate(props.environment.environmentId, { | ||
| 53 | label: label.trim(), | 52 | label: label.trim(), |
| 54 | displayUrl: url.trim(), | 53 | displayUrl: url.trim(), |
| 55 | }); | 54 | }); |
| 56 | props.onToggle(); | 55 | if (AsyncResult.isSuccess(result)) { |
| 56 | props.onToggle(); | ||
| 57 | return; | ||
| 58 | } | ||
| 59 | const error = Cause.squash(result.cause); | ||
| 60 | Alert.alert( | ||
| 61 | "Could not update environment", | ||
| 62 | error instanceof Error ? error.message : "The environment could not be updated.", | ||
| 63 | ); | ||
| 57 | }, [label, url, props]); | 64 | }, [label, url, props]); |
| 58 | 65 | ||
| 59 | return ( | 66 | return ( |
| ... | @@ -64,10 +71,7 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -64,10 +71,7 @@ export function ConnectionEnvironmentRow(props: { |
| 64 | > | 71 | > |
| 65 | <ConnectionStatusDot | 72 | <ConnectionStatusDot |
| 66 | state={props.environment.connectionState} | 73 | state={props.environment.connectionState} |
| 67 | pulse={ | 74 | pulse={isRetrying} |
| 68 | props.environment.connectionState === "connecting" || | ||
| 69 | props.environment.connectionState === "reconnecting" | ||
| 70 | } | ||
| 71 | size={8} | 75 | size={8} |
| 72 | /> | 76 | /> |
| 73 | 77 | ||
| ... | @@ -82,16 +86,35 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -82,16 +86,35 @@ export function ConnectionEnvironmentRow(props: { |
| 82 | {props.environment.displayUrl} | 86 | {props.environment.displayUrl} |
| 83 | </Text> | 87 | </Text> |
| 84 | {statusLabel ? ( | 88 | {statusLabel ? ( |
| 85 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}> | ||
| 86 | {statusLabel} | ||
| 87 | </Text> | ||
| 88 | ) : null} | ||
| 89 | {props.environment.connectionError ? ( | ||
| 90 | <Text | 89 | <Text |
| 91 | className="text-[12px] leading-[16px] text-rose-500 dark:text-rose-400" | 90 | className={cn( |
| 92 | numberOfLines={2} | 91 | "text-[12px] leading-[16px]", |
| 92 | hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted", | ||
| 93 | )} | ||
| 94 | numberOfLines={props.expanded ? undefined : 1} | ||
| 95 | selectable={props.expanded} | ||
| 93 | > | 96 | > |
| 94 | {props.environment.connectionError} | 97 | {statusLabel} |
| 98 | {statusTraceId ? ( | ||
| 99 | <> | ||
| 100 | {" Trace ID: "} | ||
| 101 | <Text | ||
| 102 | accessibilityHint="Copies the trace ID" | ||
| 103 | accessibilityRole="button" | ||
| 104 | className="underline" | ||
| 105 | onLongPress={(event) => { | ||
| 106 | event.stopPropagation(); | ||
| 107 | copyTextWithHaptic(statusTraceId); | ||
| 108 | }} | ||
| 109 | onPress={(event) => { | ||
| 110 | event.stopPropagation(); | ||
| 111 | }} | ||
| 112 | style={{ textDecorationStyle: "dotted" }} | ||
| 113 | > | ||
| 114 | {statusTraceId} | ||
| 115 | </Text> | ||
| 116 | </> | ||
| 117 | ) : null} | ||
| 95 | </Text> | 118 | </Text> |
| 96 | ) : null} | 119 | ) : null} |
| 97 | </View> | 120 | </View> |
apps/mobile/src/features/connection/ConnectionStatusDot.tsx+12-5| ... | @@ -11,12 +11,19 @@ import Animated, { | ... | @@ -11,12 +11,19 @@ import Animated, { |
| 11 | 11 | ||
| 12 | import type { RemoteClientConnectionState } from "../../lib/connection"; | 12 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 13 | 13 | ||
| 14 | function statusDotTone(state: RemoteClientConnectionState): { | 14 | export type ConnectionStatusDotState = RemoteClientConnectionState; |
| 15 | |||
| 16 | function statusDotTone(state: ConnectionStatusDotState): { | ||
| 15 | readonly dotColor: string; | 17 | readonly dotColor: string; |
| 16 | readonly haloColor: string; | 18 | readonly haloColor: string; |
| 17 | } { | 19 | } { |
| 18 | switch (state) { | 20 | switch (state) { |
| 19 | case "ready": | 21 | case "available": |
| 22 | return { | ||
| 23 | dotColor: "#9ca3af", | ||
| 24 | haloColor: "rgba(156,163,175,0.42)", | ||
| 25 | }; | ||
| 26 | case "connected": | ||
| 20 | return { | 27 | return { |
| 21 | dotColor: "#34d399", | 28 | dotColor: "#34d399", |
| 22 | haloColor: "rgba(52,211,153,0.48)", | 29 | haloColor: "rgba(52,211,153,0.48)", |
| ... | @@ -27,8 +34,8 @@ function statusDotTone(state: RemoteClientConnectionState): { | ... | @@ -27,8 +34,8 @@ function statusDotTone(state: RemoteClientConnectionState): { |
| 27 | dotColor: "#f59e0b", | 34 | dotColor: "#f59e0b", |
| 28 | haloColor: "rgba(245,158,11,0.5)", | 35 | haloColor: "rgba(245,158,11,0.5)", |
| 29 | }; | 36 | }; |
| 30 | case "idle": | 37 | case "offline": |
| 31 | case "disconnected": | 38 | case "error": |
| 32 | return { | 39 | return { |
| 33 | dotColor: "#ef4444", | 40 | dotColor: "#ef4444", |
| 34 | haloColor: "rgba(239,68,68,0.48)", | 41 | haloColor: "rgba(239,68,68,0.48)", |
| ... | @@ -63,7 +70,7 @@ function usePulseAnimation(pulse: boolean) { | ... | @@ -63,7 +70,7 @@ function usePulseAnimation(pulse: boolean) { |
| 63 | } | 70 | } |
| 64 | 71 | ||
| 65 | export function ConnectionStatusDot(props: { | 72 | export function ConnectionStatusDot(props: { |
| 66 | readonly state: RemoteClientConnectionState; | 73 | readonly state: ConnectionStatusDotState; |
| 67 | readonly pulse: boolean; | 74 | readonly pulse: boolean; |
| 68 | readonly size?: number; | 75 | readonly size?: number; |
| 69 | }) { | 76 | }) { |
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx created+108| ... | @@ -0,0 +1,108 @@ | ||
| 1 | import { | ||
| 2 | type EnvironmentConnectionPhase, | ||
| 3 | type EnvironmentConnectionPresentation, | ||
| 4 | } from "@t3tools/client-runtime/connection"; | ||
| 5 | import { SymbolView } from "expo-symbols"; | ||
| 6 | import { ActivityIndicator, Pressable, View } from "react-native"; | ||
| 7 | |||
| 8 | import { AppText as Text } from "../../components/AppText"; | ||
| 9 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 10 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 11 | |||
| 12 | function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string { | ||
| 13 | switch (phase) { | ||
| 14 | case "offline": | ||
| 15 | return "You are offline"; | ||
| 16 | case "connecting": | ||
| 17 | return `Connecting to ${environmentLabel}...`; | ||
| 18 | case "reconnecting": | ||
| 19 | return `Reconnecting to ${environmentLabel}...`; | ||
| 20 | case "error": | ||
| 21 | return `${environmentLabel} is unavailable`; | ||
| 22 | case "available": | ||
| 23 | return `${environmentLabel} is disconnected`; | ||
| 24 | case "connected": | ||
| 25 | return ""; | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | function noticeDetail( | ||
| 30 | phase: EnvironmentConnectionPhase, | ||
| 31 | resourceName: string, | ||
| 32 | error: string | null, | ||
| 33 | ): string { | ||
| 34 | if (error) { | ||
| 35 | return `The app will keep retrying automatically. ${error}`; | ||
| 36 | } | ||
| 37 | |||
| 38 | switch (phase) { | ||
| 39 | case "offline": | ||
| 40 | return `Cached data remains available. The ${resourceName} will load when your connection returns.`; | ||
| 41 | case "connecting": | ||
| 42 | case "reconnecting": | ||
| 43 | return `The ${resourceName} will load as soon as the environment is ready.`; | ||
| 44 | case "available": | ||
| 45 | case "error": | ||
| 46 | return `Reconnect the environment to load the ${resourceName}.`; | ||
| 47 | case "connected": | ||
| 48 | return ""; | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | export function EnvironmentConnectionNotice(props: { | ||
| 53 | readonly environmentLabel: string; | ||
| 54 | readonly connection: EnvironmentConnectionPresentation; | ||
| 55 | readonly resourceName: string; | ||
| 56 | readonly onRetry: () => void; | ||
| 57 | }) { | ||
| 58 | const iconColor = String(useThemeColor("--color-icon-muted")); | ||
| 59 | const isRetrying = | ||
| 60 | props.connection.phase === "connecting" || props.connection.phase === "reconnecting"; | ||
| 61 | |||
| 62 | return ( | ||
| 63 | <View className="flex-1 items-center justify-center px-8"> | ||
| 64 | <View className="max-w-[320px] items-center gap-3"> | ||
| 65 | {isRetrying ? ( | ||
| 66 | <ActivityIndicator size="small" color={iconColor} /> | ||
| 67 | ) : ( | ||
| 68 | <SymbolView | ||
| 69 | name={props.connection.phase === "offline" ? "wifi.slash" : "bolt.horizontal.circle"} | ||
| 70 | size={24} | ||
| 71 | tintColor={iconColor} | ||
| 72 | type="monochrome" | ||
| 73 | /> | ||
| 74 | )} | ||
| 75 | |||
| 76 | <Text className="text-center text-[17px] font-t3-bold leading-[22px] text-foreground"> | ||
| 77 | {noticeTitle(props.connection.phase, props.environmentLabel)} | ||
| 78 | </Text> | ||
| 79 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> | ||
| 80 | {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} | ||
| 81 | {props.connection.traceId ? ( | ||
| 82 | <> | ||
| 83 | {" Trace ID: "} | ||
| 84 | <Text | ||
| 85 | accessibilityHint="Copies the trace ID" | ||
| 86 | accessibilityRole="button" | ||
| 87 | className="underline decoration-dotted" | ||
| 88 | onPress={() => copyTextWithHaptic(props.connection.traceId!)} | ||
| 89 | > | ||
| 90 | {props.connection.traceId} | ||
| 91 | </Text> | ||
| 92 | </> | ||
| 93 | ) : null} | ||
| 94 | </Text> | ||
| 95 | |||
| 96 | {props.connection.phase !== "offline" ? ( | ||
| 97 | <Pressable | ||
| 98 | accessibilityRole="button" | ||
| 99 | className="mt-1 rounded-full bg-subtle px-4 py-2.5 active:opacity-70" | ||
| 100 | onPress={props.onRetry} | ||
| 101 | > | ||
| 102 | <Text className="text-[13px] font-t3-bold text-foreground">Retry now</Text> | ||
| 103 | </Pressable> | ||
| 104 | ) : null} | ||
| 105 | </View> | ||
| 106 | </View> | ||
| 107 | ); | ||
| 108 | } | ||
apps/mobile/src/features/connection/connectionTone.ts+11-5| ... | @@ -3,7 +3,7 @@ import type { RemoteClientConnectionState } from "../../lib/connection"; | ... | @@ -3,7 +3,7 @@ import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 3 | 3 | ||
| 4 | export function connectionTone(state: RemoteClientConnectionState): StatusTone { | 4 | export function connectionTone(state: RemoteClientConnectionState): StatusTone { |
| 5 | switch (state) { | 5 | switch (state) { |
| 6 | case "ready": | 6 | case "connected": |
| 7 | return { | 7 | return { |
| 8 | label: "Connected", | 8 | label: "Connected", |
| 9 | pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", | 9 | pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", |
| ... | @@ -21,15 +21,21 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { | ... | @@ -21,15 +21,21 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { |
| 21 | pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", | 21 | pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", |
| 22 | textClassName: "text-sky-700 dark:text-sky-300", | 22 | textClassName: "text-sky-700 dark:text-sky-300", |
| 23 | }; | 23 | }; |
| 24 | case "disconnected": | 24 | case "error": |
| 25 | return { | 25 | return { |
| 26 | label: "Disconnected", | 26 | label: "Connection failed", |
| 27 | pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", | 27 | pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", |
| 28 | textClassName: "text-rose-700 dark:text-rose-300", | 28 | textClassName: "text-rose-700 dark:text-rose-300", |
| 29 | }; | 29 | }; |
| 30 | case "idle": | 30 | case "offline": |
| 31 | return { | 31 | return { |
| 32 | label: "Idle", | 32 | label: "Offline", |
| 33 | pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", | ||
| 34 | textClassName: "text-rose-700 dark:text-rose-300", | ||
| 35 | }; | ||
| 36 | case "available": | ||
| 37 | return { | ||
| 38 | label: "Available", | ||
| 33 | pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", | 39 | pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", |
| 34 | textClassName: "text-neutral-600 dark:text-neutral-300", | 40 | textClassName: "text-neutral-600 dark:text-neutral-300", |
| 35 | }; | 41 | }; |
apps/mobile/src/features/connection/environmentSections.test.ts created+130| ... | @@ -0,0 +1,130 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | ||
| 5 | import { splitEnvironmentSections } from "./environmentSections"; | ||
| 6 | |||
| 7 | function connectedEnvironment( | ||
| 8 | input: Omit<Partial<ConnectedEnvironmentSummary>, "environmentId"> & { | ||
| 9 | readonly environmentId: string; | ||
| 10 | readonly isRelayManaged: boolean; | ||
| 11 | }, | ||
| 12 | ): ConnectedEnvironmentSummary { | ||
| 13 | return { | ||
| 14 | environmentId: EnvironmentId.make(input.environmentId), | ||
| 15 | environmentLabel: input.environmentLabel ?? input.environmentId, | ||
| 16 | displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`, | ||
| 17 | isRelayManaged: input.isRelayManaged, | ||
| 18 | connectionState: input.connectionState ?? "connected", | ||
| 19 | connectionError: input.connectionError ?? null, | ||
| 20 | connectionErrorTraceId: input.connectionErrorTraceId ?? null, | ||
| 21 | }; | ||
| 22 | } | ||
| 23 | |||
| 24 | function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { | ||
| 25 | return { | ||
| 26 | environmentId: EnvironmentId.make(environmentId), | ||
| 27 | label: environmentId, | ||
| 28 | endpoint: { | ||
| 29 | httpBaseUrl: `https://${environmentId}.cloud.example.test/`, | ||
| 30 | wsBaseUrl: `wss://${environmentId}.cloud.example.test/ws`, | ||
| 31 | providerKind: "cloudflare_tunnel", | ||
| 32 | }, | ||
| 33 | linkedAt: "2026-01-01T00:00:00.000Z", | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | |||
| 37 | describe("mobile environment settings sections", () => { | ||
| 38 | it("keeps saved relay-managed connections under T3 Cloud", () => { | ||
| 39 | const local = connectedEnvironment({ | ||
| 40 | environmentId: "environment-local", | ||
| 41 | isRelayManaged: false, | ||
| 42 | }); | ||
| 43 | const cloud = connectedEnvironment({ | ||
| 44 | environmentId: "environment-cloud", | ||
| 45 | isRelayManaged: true, | ||
| 46 | }); | ||
| 47 | |||
| 48 | const sections = splitEnvironmentSections({ | ||
| 49 | connectedEnvironments: [cloud, local], | ||
| 50 | cloudEnvironments: [ | ||
| 51 | cloudEnvironment("environment-cloud"), | ||
| 52 | cloudEnvironment("environment-new"), | ||
| 53 | ], | ||
| 54 | }); | ||
| 55 | |||
| 56 | expect(sections.localEnvironments).toEqual([local]); | ||
| 57 | expect(sections.connectedCloudEnvironments).toEqual([cloud]); | ||
| 58 | expect( | ||
| 59 | sections.availableCloudEnvironments.map((environment) => environment.environmentId), | ||
| 60 | ).toEqual([EnvironmentId.make("environment-new")]); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("keeps saved relay-managed connections visible when cloud listing is unavailable", () => { | ||
| 64 | const cloud = connectedEnvironment({ | ||
| 65 | environmentId: "environment-cloud", | ||
| 66 | isRelayManaged: true, | ||
| 67 | connectionState: "reconnecting", | ||
| 68 | connectionError: "Environment did not respond before the connection timeout.", | ||
| 69 | }); | ||
| 70 | |||
| 71 | const sections = splitEnvironmentSections({ | ||
| 72 | connectedEnvironments: [cloud], | ||
| 73 | cloudEnvironments: null, | ||
| 74 | }); | ||
| 75 | |||
| 76 | expect(sections.localEnvironments).toEqual([]); | ||
| 77 | expect(sections.connectedCloudEnvironments).toEqual([cloud]); | ||
| 78 | expect(sections.availableCloudEnvironments).toEqual([]); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("keeps an available saved relay environment as a fallback when listing is unavailable", () => { | ||
| 82 | const cloud = connectedEnvironment({ | ||
| 83 | environmentId: "environment-cloud", | ||
| 84 | isRelayManaged: true, | ||
| 85 | connectionState: "available", | ||
| 86 | }); | ||
| 87 | |||
| 88 | const sections = splitEnvironmentSections({ | ||
| 89 | connectedEnvironments: [cloud], | ||
| 90 | cloudEnvironments: null, | ||
| 91 | }); | ||
| 92 | |||
| 93 | expect(sections.connectedCloudEnvironments).toEqual([cloud]); | ||
| 94 | expect(sections.availableCloudEnvironments).toEqual([]); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("does not duplicate a saved relay environment in the available cloud listing", () => { | ||
| 98 | const cloud = connectedEnvironment({ | ||
| 99 | environmentId: "environment-cloud", | ||
| 100 | isRelayManaged: true, | ||
| 101 | connectionState: "available", | ||
| 102 | }); | ||
| 103 | const listedCloud = cloudEnvironment("environment-cloud"); | ||
| 104 | |||
| 105 | const sections = splitEnvironmentSections({ | ||
| 106 | connectedEnvironments: [cloud], | ||
| 107 | cloudEnvironments: [listedCloud], | ||
| 108 | }); | ||
| 109 | |||
| 110 | expect(sections.connectedCloudEnvironments).toEqual([cloud]); | ||
| 111 | expect(sections.availableCloudEnvironments).toEqual([]); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("keeps failed relay environments in the local connection row", () => { | ||
| 115 | const cloud = connectedEnvironment({ | ||
| 116 | environmentId: "environment-cloud", | ||
| 117 | isRelayManaged: true, | ||
| 118 | connectionState: "error", | ||
| 119 | connectionError: "Connection failed.", | ||
| 120 | }); | ||
| 121 | |||
| 122 | const sections = splitEnvironmentSections({ | ||
| 123 | connectedEnvironments: [cloud], | ||
| 124 | cloudEnvironments: [cloudEnvironment("environment-cloud")], | ||
| 125 | }); | ||
| 126 | |||
| 127 | expect(sections.connectedCloudEnvironments).toEqual([cloud]); | ||
| 128 | expect(sections.availableCloudEnvironments).toEqual([]); | ||
| 129 | }); | ||
| 130 | }); | ||
apps/mobile/src/features/connection/environmentSections.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | ||
| 2 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | ||
| 3 | |||
| 4 | export interface EnvironmentSectionsInput { | ||
| 5 | readonly connectedEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; | ||
| 6 | readonly cloudEnvironments: ReadonlyArray<RelayClientEnvironmentRecord> | null; | ||
| 7 | } | ||
| 8 | |||
| 9 | export interface EnvironmentSections { | ||
| 10 | readonly localEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; | ||
| 11 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; | ||
| 12 | readonly availableCloudEnvironments: ReadonlyArray<RelayClientEnvironmentRecord>; | ||
| 13 | } | ||
| 14 | |||
| 15 | export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { | ||
| 16 | const savedEnvironmentIds = new Set( | ||
| 17 | input.connectedEnvironments.map((environment) => environment.environmentId), | ||
| 18 | ); | ||
| 19 | |||
| 20 | return { | ||
| 21 | localEnvironments: input.connectedEnvironments.filter( | ||
| 22 | (environment) => !environment.isRelayManaged, | ||
| 23 | ), | ||
| 24 | connectedCloudEnvironments: input.connectedEnvironments.filter( | ||
| 25 | (environment) => environment.isRelayManaged, | ||
| 26 | ), | ||
| 27 | availableCloudEnvironments: (input.cloudEnvironments ?? []).filter( | ||
| 28 | (environment) => !savedEnvironmentIds.has(environment.environmentId), | ||
| 29 | ), | ||
| 30 | }; | ||
| 31 | } | ||
apps/mobile/src/features/connection/useConnectionController.ts created+125| ... | @@ -0,0 +1,125 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | RelayConnectionRegistration, | ||
| 4 | RelayConnectionTarget, | ||
| 5 | } from "@t3tools/client-runtime/connection"; | ||
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 7 | import type { | ||
| 8 | RelayClientEnvironmentRecord, | ||
| 9 | RelayEnvironmentStatusResponse, | ||
| 10 | } from "@t3tools/contracts/relay"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import { useCallback, useMemo } from "react"; | ||
| 13 | |||
| 14 | import { environmentCatalog } from "../../connection/catalog"; | ||
| 15 | import { | ||
| 16 | connectPairingUrl as connectPairingUrlAtom, | ||
| 17 | updateBearerConnection, | ||
| 18 | } from "../../connection/onboarding"; | ||
| 19 | import { useEnvironments } from "../../state/environments"; | ||
| 20 | import { relayEnvironmentDiscovery } from "../../state/relay"; | ||
| 21 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 22 | import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; | ||
| 23 | |||
| 24 | export interface RelayEnvironmentView { | ||
| 25 | readonly environment: RelayClientEnvironmentRecord; | ||
| 26 | readonly availability: "checking" | "online" | "offline" | "error"; | ||
| 27 | readonly status: RelayEnvironmentStatusResponse | null; | ||
| 28 | readonly error: string | null; | ||
| 29 | readonly traceId: string | null; | ||
| 30 | } | ||
| 31 | |||
| 32 | export function useConnectionController() { | ||
| 33 | const { environments } = useEnvironments(); | ||
| 34 | const discovery = useAtomValue(relayEnvironmentDiscovery.stateValueAtom); | ||
| 35 | const connectPairingUrlMutation = useAtomCommand(connectPairingUrlAtom, { | ||
| 36 | reportFailure: false, | ||
| 37 | }); | ||
| 38 | const updateBearer = useAtomCommand(updateBearerConnection, { reportFailure: false }); | ||
| 39 | const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register"); | ||
| 40 | const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove"); | ||
| 41 | const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry"); | ||
| 42 | const refreshRelayEnvironments = useAtomCommand( | ||
| 43 | relayEnvironmentDiscovery.refresh, | ||
| 44 | "relay environment refresh", | ||
| 45 | ); | ||
| 46 | |||
| 47 | const connectedEnvironments = useMemo<ReadonlyArray<WorkspaceEnvironment>>( | ||
| 48 | () => environments.map(projectWorkspaceEnvironment), | ||
| 49 | [environments], | ||
| 50 | ); | ||
| 51 | const registeredIds = useMemo( | ||
| 52 | () => new Set(connectedEnvironments.map((environment) => environment.environmentId)), | ||
| 53 | [connectedEnvironments], | ||
| 54 | ); | ||
| 55 | const relayEnvironments = useMemo<ReadonlyArray<RelayEnvironmentView>>( | ||
| 56 | () => | ||
| 57 | [...discovery.environments.values()].map((entry) => ({ | ||
| 58 | environment: entry.environment, | ||
| 59 | availability: entry.availability, | ||
| 60 | status: Option.getOrNull(entry.status), | ||
| 61 | error: Option.getOrNull(entry.error)?.message ?? null, | ||
| 62 | traceId: Option.getOrNull(entry.error)?.traceId ?? null, | ||
| 63 | })), | ||
| 64 | [discovery.environments], | ||
| 65 | ); | ||
| 66 | const availableRelayEnvironments = useMemo( | ||
| 67 | () => relayEnvironments.filter((entry) => !registeredIds.has(entry.environment.environmentId)), | ||
| 68 | [registeredIds, relayEnvironments], | ||
| 69 | ); | ||
| 70 | |||
| 71 | const connectPairingUrl = useCallback( | ||
| 72 | (pairingUrl: string) => connectPairingUrlMutation(pairingUrl), | ||
| 73 | [connectPairingUrlMutation], | ||
| 74 | ); | ||
| 75 | const connectRelayEnvironment = useCallback( | ||
| 76 | (environment: RelayClientEnvironmentRecord) => | ||
| 77 | registerEnvironment( | ||
| 78 | new RelayConnectionRegistration({ | ||
| 79 | target: new RelayConnectionTarget({ | ||
| 80 | environmentId: environment.environmentId, | ||
| 81 | label: environment.label, | ||
| 82 | }), | ||
| 83 | }), | ||
| 84 | ), | ||
| 85 | [registerEnvironment], | ||
| 86 | ); | ||
| 87 | const removeEnvironment = useCallback( | ||
| 88 | (environmentId: EnvironmentId) => removeEnvironmentMutation(environmentId), | ||
| 89 | [removeEnvironmentMutation], | ||
| 90 | ); | ||
| 91 | const retryEnvironment = useCallback( | ||
| 92 | (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId), | ||
| 93 | [retryEnvironmentMutation], | ||
| 94 | ); | ||
| 95 | const updateEnvironment = useCallback( | ||
| 96 | ( | ||
| 97 | environmentId: EnvironmentId, | ||
| 98 | updates: { readonly label: string; readonly displayUrl: string }, | ||
| 99 | ) => | ||
| 100 | updateBearer({ | ||
| 101 | environmentId, | ||
| 102 | label: updates.label, | ||
| 103 | httpBaseUrl: updates.displayUrl, | ||
| 104 | }), | ||
| 105 | [updateBearer], | ||
| 106 | ); | ||
| 107 | |||
| 108 | return { | ||
| 109 | connectedEnvironments, | ||
| 110 | relayEnvironments, | ||
| 111 | availableRelayEnvironments, | ||
| 112 | relayDiscovery: { | ||
| 113 | isRefreshing: discovery.refreshing, | ||
| 114 | isOffline: discovery.offline, | ||
| 115 | error: Option.getOrNull(discovery.error)?.message ?? null, | ||
| 116 | errorTraceId: Option.getOrNull(discovery.error)?.traceId ?? null, | ||
| 117 | }, | ||
| 118 | connectPairingUrl, | ||
| 119 | connectRelayEnvironment, | ||
| 120 | removeEnvironment, | ||
| 121 | retryEnvironment, | ||
| 122 | updateEnvironment, | ||
| 123 | refreshRelayEnvironments, | ||
| 124 | }; | ||
| 125 | } | ||
apps/mobile/src/features/home/HomeScreen.tsx+167-122| ... | @@ -1,11 +1,11 @@ | ... | @@ -1,11 +1,11 @@ |
| 1 | import type { | 1 | import { |
| 2 | EnvironmentScopedProjectShell, | 2 | type EnvironmentProject, |
| 3 | EnvironmentScopedThreadShell, | 3 | type EnvironmentThreadShell, |
| 4 | VcsStatusState, | 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import { SymbolView } from "expo-symbols"; | 5 | import { SymbolView } from "expo-symbols"; |
| 7 | import { useCallback, useMemo, useState } from "react"; | 6 | import { useCallback, useMemo, useState } from "react"; |
| 8 | import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; | 7 | import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; |
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 9 | import * as Arr from "effect/Array"; | 9 | import * as Arr from "effect/Array"; |
| 10 | import * as Order from "effect/Order"; | 10 | import * as Order from "effect/Order"; |
| 11 | import { useThemeColor } from "../../lib/useThemeColor"; | 11 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | @@ -13,29 +13,29 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -13,29 +13,29 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 13 | import { AppText as Text } from "../../components/AppText"; | 13 | import { AppText as Text } from "../../components/AppText"; |
| 14 | import { EmptyState } from "../../components/EmptyState"; | 14 | import { EmptyState } from "../../components/EmptyState"; |
| 15 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 15 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 16 | import type { WorkspaceState } from "../../state/workspaceModel"; | ||
| 16 | import type { SavedRemoteConnection } from "../../lib/connection"; | 17 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 17 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 18 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 18 | import { relativeTime } from "../../lib/time"; | 19 | import { relativeTime } from "../../lib/time"; |
| 19 | import type { RemoteCatalogState } from "../../state/use-remote-catalog"; | ||
| 20 | import { useVcsStatus } from "../../state/use-vcs-status"; | ||
| 21 | import { threadStatusTone } from "../threads/threadPresentation"; | 20 | import { threadStatusTone } from "../threads/threadPresentation"; |
| 22 | 21 | ||
| 23 | /* ─── Types ──────────────────────────────────────────────────────────── */ | 22 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 24 | 23 | ||
| 25 | interface HomeScreenProps { | 24 | interface HomeScreenProps { |
| 26 | readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>; | 25 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 27 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | 26 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 28 | readonly catalogState: RemoteCatalogState; | 27 | readonly catalogState: WorkspaceState; |
| 29 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; | 28 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; |
| 30 | readonly searchQuery: string; | 29 | readonly searchQuery: string; |
| 31 | readonly onAddConnection: () => void; | 30 | readonly onAddConnection: () => void; |
| 32 | readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; | 31 | readonly onOpenEnvironments: () => void; |
| 32 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | ||
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | interface ProjectGroup { | 35 | interface ProjectGroup { |
| 36 | readonly key: string; | 36 | readonly key: string; |
| 37 | readonly project: EnvironmentScopedProjectShell; | 37 | readonly project: EnvironmentProject; |
| 38 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | 38 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 39 | } | 39 | } |
| 40 | 40 | ||
| 41 | const projectGroupActivityOrder = Order.mapInput( | 41 | const projectGroupActivityOrder = Order.mapInput( |
| ... | @@ -49,7 +49,7 @@ const projectGroupActivityOrder = Order.mapInput( | ... | @@ -49,7 +49,7 @@ const projectGroupActivityOrder = Order.mapInput( |
| 49 | 49 | ||
| 50 | /* ─── Status indicator colors ────────────────────────────────────────── */ | 50 | /* ─── Status indicator colors ────────────────────────────────────────── */ |
| 51 | 51 | ||
| 52 | function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: string } { | 52 | function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { |
| 53 | switch (thread.session?.status) { | 53 | switch (thread.session?.status) { |
| 54 | case "running": | 54 | case "running": |
| 55 | return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; | 55 | return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; |
| ... | @@ -67,11 +67,11 @@ function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: s | ... | @@ -67,11 +67,11 @@ function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: s |
| 67 | const COLLAPSED_THREAD_LIMIT = 6; | 67 | const COLLAPSED_THREAD_LIMIT = 6; |
| 68 | 68 | ||
| 69 | function deriveEmptyState(props: { | 69 | function deriveEmptyState(props: { |
| 70 | readonly catalogState: RemoteCatalogState; | 70 | readonly catalogState: WorkspaceState; |
| 71 | readonly projectCount: number; | 71 | readonly projectCount: number; |
| 72 | }): { readonly title: string; readonly detail: string; readonly loading: boolean } { | 72 | }): { readonly title: string; readonly detail: string; readonly loading: boolean } { |
| 73 | const { catalogState } = props; | 73 | const { catalogState } = props; |
| 74 | if (catalogState.isLoadingSavedConnections) { | 74 | if (catalogState.isLoadingConnections) { |
| 75 | return { | 75 | return { |
| 76 | title: "Loading environments", | 76 | title: "Loading environments", |
| 77 | detail: "Checking saved environments on this device.", | 77 | detail: "Checking saved environments on this device.", |
| ... | @@ -79,7 +79,7 @@ function deriveEmptyState(props: { | ... | @@ -79,7 +79,7 @@ function deriveEmptyState(props: { |
| 79 | }; | 79 | }; |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | if (!catalogState.hasSavedConnections) { | 82 | if (!catalogState.hasConnections) { |
| 83 | return { | 83 | return { |
| 84 | title: "No environments connected", | 84 | title: "No environments connected", |
| 85 | detail: "Add an environment to load projects and start coding sessions.", | 85 | detail: "Add an environment to load projects and start coding sessions.", |
| ... | @@ -87,7 +87,12 @@ function deriveEmptyState(props: { | ... | @@ -87,7 +87,12 @@ function deriveEmptyState(props: { |
| 87 | }; | 87 | }; |
| 88 | } | 88 | } |
| 89 | 89 | ||
| 90 | if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { | 90 | if ( |
| 91 | (catalogState.connectionState === "available" || | ||
| 92 | catalogState.connectionState === "offline" || | ||
| 93 | catalogState.connectionState === "error") && | ||
| 94 | !catalogState.hasLoadedShellSnapshot | ||
| 95 | ) { | ||
| 91 | return { | 96 | return { |
| 92 | title: "Environment unavailable", | 97 | title: "Environment unavailable", |
| 93 | detail: | 98 | detail: |
| ... | @@ -127,10 +132,8 @@ function deriveEmptyState(props: { | ... | @@ -127,10 +132,8 @@ function deriveEmptyState(props: { |
| 127 | /* ─── Project group header ───────────────────────────────────────────── */ | 132 | /* ─── Project group header ───────────────────────────────────────────── */ |
| 128 | 133 | ||
| 129 | function ProjectGroupLabel(props: { | 134 | function ProjectGroupLabel(props: { |
| 130 | readonly project: EnvironmentScopedProjectShell; | 135 | readonly project: EnvironmentProject; |
| 131 | readonly totalThreadCount: number; | 136 | readonly totalThreadCount: number; |
| 132 | readonly httpBaseUrl: string | null; | ||
| 133 | readonly bearerToken: string | null; | ||
| 134 | readonly isExpanded: boolean; | 137 | readonly isExpanded: boolean; |
| 135 | readonly onToggleExpand: () => void; | 138 | readonly onToggleExpand: () => void; |
| 136 | }) { | 139 | }) { |
| ... | @@ -139,11 +142,10 @@ function ProjectGroupLabel(props: { | ... | @@ -139,11 +142,10 @@ function ProjectGroupLabel(props: { |
| 139 | return ( | 142 | return ( |
| 140 | <View className="flex-row items-center gap-2.5 px-1 pb-2"> | 143 | <View className="flex-row items-center gap-2.5 px-1 pb-2"> |
| 141 | <ProjectFavicon | 144 | <ProjectFavicon |
| 145 | environmentId={props.project.environmentId} | ||
| 142 | size={18} | 146 | size={18} |
| 143 | projectTitle={props.project.title} | 147 | projectTitle={props.project.title} |
| 144 | httpBaseUrl={props.httpBaseUrl} | ||
| 145 | workspaceRoot={props.project.workspaceRoot} | 148 | workspaceRoot={props.project.workspaceRoot} |
| 146 | bearerToken={props.bearerToken} | ||
| 147 | /> | 149 | /> |
| 148 | <Text | 150 | <Text |
| 149 | className="flex-1 text-[12px] font-t3-medium uppercase text-foreground-muted" | 151 | className="flex-1 text-[12px] font-t3-medium uppercase text-foreground-muted" |
| ... | @@ -167,26 +169,11 @@ function ProjectGroupLabel(props: { | ... | @@ -167,26 +169,11 @@ function ProjectGroupLabel(props: { |
| 167 | ); | 169 | ); |
| 168 | } | 170 | } |
| 169 | 171 | ||
| 170 | /* ─── Git summary line ──────────────────────────────────────────────── */ | ||
| 171 | |||
| 172 | function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray<string> { | ||
| 173 | if (!gitStatus.data) return []; | ||
| 174 | const { data } = gitStatus; | ||
| 175 | const parts: string[] = []; | ||
| 176 | if (data.hasWorkingTreeChanges) { | ||
| 177 | parts.push(`${data.workingTree.files.length} changed`); | ||
| 178 | } | ||
| 179 | if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`); | ||
| 180 | if (data.behindCount > 0) parts.push(`${data.behindCount} behind`); | ||
| 181 | if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`); | ||
| 182 | return parts; | ||
| 183 | } | ||
| 184 | |||
| 185 | /* ─── Thread row ─────────────────────────────────────────────────────── */ | 172 | /* ─── Thread row ─────────────────────────────────────────────────────── */ |
| 186 | 173 | ||
| 187 | function ThreadRow(props: { | 174 | function ThreadRow(props: { |
| 188 | readonly thread: EnvironmentScopedThreadShell; | 175 | readonly thread: EnvironmentThreadShell; |
| 189 | readonly projectCwd: string | null; | 176 | readonly environmentLabel: string | null; |
| 190 | readonly onPress: () => void; | 177 | readonly onPress: () => void; |
| 191 | readonly isLast: boolean; | 178 | readonly isLast: boolean; |
| 192 | }) { | 179 | }) { |
| ... | @@ -196,15 +183,9 @@ function ThreadRow(props: { | ... | @@ -196,15 +183,9 @@ function ThreadRow(props: { |
| 196 | const tone = threadStatusTone(props.thread); | 183 | const tone = threadStatusTone(props.thread); |
| 197 | const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); | 184 | const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); |
| 198 | const branch = props.thread.branch; | 185 | const branch = props.thread.branch; |
| 199 | 186 | const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => | |
| 200 | // Subscribe to live git status — only when thread has a branch set. | 187 | Boolean(part), |
| 201 | // Threads sharing the same cwd share one WS subscription via ref-counting. | 188 | ); |
| 202 | const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null; | ||
| 203 | const gitStatus = useVcsStatus({ | ||
| 204 | environmentId: cwd ? props.thread.environmentId : null, | ||
| 205 | cwd, | ||
| 206 | }); | ||
| 207 | const gitParts = gitSummaryParts(gitStatus); | ||
| 208 | 189 | ||
| 209 | return ( | 190 | return ( |
| 210 | <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> | 191 | <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> |
| ... | @@ -262,8 +243,8 @@ function ThreadRow(props: { | ... | @@ -262,8 +243,8 @@ function ThreadRow(props: { |
| 262 | </View> | 243 | </View> |
| 263 | </View> | 244 | </View> |
| 264 | 245 | ||
| 265 | {/* Branch + git info */} | 246 | {/* Environment + branch */} |
| 266 | {branch ? ( | 247 | {subtitleParts.length > 0 ? ( |
| 267 | <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> | 248 | <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> |
| 268 | <SymbolView | 249 | <SymbolView |
| 269 | name="arrow.triangle.branch" | 250 | name="arrow.triangle.branch" |
| ... | @@ -276,13 +257,8 @@ function ThreadRow(props: { | ... | @@ -276,13 +257,8 @@ function ThreadRow(props: { |
| 276 | numberOfLines={1} | 257 | numberOfLines={1} |
| 277 | style={{ fontFamily: "monospace" }} | 258 | style={{ fontFamily: "monospace" }} |
| 278 | > | 259 | > |
| 279 | {branch} | 260 | {subtitleParts.join(" · ")} |
| 280 | </Text> | 261 | </Text> |
| 281 | {gitParts.length > 0 ? ( | ||
| 282 | <Text className="text-[11px] text-foreground-tertiary"> | ||
| 283 | {" · " + gitParts.join(" · ")} | ||
| 284 | </Text> | ||
| 285 | ) : null} | ||
| 286 | </View> | 262 | </View> |
| 287 | ) : null} | 263 | ) : null} |
| 288 | </View> | 264 | </View> |
| ... | @@ -293,8 +269,61 @@ function ThreadRow(props: { | ... | @@ -293,8 +269,61 @@ function ThreadRow(props: { |
| 293 | 269 | ||
| 294 | /* ─── Main screen ────────────────────────────────────────────────────── */ | 270 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 295 | 271 | ||
| 272 | function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string { | ||
| 273 | if (props.catalogState.networkStatus === "offline") { | ||
| 274 | return "You are offline"; | ||
| 275 | } | ||
| 276 | const connectingEnvironments = props.catalogState.connectingEnvironments; | ||
| 277 | if (connectingEnvironments.length === 1) { | ||
| 278 | return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`; | ||
| 279 | } | ||
| 280 | if (connectingEnvironments.length > 1) { | ||
| 281 | return `Reconnecting ${connectingEnvironments.length} environments`; | ||
| 282 | } | ||
| 283 | return "Not connected"; | ||
| 284 | } | ||
| 285 | |||
| 286 | function StaleCatalogStatusPill(props: { | ||
| 287 | readonly catalogState: WorkspaceState; | ||
| 288 | readonly onPress: () => void; | ||
| 289 | }) { | ||
| 290 | const iconColor = useThemeColor("--color-icon-muted"); | ||
| 291 | const label = staleCatalogPillLabel(props); | ||
| 292 | const isReconnecting = props.catalogState.connectingEnvironments.length > 0; | ||
| 293 | |||
| 294 | return ( | ||
| 295 | <Pressable | ||
| 296 | accessibilityRole="button" | ||
| 297 | onPress={props.onPress} | ||
| 298 | className="flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5" | ||
| 299 | style={{ | ||
| 300 | shadowColor: "#000", | ||
| 301 | shadowOffset: { width: 0, height: 10 }, | ||
| 302 | shadowOpacity: 0.12, | ||
| 303 | shadowRadius: 24, | ||
| 304 | }} | ||
| 305 | > | ||
| 306 | {isReconnecting ? ( | ||
| 307 | <ActivityIndicator color={iconColor} size="small" /> | ||
| 308 | ) : ( | ||
| 309 | <SymbolView | ||
| 310 | name="wifi.slash" | ||
| 311 | size={15} | ||
| 312 | tintColor={iconColor} | ||
| 313 | type="monochrome" | ||
| 314 | weight="semibold" | ||
| 315 | /> | ||
| 316 | )} | ||
| 317 | <Text className="max-w-[260px] text-[13px] font-t3-bold text-foreground" numberOfLines={1}> | ||
| 318 | {label} | ||
| 319 | </Text> | ||
| 320 | </Pressable> | ||
| 321 | ); | ||
| 322 | } | ||
| 323 | |||
| 296 | export function HomeScreen(props: HomeScreenProps) { | 324 | export function HomeScreen(props: HomeScreenProps) { |
| 297 | const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set()); | 325 | const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set()); |
| 326 | const insets = useSafeAreaInsets(); | ||
| 298 | const accentColor = useThemeColor("--color-icon-muted"); | 327 | const accentColor = useThemeColor("--color-icon-muted"); |
| 299 | 328 | ||
| 300 | const toggleExpanded = useCallback((key: string) => { | 329 | const toggleExpanded = useCallback((key: string) => { |
| ... | @@ -328,7 +357,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -328,7 +357,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 328 | 357 | ||
| 329 | /* Group filtered threads by project */ | 358 | /* Group filtered threads by project */ |
| 330 | const projectGroups = useMemo<ReadonlyArray<ProjectGroup>>(() => { | 359 | const projectGroups = useMemo<ReadonlyArray<ProjectGroup>>(() => { |
| 331 | const byProject = new Map<string, EnvironmentScopedThreadShell[]>(); | 360 | const byProject = new Map<string, EnvironmentThreadShell[]>(); |
| 332 | for (const thread of filteredThreads) { | 361 | for (const thread of filteredThreads) { |
| 333 | const key = scopedProjectKey(thread.environmentId, thread.projectId); | 362 | const key = scopedProjectKey(thread.environmentId, thread.projectId); |
| 334 | const existing = byProject.get(key); | 363 | const existing = byProject.get(key); |
| ... | @@ -351,77 +380,93 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -351,77 +380,93 @@ export function HomeScreen(props: HomeScreenProps) { |
| 351 | /* Empty states */ | 380 | /* Empty states */ |
| 352 | const hasAnyThreads = props.threads.length > 0; | 381 | const hasAnyThreads = props.threads.length > 0; |
| 353 | const hasResults = filteredThreads.length > 0; | 382 | const hasResults = filteredThreads.length > 0; |
| 383 | const shouldShowConnectionStatus = | ||
| 384 | props.catalogState.networkStatus === "offline" || | ||
| 385 | props.catalogState.hasConnectingEnvironment || | ||
| 386 | (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment); | ||
| 354 | const emptyState = deriveEmptyState({ | 387 | const emptyState = deriveEmptyState({ |
| 355 | catalogState: props.catalogState, | 388 | catalogState: props.catalogState, |
| 356 | projectCount: props.projects.length, | 389 | projectCount: props.projects.length, |
| 357 | }); | 390 | }); |
| 358 | 391 | ||
| 359 | return ( | 392 | return ( |
| 360 | <ScrollView | 393 | <View className="flex-1 bg-screen"> |
| 361 | contentInsetAdjustmentBehavior="automatic" | 394 | <ScrollView |
| 362 | showsVerticalScrollIndicator={false} | 395 | contentInsetAdjustmentBehavior="automatic" |
| 363 | keyboardDismissMode="on-drag" | 396 | showsVerticalScrollIndicator={false} |
| 364 | keyboardShouldPersistTaps="handled" | 397 | keyboardDismissMode="on-drag" |
| 365 | className="flex-1 bg-screen" | 398 | keyboardShouldPersistTaps="handled" |
| 366 | contentContainerStyle={{ | 399 | className="flex-1" |
| 367 | paddingHorizontal: 16, | 400 | contentContainerStyle={{ |
| 368 | paddingTop: 8, | 401 | paddingHorizontal: 16, |
| 369 | paddingBottom: 24, | 402 | paddingTop: 8, |
| 370 | gap: 20, | 403 | paddingBottom: 24, |
| 371 | }} | 404 | gap: 20, |
| 372 | > | 405 | }} |
| 373 | {!hasAnyThreads ? ( | 406 | > |
| 374 | <View> | 407 | {!hasAnyThreads ? ( |
| 375 | <EmptyState | 408 | <View> |
| 376 | title={emptyState.title} | 409 | <EmptyState |
| 377 | detail={emptyState.detail} | 410 | title={emptyState.title} |
| 378 | actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} | 411 | detail={emptyState.detail} |
| 379 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} | 412 | actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} |
| 413 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} | ||
| 414 | /> | ||
| 415 | {emptyState.loading ? ( | ||
| 416 | <View className="absolute right-5 top-5"> | ||
| 417 | <ActivityIndicator color={accentColor} /> | ||
| 418 | </View> | ||
| 419 | ) : null} | ||
| 420 | </View> | ||
| 421 | ) : !hasResults ? ( | ||
| 422 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | ||
| 423 | ) : ( | ||
| 424 | projectGroups.map((group) => { | ||
| 425 | const isExpanded = expandedProjects.has(group.key); | ||
| 426 | const visibleThreads = isExpanded | ||
| 427 | ? group.threads | ||
| 428 | : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); | ||
| 429 | |||
| 430 | return ( | ||
| 431 | <View key={group.key}> | ||
| 432 | <ProjectGroupLabel | ||
| 433 | project={group.project} | ||
| 434 | totalThreadCount={group.threads.length} | ||
| 435 | isExpanded={isExpanded} | ||
| 436 | onToggleExpand={() => toggleExpanded(group.key)} | ||
| 437 | /> | ||
| 438 | <View | ||
| 439 | className="overflow-hidden rounded-[20px] bg-card" | ||
| 440 | style={{ borderCurve: "continuous" }} | ||
| 441 | > | ||
| 442 | {visibleThreads.map((thread, i) => ( | ||
| 443 | <ThreadRow | ||
| 444 | key={`${thread.environmentId}:${thread.id}`} | ||
| 445 | thread={thread} | ||
| 446 | environmentLabel={ | ||
| 447 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null | ||
| 448 | } | ||
| 449 | onPress={() => props.onSelectThread(thread)} | ||
| 450 | isLast={i === visibleThreads.length - 1} | ||
| 451 | /> | ||
| 452 | ))} | ||
| 453 | </View> | ||
| 454 | </View> | ||
| 455 | ); | ||
| 456 | }) | ||
| 457 | )} | ||
| 458 | </ScrollView> | ||
| 459 | {shouldShowConnectionStatus ? ( | ||
| 460 | <View | ||
| 461 | className="absolute left-0 right-0 items-center" | ||
| 462 | style={{ bottom: Math.max(insets.bottom, 18) + 76 }} | ||
| 463 | > | ||
| 464 | <StaleCatalogStatusPill | ||
| 465 | catalogState={props.catalogState} | ||
| 466 | onPress={props.onOpenEnvironments} | ||
| 380 | /> | 467 | /> |
| 381 | {emptyState.loading ? ( | ||
| 382 | <View className="absolute right-5 top-5"> | ||
| 383 | <ActivityIndicator color={accentColor} /> | ||
| 384 | </View> | ||
| 385 | ) : null} | ||
| 386 | </View> | 468 | </View> |
| 387 | ) : !hasResults ? ( | 469 | ) : null} |
| 388 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | 470 | </View> |
| 389 | ) : ( | ||
| 390 | projectGroups.map((group) => { | ||
| 391 | const connection = props.savedConnectionsById[group.project.environmentId]; | ||
| 392 | const isExpanded = expandedProjects.has(group.key); | ||
| 393 | const visibleThreads = isExpanded | ||
| 394 | ? group.threads | ||
| 395 | : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); | ||
| 396 | |||
| 397 | return ( | ||
| 398 | <View key={group.key}> | ||
| 399 | <ProjectGroupLabel | ||
| 400 | project={group.project} | ||
| 401 | totalThreadCount={group.threads.length} | ||
| 402 | httpBaseUrl={connection?.httpBaseUrl ?? null} | ||
| 403 | bearerToken={connection?.bearerToken ?? null} | ||
| 404 | isExpanded={isExpanded} | ||
| 405 | onToggleExpand={() => toggleExpanded(group.key)} | ||
| 406 | /> | ||
| 407 | <View | ||
| 408 | className="overflow-hidden rounded-[20px] bg-card" | ||
| 409 | style={{ borderCurve: "continuous" }} | ||
| 410 | > | ||
| 411 | {visibleThreads.map((thread, i) => ( | ||
| 412 | <ThreadRow | ||
| 413 | key={`${thread.environmentId}:${thread.id}`} | ||
| 414 | thread={thread} | ||
| 415 | projectCwd={group.project.workspaceRoot} | ||
| 416 | onPress={() => props.onSelectThread(thread)} | ||
| 417 | isLast={i === visibleThreads.length - 1} | ||
| 418 | /> | ||
| 419 | ))} | ||
| 420 | </View> | ||
| 421 | </View> | ||
| 422 | ); | ||
| 423 | }) | ||
| 424 | )} | ||
| 425 | </ScrollView> | ||
| 426 | ); | 471 | ); |
| 427 | } | 472 | } |
apps/mobile/src/features/observability/mobileTracing.test.ts deleted-53| ... | @@ -1,53 +0,0 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | ||
| 7 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 8 | |||
| 9 | import { makeMobileTracingLayer } from "./mobileTracing"; | ||
| 10 | |||
| 11 | vi.mock("expo-constants", () => ({ | ||
| 12 | default: { | ||
| 13 | expoConfig: { | ||
| 14 | extra: {}, | ||
| 15 | }, | ||
| 16 | }, | ||
| 17 | })); | ||
| 18 | |||
| 19 | it.effect("exports spans through the scoped mobile OTLP layer", () => { | ||
| 20 | const fetchFn = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 })); | ||
| 21 | const tracingLayer = makeMobileTracingLayer( | ||
| 22 | { | ||
| 23 | tracesUrl: "https://api.axiom.test/v1/traces", | ||
| 24 | tracesDataset: "mobile-traces", | ||
| 25 | tracesToken: "public-ingest-token", | ||
| 26 | }, | ||
| 27 | { | ||
| 28 | appVariant: "test", | ||
| 29 | serviceVersion: "1.2.3", | ||
| 30 | }, | ||
| 31 | ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); | ||
| 32 | const tracedApplication = Layer.effectDiscard( | ||
| 33 | Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), | ||
| 34 | ).pipe(Layer.provide(tracingLayer)); | ||
| 35 | |||
| 36 | return Effect.gen(function* () { | ||
| 37 | yield* Layer.build(tracedApplication); | ||
| 38 | |||
| 39 | expect(fetchFn).not.toHaveBeenCalled(); | ||
| 40 | }).pipe( | ||
| 41 | Effect.scoped, | ||
| 42 | Effect.andThen( | ||
| 43 | Effect.sync(() => { | ||
| 44 | expect(fetchFn).toHaveBeenCalledOnce(); | ||
| 45 | const [url, init] = fetchFn.mock.calls[0]!; | ||
| 46 | expect(String(url)).toBe("https://api.axiom.test/v1/traces"); | ||
| 47 | expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); | ||
| 48 | expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); | ||
| 49 | expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); | ||
| 50 | }), | ||
| 51 | ), | ||
| 52 | ); | ||
| 53 | }); | ||
apps/mobile/src/features/observability/mobileTracing.ts deleted-44| ... | @@ -1,44 +0,0 @@ | ||
| 1 | import Constants from "expo-constants"; | ||
| 2 | import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; | ||
| 3 | |||
| 4 | import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 5 | |||
| 6 | export interface MobileTracingConfig { | ||
| 7 | readonly tracesUrl: string; | ||
| 8 | readonly tracesDataset: string; | ||
| 9 | readonly tracesToken: string; | ||
| 10 | } | ||
| 11 | |||
| 12 | export interface MobileTracingResource { | ||
| 13 | readonly serviceVersion?: string; | ||
| 14 | readonly appVariant: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function resolveMobileTracingConfig(): MobileTracingConfig | null { | ||
| 18 | const config = resolveCloudPublicConfig(); | ||
| 19 | if (!hasMobileTracingPublicConfig(config)) { | ||
| 20 | return null; | ||
| 21 | } | ||
| 22 | const { tracesUrl, tracesDataset, tracesToken } = config.observability; | ||
| 23 | return { tracesUrl, tracesDataset, tracesToken }; | ||
| 24 | } | ||
| 25 | |||
| 26 | export function makeMobileTracingLayer( | ||
| 27 | config: MobileTracingConfig | null, | ||
| 28 | resource: MobileTracingResource, | ||
| 29 | ) { | ||
| 30 | return makeRelayClientTracingLayer(config, { | ||
| 31 | serviceName: "t3-mobile-relay-client", | ||
| 32 | serviceVersion: resource.serviceVersion, | ||
| 33 | runtime: "react-native", | ||
| 34 | client: `mobile-${resource.appVariant}`, | ||
| 35 | }); | ||
| 36 | } | ||
| 37 | |||
| 38 | export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), { | ||
| 39 | serviceVersion: Constants.expoConfig?.version, | ||
| 40 | appVariant: | ||
| 41 | typeof Constants.expoConfig?.extra?.appVariant === "string" | ||
| 42 | ? Constants.expoConfig.extra.appVariant | ||
| 43 | : "unknown", | ||
| 44 | }); | ||
apps/mobile/src/features/observability/tracing.test.ts created+97| ... | @@ -0,0 +1,97 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import { vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; | ||
| 8 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 9 | |||
| 10 | import { makeTracingLayer } from "./tracing"; | ||
| 11 | |||
| 12 | vi.mock("expo-constants", () => ({ | ||
| 13 | default: { | ||
| 14 | expoConfig: { | ||
| 15 | extra: {}, | ||
| 16 | }, | ||
| 17 | }, | ||
| 18 | })); | ||
| 19 | |||
| 20 | it.effect("exports spans through the scoped mobile OTLP layer", () => { | ||
| 21 | const fetchFn = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 })); | ||
| 22 | const tracingLayer = makeTracingLayer( | ||
| 23 | { | ||
| 24 | tracesUrl: "https://api.axiom.test/v1/traces", | ||
| 25 | tracesDataset: "mobile-traces", | ||
| 26 | tracesToken: "public-ingest-token", | ||
| 27 | }, | ||
| 28 | { | ||
| 29 | appVariant: "test", | ||
| 30 | serviceVersion: "1.2.3", | ||
| 31 | }, | ||
| 32 | ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); | ||
| 33 | const tracedApplication = Layer.effectDiscard( | ||
| 34 | Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), | ||
| 35 | ).pipe(Layer.provide(tracingLayer)); | ||
| 36 | |||
| 37 | return Effect.gen(function* () { | ||
| 38 | yield* Layer.build(tracedApplication); | ||
| 39 | |||
| 40 | expect(fetchFn).not.toHaveBeenCalled(); | ||
| 41 | }).pipe( | ||
| 42 | Effect.scoped, | ||
| 43 | Effect.andThen( | ||
| 44 | Effect.sync(() => { | ||
| 45 | expect(fetchFn).toHaveBeenCalledOnce(); | ||
| 46 | const [url, init] = fetchFn.mock.calls[0]!; | ||
| 47 | expect(String(url)).toBe("https://api.axiom.test/v1/traces"); | ||
| 48 | expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); | ||
| 49 | expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); | ||
| 50 | expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); | ||
| 51 | }), | ||
| 52 | ), | ||
| 53 | ); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it.effect("does not let OTLP serialization failures alter application effects", () => { | ||
| 57 | const fetchFn = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 })); | ||
| 58 | const tracingLayer = makeTracingLayer( | ||
| 59 | { | ||
| 60 | tracesUrl: "https://api.axiom.test/v1/traces", | ||
| 61 | tracesDataset: "mobile-traces", | ||
| 62 | tracesToken: "public-ingest-token", | ||
| 63 | }, | ||
| 64 | { | ||
| 65 | appVariant: "test", | ||
| 66 | serviceVersion: "1.2.3", | ||
| 67 | }, | ||
| 68 | ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); | ||
| 69 | const failure = { durationNanos: 1n }; | ||
| 70 | const tracedApplication = Layer.effectDiscard( | ||
| 71 | Effect.fail(failure).pipe( | ||
| 72 | Effect.withSpan("mobile.test.failed-span"), | ||
| 73 | withRelayClientTracing, | ||
| 74 | Effect.exit, | ||
| 75 | Effect.flatMap((exit) => { | ||
| 76 | const reason = exit._tag === "Failure" ? exit.cause.reasons[0] : undefined; | ||
| 77 | return reason && Cause.isFailReason(reason) | ||
| 78 | ? Effect.sync(() => { | ||
| 79 | expect(reason.error).toBe(failure); | ||
| 80 | }) | ||
| 81 | : Effect.die(new Error("Expected the original typed failure.")); | ||
| 82 | }), | ||
| 83 | ), | ||
| 84 | ).pipe(Layer.provide(tracingLayer)); | ||
| 85 | |||
| 86 | return Layer.build(tracedApplication).pipe( | ||
| 87 | Effect.scoped, | ||
| 88 | Effect.andThen( | ||
| 89 | Effect.sync(() => { | ||
| 90 | expect(fetchFn).toHaveBeenCalledOnce(); | ||
| 91 | expect(new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array)).toContain( | ||
| 92 | "mobile.test.failed-span", | ||
| 93 | ); | ||
| 94 | }), | ||
| 95 | ), | ||
| 96 | ); | ||
| 97 | }); | ||
apps/mobile/src/features/observability/tracing.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import Constants from "expo-constants"; | ||
| 2 | import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; | ||
| 3 | |||
| 4 | import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 5 | |||
| 6 | export interface TracingConfig { | ||
| 7 | readonly tracesUrl: string; | ||
| 8 | readonly tracesDataset: string; | ||
| 9 | readonly tracesToken: string; | ||
| 10 | } | ||
| 11 | |||
| 12 | export interface TracingResource { | ||
| 13 | readonly serviceVersion?: string; | ||
| 14 | readonly appVariant: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function resolveTracingConfig(): TracingConfig | null { | ||
| 18 | const config = resolveCloudPublicConfig(); | ||
| 19 | if (!hasTracingPublicConfig(config)) { | ||
| 20 | return null; | ||
| 21 | } | ||
| 22 | const { tracesUrl, tracesDataset, tracesToken } = config.observability; | ||
| 23 | return { tracesUrl, tracesDataset, tracesToken }; | ||
| 24 | } | ||
| 25 | |||
| 26 | export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) { | ||
| 27 | return makeRelayClientTracingLayer(config, { | ||
| 28 | serviceName: "t3-mobile-relay-client", | ||
| 29 | serviceVersion: resource.serviceVersion, | ||
| 30 | runtime: "react-native", | ||
| 31 | client: `mobile-${resource.appVariant}`, | ||
| 32 | }); | ||
| 33 | } | ||
| 34 | |||
| 35 | export const tracingLayer = makeTracingLayer(resolveTracingConfig(), { | ||
| 36 | serviceVersion: Constants.expoConfig?.version, | ||
| 37 | appVariant: | ||
| 38 | typeof Constants.expoConfig?.extra?.appVariant === "string" | ||
| 39 | ? Constants.expoConfig.extra.appVariant | ||
| 40 | : "unknown", | ||
| 41 | }); | ||
apps/mobile/src/features/projects/AddProjectScreen.tsx+105-78| ... | @@ -2,23 +2,25 @@ import { | ... | @@ -2,23 +2,25 @@ import { |
| 2 | addProjectRemoteSourceLabel, | 2 | addProjectRemoteSourceLabel, |
| 3 | addProjectRemoteSourcePathHint, | 3 | addProjectRemoteSourcePathHint, |
| 4 | addProjectRemoteSourceProvider, | 4 | addProjectRemoteSourceProvider, |
| 5 | appendBrowsePathSegment, | ||
| 6 | buildAddProjectRemoteSourceReadiness, | 5 | buildAddProjectRemoteSourceReadiness, |
| 7 | buildProjectCreateCommand, | 6 | buildProjectCreateCommand, |
| 8 | canNavigateUp, | ||
| 9 | ensureBrowseDirectoryPath, | ||
| 10 | findExistingAddProject, | 7 | findExistingAddProject, |
| 11 | getAddProjectInitialQuery, | 8 | getAddProjectInitialQuery, |
| 9 | resolveAddProjectPath, | ||
| 10 | sortAddProjectProviderSources, | ||
| 11 | type AddProjectRemoteSource, | ||
| 12 | } from "@t3tools/client-runtime/operations/projects"; | ||
| 13 | import { | ||
| 14 | appendBrowsePathSegment, | ||
| 15 | canNavigateUp, | ||
| 16 | ensureBrowseDirectoryPath, | ||
| 12 | getBrowseDirectoryPath, | 17 | getBrowseDirectoryPath, |
| 13 | getBrowseLeafPathSegment, | 18 | getBrowseLeafPathSegment, |
| 14 | getBrowseParentPath, | 19 | getBrowseParentPath, |
| 15 | hasTrailingPathSeparator, | 20 | hasTrailingPathSeparator, |
| 16 | inferProjectTitleFromPath, | 21 | inferProjectTitleFromPath, |
| 17 | isFilesystemBrowseQuery, | 22 | isFilesystemBrowseQuery, |
| 18 | resolveAddProjectPath, | 23 | } from "@t3tools/client-runtime/state/projects"; |
| 19 | sortAddProjectProviderSources, | ||
| 20 | type AddProjectRemoteSource, | ||
| 21 | } from "@t3tools/client-runtime"; | ||
| 22 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; | 24 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 23 | import { useLocalSearchParams, useRouter } from "expo-router"; | 25 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 24 | import { SymbolView } from "expo-symbols"; | 26 | import { SymbolView } from "expo-symbols"; |
| ... | @@ -26,21 +28,23 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react | ... | @@ -26,21 +28,23 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react |
| 26 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | 28 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; |
| 27 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 29 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 28 | import * as Arr from "effect/Array"; | 30 | import * as Arr from "effect/Array"; |
| 31 | import * as Cause from "effect/Cause"; | ||
| 29 | import * as Order from "effect/Order"; | 32 | import * as Order from "effect/Order"; |
| 33 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 30 | 34 | ||
| 35 | import { useProjects, useServerConfigs } from "../../state/entities"; | ||
| 36 | import { filesystemEnvironment } from "../../state/filesystem"; | ||
| 37 | import { projectEnvironment } from "../../state/projects"; | ||
| 38 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 39 | import { sourceControlEnvironment } from "../../state/sourceControl"; | ||
| 31 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 40 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 32 | import { ErrorBanner } from "../../components/ErrorBanner"; | 41 | import { ErrorBanner } from "../../components/ErrorBanner"; |
| 33 | import { SourceControlIcon } from "../../components/SourceControlIcon"; | 42 | import { SourceControlIcon } from "../../components/SourceControlIcon"; |
| 34 | import { useThemeColor } from "../../lib/useThemeColor"; | 43 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 35 | import { uuidv4 } from "../../lib/uuid"; | 44 | import { uuidv4 } from "../../lib/uuid"; |
| 36 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | 45 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 37 | import { useFilesystemBrowse } from "../../state/use-filesystem-browse"; | 46 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 38 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 47 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 39 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | ||
| 40 | import { | ||
| 41 | refreshSourceControlDiscoveryForEnvironment, | ||
| 42 | useSourceControlDiscovery, | ||
| 43 | } from "../../state/use-source-control-discovery"; | ||
| 44 | 48 | ||
| 45 | interface EnvironmentOption { | 49 | interface EnvironmentOption { |
| 46 | readonly environmentId: EnvironmentId; | 50 | readonly environmentId: EnvironmentId; |
| ... | @@ -224,12 +228,12 @@ function ProjectPathInput(props: { | ... | @@ -224,12 +228,12 @@ function ProjectPathInput(props: { |
| 224 | } | 228 | } |
| 225 | 229 | ||
| 226 | function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { | 230 | function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { |
| 227 | const { serverConfigByEnvironmentId } = useRemoteCatalog(); | 231 | const serverConfigByEnvironmentId = useServerConfigs(); |
| 228 | const { savedConnectionsById } = useRemoteEnvironmentState(); | 232 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 229 | 233 | ||
| 230 | return useMemo<ReadonlyArray<EnvironmentOption>>(() => { | 234 | return useMemo<ReadonlyArray<EnvironmentOption>>(() => { |
| 231 | const options = Object.values(savedConnectionsById).map((connection) => { | 235 | const options = Object.values(savedConnectionsById).map((connection) => { |
| 232 | const config = serverConfigByEnvironmentId[connection.environmentId]; | 236 | const config = serverConfigByEnvironmentId.get(connection.environmentId); |
| 233 | return { | 237 | return { |
| 234 | environmentId: connection.environmentId, | 238 | environmentId: connection.environmentId, |
| 235 | label: connection.environmentLabel, | 239 | label: connection.environmentLabel, |
| ... | @@ -336,17 +340,19 @@ export function AddProjectSourceScreen() { | ... | @@ -336,17 +340,19 @@ export function AddProjectSourceScreen() { |
| 336 | const iconColor = useThemeColor("--color-icon"); | 340 | const iconColor = useThemeColor("--color-icon"); |
| 337 | const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = | 341 | const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = |
| 338 | useSelectedEnvironment(); | 342 | useSelectedEnvironment(); |
| 339 | const discoveryState = useSourceControlDiscovery(selectedEnvironment?.environmentId ?? null); | 343 | const discoveryState = useEnvironmentQuery( |
| 344 | selectedEnvironment === null | ||
| 345 | ? null | ||
| 346 | : sourceControlEnvironment.discovery({ | ||
| 347 | environmentId: selectedEnvironment.environmentId, | ||
| 348 | input: {}, | ||
| 349 | }), | ||
| 350 | ); | ||
| 340 | const readiness = useMemo( | 351 | const readiness = useMemo( |
| 341 | () => buildAddProjectRemoteSourceReadiness(discoveryState.data), | 352 | () => buildAddProjectRemoteSourceReadiness(discoveryState.data), |
| 342 | [discoveryState.data], | 353 | [discoveryState.data], |
| 343 | ); | 354 | ); |
| 344 | 355 | ||
| 345 | useEffect(() => { | ||
| 346 | if (!selectedEnvironment) return; | ||
| 347 | void refreshSourceControlDiscoveryForEnvironment(selectedEnvironment.environmentId); | ||
| 348 | }, [selectedEnvironment]); | ||
| 349 | |||
| 350 | return ( | 356 | return ( |
| 351 | <AddProjectShell> | 357 | <AddProjectShell> |
| 352 | {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null} | 358 | {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null} |
| ... | @@ -435,13 +441,12 @@ export function AddProjectSourceScreen() { | ... | @@ -435,13 +441,12 @@ export function AddProjectSourceScreen() { |
| 435 | 441 | ||
| 436 | function useCreateProject(environment: EnvironmentOption | null) { | 442 | function useCreateProject(environment: EnvironmentOption | null) { |
| 437 | const router = useRouter(); | 443 | const router = useRouter(); |
| 438 | const { projects } = useRemoteCatalog(); | 444 | const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); |
| 445 | const projects = useProjects(); | ||
| 439 | 446 | ||
| 440 | return useCallback( | 447 | return useCallback( |
| 441 | async (workspaceRoot: string) => { | 448 | async (workspaceRoot: string) => { |
| 442 | if (!environment) return; | 449 | if (!environment) return; |
| 443 | const client = getEnvironmentClient(environment.environmentId); | ||
| 444 | if (!client) throw new Error("Environment API is not available."); | ||
| 445 | 450 | ||
| 446 | const existing = findExistingAddProject({ | 451 | const existing = findExistingAddProject({ |
| 447 | projects, | 452 | projects, |
| ... | @@ -462,14 +467,19 @@ function useCreateProject(environment: EnvironmentOption | null) { | ... | @@ -462,14 +467,19 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 462 | } | 467 | } |
| 463 | 468 | ||
| 464 | const projectId = ProjectId.make(uuidv4()); | 469 | const projectId = ProjectId.make(uuidv4()); |
| 465 | await client.orchestration.dispatchCommand( | 470 | const command = buildProjectCreateCommand({ |
| 466 | buildProjectCreateCommand({ | 471 | commandId: CommandId.make(uuidv4()), |
| 467 | commandId: CommandId.make(uuidv4()), | 472 | projectId, |
| 468 | projectId, | 473 | workspaceRoot, |
| 469 | workspaceRoot, | 474 | createdAt: new Date().toISOString(), |
| 470 | createdAt: new Date().toISOString(), | 475 | }); |
| 471 | }), | 476 | const result = await createProject({ |
| 472 | ); | 477 | environmentId: environment.environmentId, |
| 478 | input: command, | ||
| 479 | }); | ||
| 480 | if (AsyncResult.isFailure(result)) { | ||
| 481 | return result; | ||
| 482 | } | ||
| 473 | router.replace({ | 483 | router.replace({ |
| 474 | pathname: "/new/draft", | 484 | pathname: "/new/draft", |
| 475 | params: { | 485 | params: { |
| ... | @@ -478,8 +488,9 @@ function useCreateProject(environment: EnvironmentOption | null) { | ... | @@ -478,8 +488,9 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 478 | title: inferProjectTitleFromPath(workspaceRoot), | 488 | title: inferProjectTitleFromPath(workspaceRoot), |
| 479 | }, | 489 | }, |
| 480 | }); | 490 | }); |
| 491 | return result; | ||
| 481 | }, | 492 | }, |
| 482 | [environment, projects, router], | 493 | [createProject, environment, projects, router], |
| 483 | ); | 494 | ); |
| 484 | } | 495 | } |
| 485 | 496 | ||
| ... | @@ -495,6 +506,9 @@ function useEnvironmentFromParam(): EnvironmentOption | null { | ... | @@ -495,6 +506,9 @@ function useEnvironmentFromParam(): EnvironmentOption | null { |
| 495 | } | 506 | } |
| 496 | 507 | ||
| 497 | export function AddProjectRepositoryScreen() { | 508 | export function AddProjectRepositoryScreen() { |
| 509 | const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { | ||
| 510 | reportFailure: false, | ||
| 511 | }); | ||
| 498 | const router = useRouter(); | 512 | const router = useRouter(); |
| 499 | const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); | 513 | const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); |
| 500 | const environment = useEnvironmentFromParam(); | 514 | const environment = useEnvironmentFromParam(); |
| ... | @@ -507,28 +521,33 @@ export function AddProjectRepositoryScreen() { | ... | @@ -507,28 +521,33 @@ export function AddProjectRepositoryScreen() { |
| 507 | if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; | 521 | if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; |
| 508 | setError(null); | 522 | setError(null); |
| 509 | setIsSubmitting(true); | 523 | setIsSubmitting(true); |
| 510 | try { | 524 | const provider = addProjectRemoteSourceProvider(source); |
| 511 | const provider = addProjectRemoteSourceProvider(source); | 525 | if (!provider) { |
| 512 | if (!provider) { | 526 | const remoteUrl = repositoryInput.trim(); |
| 513 | const remoteUrl = repositoryInput.trim(); | 527 | router.push({ |
| 514 | router.push({ | 528 | pathname: "/new/add-project/destination", |
| 515 | pathname: "/new/add-project/destination", | 529 | params: { |
| 516 | params: { | 530 | environmentId: environment.environmentId, |
| 517 | environmentId: environment.environmentId, | 531 | source, |
| 518 | source, | 532 | remoteUrl, |
| 519 | remoteUrl, | 533 | repositoryTitle: remoteUrl, |
| 520 | repositoryTitle: remoteUrl, | 534 | }, |
| 521 | }, | 535 | }); |
| 522 | }); | 536 | setIsSubmitting(false); |
| 523 | return; | 537 | return; |
| 524 | } | 538 | } |
| 525 | 539 | ||
| 526 | const client = getEnvironmentClient(environment.environmentId); | 540 | const result = await lookupRepositoryQuery({ |
| 527 | if (!client) throw new Error("Environment API is not available."); | 541 | environmentId: environment.environmentId, |
| 528 | const repository = await client.sourceControl.lookupRepository({ | 542 | input: { |
| 529 | provider, | 543 | provider, |
| 530 | repository: repositoryInput.trim(), | 544 | repository: repositoryInput.trim(), |
| 531 | }); | 545 | }, |
| 546 | }); | ||
| 547 | if (AsyncResult.isFailure(result)) { | ||
| 548 | setError(errorMessage(Cause.squash(result.cause))); | ||
| 549 | } else { | ||
| 550 | const repository = result.value; | ||
| 532 | router.push({ | 551 | router.push({ |
| 533 | pathname: "/new/add-project/destination", | 552 | pathname: "/new/add-project/destination", |
| 534 | params: { | 553 | params: { |
| ... | @@ -538,12 +557,9 @@ export function AddProjectRepositoryScreen() { | ... | @@ -538,12 +557,9 @@ export function AddProjectRepositoryScreen() { |
| 538 | repositoryTitle: repository.nameWithOwner, | 557 | repositoryTitle: repository.nameWithOwner, |
| 539 | }, | 558 | }, |
| 540 | }); | 559 | }); |
| 541 | } catch (nextError) { | ||
| 542 | setError(errorMessage(nextError)); | ||
| 543 | } finally { | ||
| 544 | setIsSubmitting(false); | ||
| 545 | } | 560 | } |
| 546 | }, [environment, isSubmitting, repositoryInput, router, source]); | 561 | setIsSubmitting(false); |
| 562 | }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]); | ||
| 547 | 563 | ||
| 548 | return ( | 564 | return ( |
| 549 | <AddProjectShell> | 565 | <AddProjectShell> |
| ... | @@ -593,7 +609,14 @@ function FolderBrowser(props: { | ... | @@ -593,7 +609,14 @@ function FolderBrowser(props: { |
| 593 | () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), | 609 | () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), |
| 594 | [browseDirectoryPath], | 610 | [browseDirectoryPath], |
| 595 | ); | 611 | ); |
| 596 | const browseState = useFilesystemBrowse(props.environment.environmentId, browseInput); | 612 | const browseState = useEnvironmentQuery( |
| 613 | browseInput === null | ||
| 614 | ? null | ||
| 615 | : filesystemEnvironment.browse({ | ||
| 616 | environmentId: props.environment.environmentId, | ||
| 617 | input: browseInput, | ||
| 618 | }), | ||
| 619 | ); | ||
| 597 | const visibleBrowseEntries = useMemo( | 620 | const visibleBrowseEntries = useMemo( |
| 598 | () => | 621 | () => |
| 599 | Arr.sort( | 622 | Arr.sort( |
| ... | @@ -686,13 +709,11 @@ export function AddProjectLocalFolderScreen() { | ... | @@ -686,13 +709,11 @@ export function AddProjectLocalFolderScreen() { |
| 686 | } | 709 | } |
| 687 | 710 | ||
| 688 | setIsSubmitting(true); | 711 | setIsSubmitting(true); |
| 689 | try { | 712 | const result = await createProject(resolved.path); |
| 690 | await createProject(resolved.path); | 713 | if (result && AsyncResult.isFailure(result)) { |
| 691 | } catch (nextError) { | 714 | setError(errorMessage(Cause.squash(result.cause))); |
| 692 | setError(errorMessage(nextError)); | ||
| 693 | } finally { | ||
| 694 | setIsSubmitting(false); | ||
| 695 | } | 715 | } |
| 716 | setIsSubmitting(false); | ||
| 696 | }, [createProject, environment, isSubmitting, pathInput]); | 717 | }, [createProject, environment, isSubmitting, pathInput]); |
| 697 | 718 | ||
| 698 | return ( | 719 | return ( |
| ... | @@ -725,6 +746,9 @@ export function AddProjectLocalFolderScreen() { | ... | @@ -725,6 +746,9 @@ export function AddProjectLocalFolderScreen() { |
| 725 | } | 746 | } |
| 726 | 747 | ||
| 727 | export function AddProjectDestinationScreen() { | 748 | export function AddProjectDestinationScreen() { |
| 749 | const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { | ||
| 750 | reportFailure: false, | ||
| 751 | }); | ||
| 728 | const params = useLocalSearchParams<{ | 752 | const params = useLocalSearchParams<{ |
| 729 | environmentId?: string; | 753 | environmentId?: string; |
| 730 | remoteUrl?: string; | 754 | remoteUrl?: string; |
| ... | @@ -759,20 +783,23 @@ export function AddProjectDestinationScreen() { | ... | @@ -759,20 +783,23 @@ export function AddProjectDestinationScreen() { |
| 759 | } | 783 | } |
| 760 | 784 | ||
| 761 | setIsSubmitting(true); | 785 | setIsSubmitting(true); |
| 762 | try { | 786 | const cloneResult = await cloneRepository({ |
| 763 | const client = getEnvironmentClient(environment.environmentId); | 787 | environmentId: environment.environmentId, |
| 764 | if (!client) throw new Error("Environment API is not available."); | 788 | input: { |
| 765 | const result = await client.sourceControl.cloneRepository({ | ||
| 766 | remoteUrl, | 789 | remoteUrl, |
| 767 | destinationPath: resolved.path, | 790 | destinationPath: resolved.path, |
| 768 | }); | 791 | }, |
| 769 | await createProject(result.cwd); | 792 | }); |
| 770 | } catch (nextError) { | 793 | if (AsyncResult.isFailure(cloneResult)) { |
| 771 | setError(errorMessage(nextError)); | 794 | setError(errorMessage(Cause.squash(cloneResult.cause))); |
| 772 | } finally { | 795 | } else { |
| 773 | setIsSubmitting(false); | 796 | const createResult = await createProject(cloneResult.value.cwd); |
| 797 | if (createResult && AsyncResult.isFailure(createResult)) { | ||
| 798 | setError(errorMessage(Cause.squash(createResult.cause))); | ||
| 799 | } | ||
| 774 | } | 800 | } |
| 775 | }, [createProject, environment, isSubmitting, pathInput, remoteUrl]); | 801 | setIsSubmitting(false); |
| 802 | }, [cloneRepository, createProject, environment, isSubmitting, pathInput, remoteUrl]); | ||
| 776 | 803 | ||
| 777 | return ( | 804 | return ( |
| 778 | <AddProjectShell> | 805 | <AddProjectShell> |
apps/mobile/src/features/review/ReviewSheet.tsx+65-24| ... | @@ -16,8 +16,12 @@ import { | ... | @@ -16,8 +16,12 @@ import { |
| 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 17 | 17 | ||
| 18 | import { AppText as Text } from "../../components/AppText"; | 18 | import { AppText as Text } from "../../components/AppText"; |
| 19 | import { environmentCatalog } from "../../connection/catalog"; | ||
| 20 | import { useEnvironmentPresentation } from "../../state/presentation"; | ||
| 21 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 19 | import { useThemeColor } from "../../lib/useThemeColor"; | 22 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 20 | import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; | 23 | import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; |
| 24 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; | ||
| 21 | import { useReviewCacheForThread } from "./reviewState"; | 25 | import { useReviewCacheForThread } from "./reviewState"; |
| 22 | import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; | 26 | import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; |
| 23 | import { | 27 | import { |
| ... | @@ -29,6 +33,7 @@ import { useReviewFileVisibility } from "./reviewFileVisibility"; | ... | @@ -29,6 +33,7 @@ import { useReviewFileVisibility } from "./reviewFileVisibility"; |
| 29 | import { useReviewSections } from "./useReviewSections"; | 33 | import { useReviewSections } from "./useReviewSections"; |
| 30 | import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; | 34 | import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; |
| 31 | import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; | 35 | import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; |
| 36 | import { resolveReviewAvailability } from "./reviewAvailability"; | ||
| 32 | 37 | ||
| 33 | const IOS_NAV_BAR_HEIGHT = 44; | 38 | const IOS_NAV_BAR_HEIGHT = 44; |
| 34 | const REVIEW_HEADER_SPACING = 0; | 39 | const REVIEW_HEADER_SPACING = 0; |
| ... | @@ -114,6 +119,9 @@ export function ReviewSheet() { | ... | @@ -114,6 +119,9 @@ export function ReviewSheet() { |
| 114 | environmentId: EnvironmentId; | 119 | environmentId: EnvironmentId; |
| 115 | threadId: ThreadId; | 120 | threadId: ThreadId; |
| 116 | }>(); | 121 | }>(); |
| 122 | const environment = useEnvironmentPresentation(environmentId); | ||
| 123 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); | ||
| 124 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; | ||
| 117 | const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); | 125 | const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); |
| 118 | const reviewCache = useReviewCacheForThread({ environmentId, threadId }); | 126 | const reviewCache = useReviewCacheForThread({ environmentId, threadId }); |
| 119 | const selectedTheme = colorScheme === "dark" ? "dark" : "light"; | 127 | const selectedTheme = colorScheme === "dark" ? "dark" : "light"; |
| ... | @@ -126,7 +134,12 @@ export function ReviewSheet() { | ... | @@ -126,7 +134,12 @@ export function ReviewSheet() { |
| 126 | selectedSection, | 134 | selectedSection, |
| 127 | refreshSelectedSection, | 135 | refreshSelectedSection, |
| 128 | selectSection, | 136 | selectSection, |
| 129 | } = useReviewSections({ environmentId, threadId, reviewCache }); | 137 | } = useReviewSections({ |
| 138 | enabled: isEnvironmentReady, | ||
| 139 | environmentId, | ||
| 140 | threadId, | ||
| 141 | reviewCache, | ||
| 142 | }); | ||
| 130 | const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = | 143 | const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = |
| 131 | useReviewDiffData({ | 144 | useReviewDiffData({ |
| 132 | threadKey: reviewCache.threadKey, | 145 | threadKey: reviewCache.threadKey, |
| ... | @@ -187,6 +200,17 @@ export function ReviewSheet() { | ... | @@ -187,6 +200,17 @@ export function ReviewSheet() { |
| 187 | 200 | ||
| 188 | const parsedDiffNotice = | 201 | const parsedDiffNotice = |
| 189 | parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; | 202 | parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; |
| 203 | const hasCachedSelectedDiff = selectedSection?.diff != null; | ||
| 204 | const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null); | ||
| 205 | const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({ | ||
| 206 | hasEnvironmentPresentation: environment.isReady, | ||
| 207 | isEnvironmentConnected: isEnvironmentReady, | ||
| 208 | hasCachedSelectedDiff, | ||
| 209 | hasAnyCachedDiff, | ||
| 210 | }); | ||
| 211 | const handleRetryEnvironment = useCallback(() => { | ||
| 212 | void retryEnvironment(environmentId); | ||
| 213 | }, [environmentId, retryEnvironment]); | ||
| 190 | 214 | ||
| 191 | const listHeader = useMemo(() => { | 215 | const listHeader = useMemo(() => { |
| 192 | const children: ReactElement[] = []; | 216 | const children: ReactElement[] = []; |
| ... | @@ -312,34 +336,51 @@ export function ReviewSheet() { | ... | @@ -312,34 +336,51 @@ export function ReviewSheet() { |
| 312 | }} | 336 | }} |
| 313 | /> | 337 | /> |
| 314 | 338 | ||
| 315 | <Stack.Toolbar placement="right"> | 339 | {showSectionToolbar ? ( |
| 316 | <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground> | 340 | <Stack.Toolbar placement="right"> |
| 317 | {reviewSections.map((section) => ( | 341 | <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground> |
| 342 | {reviewSections.map((section) => ( | ||
| 343 | <Stack.Toolbar.MenuAction | ||
| 344 | key={section.id} | ||
| 345 | icon={section.id === selectedSection?.id ? "checkmark" : "circle"} | ||
| 346 | onPress={() => selectSection(section.id)} | ||
| 347 | subtitle={section.subtitle ?? undefined} | ||
| 348 | > | ||
| 349 | <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label> | ||
| 350 | </Stack.Toolbar.MenuAction> | ||
| 351 | ))} | ||
| 318 | <Stack.Toolbar.MenuAction | 352 | <Stack.Toolbar.MenuAction |
| 319 | key={section.id} | 353 | icon="arrow.clockwise" |
| 320 | icon={section.id === selectedSection?.id ? "checkmark" : "circle"} | 354 | disabled={ |
| 321 | onPress={() => selectSection(section.id)} | 355 | loadingGitDiffs || |
| 322 | subtitle={section.subtitle ?? undefined} | 356 | (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true) |
| 357 | } | ||
| 358 | onPress={() => void refreshSelectedSection()} | ||
| 359 | subtitle="Reload current diff" | ||
| 323 | > | 360 | > |
| 324 | <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label> | 361 | <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label> |
| 325 | </Stack.Toolbar.MenuAction> | 362 | </Stack.Toolbar.MenuAction> |
| 326 | ))} | 363 | </Stack.Toolbar.Menu> |
| 327 | <Stack.Toolbar.MenuAction | 364 | </Stack.Toolbar> |
| 328 | icon="arrow.clockwise" | 365 | ) : null} |
| 329 | disabled={ | ||
| 330 | loadingGitDiffs || | ||
| 331 | (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true) | ||
| 332 | } | ||
| 333 | onPress={() => void refreshSelectedSection()} | ||
| 334 | subtitle="Reload current diff" | ||
| 335 | > | ||
| 336 | <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label> | ||
| 337 | </Stack.Toolbar.MenuAction> | ||
| 338 | </Stack.Toolbar.Menu> | ||
| 339 | </Stack.Toolbar> | ||
| 340 | 366 | ||
| 341 | <View className="flex-1 bg-sheet"> | 367 | <View className="flex-1 bg-sheet"> |
| 342 | {selectedSection && parsedDiff.kind === "files" ? ( | 368 | {showConnectionNotice ? ( |
| 369 | <View style={{ flex: 1, paddingTop: topContentInset }}> | ||
| 370 | <EnvironmentConnectionNotice | ||
| 371 | environmentLabel={environment.presentation?.entry.target.label ?? "Environment"} | ||
| 372 | connection={ | ||
| 373 | environment.presentation?.connection ?? { | ||
| 374 | phase: "available", | ||
| 375 | error: null, | ||
| 376 | traceId: null, | ||
| 377 | } | ||
| 378 | } | ||
| 379 | resourceName="review" | ||
| 380 | onRetry={handleRetryEnvironment} | ||
| 381 | /> | ||
| 382 | </View> | ||
| 383 | ) : selectedSection && parsedDiff.kind === "files" ? ( | ||
| 343 | <View | 384 | <View |
| 344 | className="flex-1" | 385 | className="flex-1" |
| 345 | style={{ | 386 | style={{ |
apps/mobile/src/features/review/reviewAvailability.test.ts created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { resolveReviewAvailability } from "./reviewAvailability"; | ||
| 4 | |||
| 5 | describe("resolveReviewAvailability", () => { | ||
| 6 | it("keeps section navigation available when another section is cached offline", () => { | ||
| 7 | expect( | ||
| 8 | resolveReviewAvailability({ | ||
| 9 | hasEnvironmentPresentation: true, | ||
| 10 | isEnvironmentConnected: false, | ||
| 11 | hasCachedSelectedDiff: false, | ||
| 12 | hasAnyCachedDiff: true, | ||
| 13 | }), | ||
| 14 | ).toEqual({ | ||
| 15 | showConnectionNotice: true, | ||
| 16 | showSectionToolbar: true, | ||
| 17 | }); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("hides section navigation when no review section is available offline", () => { | ||
| 21 | expect( | ||
| 22 | resolveReviewAvailability({ | ||
| 23 | hasEnvironmentPresentation: true, | ||
| 24 | isEnvironmentConnected: false, | ||
| 25 | hasCachedSelectedDiff: false, | ||
| 26 | hasAnyCachedDiff: false, | ||
| 27 | }), | ||
| 28 | ).toEqual({ | ||
| 29 | showConnectionNotice: true, | ||
| 30 | showSectionToolbar: false, | ||
| 31 | }); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("shows cached selected content and navigation while offline", () => { | ||
| 35 | expect( | ||
| 36 | resolveReviewAvailability({ | ||
| 37 | hasEnvironmentPresentation: true, | ||
| 38 | isEnvironmentConnected: false, | ||
| 39 | hasCachedSelectedDiff: true, | ||
| 40 | hasAnyCachedDiff: true, | ||
| 41 | }), | ||
| 42 | ).toEqual({ | ||
| 43 | showConnectionNotice: false, | ||
| 44 | showSectionToolbar: true, | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | }); | ||
apps/mobile/src/features/review/reviewAvailability.ts created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | export function resolveReviewAvailability(input: { | ||
| 2 | readonly hasEnvironmentPresentation: boolean; | ||
| 3 | readonly isEnvironmentConnected: boolean; | ||
| 4 | readonly hasCachedSelectedDiff: boolean; | ||
| 5 | readonly hasAnyCachedDiff: boolean; | ||
| 6 | }): { | ||
| 7 | readonly showConnectionNotice: boolean; | ||
| 8 | readonly showSectionToolbar: boolean; | ||
| 9 | } { | ||
| 10 | const showConnectionNotice = | ||
| 11 | input.hasEnvironmentPresentation && | ||
| 12 | !input.isEnvironmentConnected && | ||
| 13 | !input.hasCachedSelectedDiff; | ||
| 14 | |||
| 15 | return { | ||
| 16 | showConnectionNotice, | ||
| 17 | showSectionToolbar: !showConnectionNotice || input.hasAnyCachedDiff, | ||
| 18 | }; | ||
| 19 | } | ||
apps/mobile/src/features/review/reviewDiffPreviewState.ts deleted-110| ... | @@ -1,110 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { EnvironmentId, ReviewDiffPreviewResult } from "@t3tools/contracts"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 7 | import { useCallback, useMemo } from "react"; | ||
| 8 | |||
| 9 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 10 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | ||
| 11 | |||
| 12 | const REVIEW_DIFF_PREVIEW_STALE_TIME_MS = 5_000; | ||
| 13 | const REVIEW_DIFF_PREVIEW_IDLE_TTL_MS = 5 * 60_000; | ||
| 14 | const REVIEW_DIFF_PREVIEW_KEY_SEPARATOR = "\u001f"; | ||
| 15 | |||
| 16 | export interface ReviewDiffPreviewState { | ||
| 17 | readonly data: ReviewDiffPreviewResult | null; | ||
| 18 | readonly error: string | null; | ||
| 19 | readonly isPending: boolean; | ||
| 20 | readonly refresh: () => void; | ||
| 21 | } | ||
| 22 | |||
| 23 | function makeReviewDiffPreviewKey(input: { | ||
| 24 | readonly environmentId: EnvironmentId; | ||
| 25 | readonly cwd: string; | ||
| 26 | }): string { | ||
| 27 | return `${input.environmentId}${REVIEW_DIFF_PREVIEW_KEY_SEPARATOR}${input.cwd}`; | ||
| 28 | } | ||
| 29 | |||
| 30 | function parseReviewDiffPreviewKey(key: string): { | ||
| 31 | readonly environmentId: EnvironmentId; | ||
| 32 | readonly cwd: string; | ||
| 33 | } { | ||
| 34 | const [environmentId, cwd = ""] = key.split(REVIEW_DIFF_PREVIEW_KEY_SEPARATOR); | ||
| 35 | return { | ||
| 36 | environmentId: environmentId as EnvironmentId, | ||
| 37 | cwd, | ||
| 38 | }; | ||
| 39 | } | ||
| 40 | |||
| 41 | const reviewDiffPreviewAtom = Atom.family((key: string) => | ||
| 42 | Atom.make( | ||
| 43 | Effect.promise(async (): Promise<ReviewDiffPreviewResult> => { | ||
| 44 | const target = parseReviewDiffPreviewKey(key); | ||
| 45 | const client = getEnvironmentClient(target.environmentId); | ||
| 46 | if (!client) { | ||
| 47 | throw new Error("Remote connection is not ready."); | ||
| 48 | } | ||
| 49 | return client.review.getDiffPreview({ cwd: target.cwd }); | ||
| 50 | }), | ||
| 51 | ).pipe( | ||
| 52 | Atom.swr({ | ||
| 53 | staleTime: REVIEW_DIFF_PREVIEW_STALE_TIME_MS, | ||
| 54 | revalidateOnMount: true, | ||
| 55 | }), | ||
| 56 | Atom.setIdleTTL(REVIEW_DIFF_PREVIEW_IDLE_TTL_MS), | ||
| 57 | Atom.withLabel(`mobile:review:diff-preview:${key}`), | ||
| 58 | ), | ||
| 59 | ); | ||
| 60 | |||
| 61 | const EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM = Atom.make( | ||
| 62 | AsyncResult.initial<ReviewDiffPreviewResult, never>(false), | ||
| 63 | ).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:diff-preview:null")); | ||
| 64 | |||
| 65 | function readReviewDiffPreviewError( | ||
| 66 | result: AsyncResult.AsyncResult<ReviewDiffPreviewResult, unknown>, | ||
| 67 | ): string | null { | ||
| 68 | if (result._tag !== "Failure") { | ||
| 69 | return null; | ||
| 70 | } | ||
| 71 | |||
| 72 | const error = Cause.squash(result.cause); | ||
| 73 | return error instanceof Error ? error.message : "Failed to load review diffs."; | ||
| 74 | } | ||
| 75 | |||
| 76 | export function useReviewDiffPreview(input: { | ||
| 77 | readonly environmentId?: EnvironmentId; | ||
| 78 | readonly cwd: string | null; | ||
| 79 | }): ReviewDiffPreviewState { | ||
| 80 | const key = useMemo(() => { | ||
| 81 | if (!input.environmentId || !input.cwd) { | ||
| 82 | return null; | ||
| 83 | } | ||
| 84 | return makeReviewDiffPreviewKey({ environmentId: input.environmentId, cwd: input.cwd }); | ||
| 85 | }, [input.cwd, input.environmentId]); | ||
| 86 | |||
| 87 | const atom = key ? reviewDiffPreviewAtom(key) : null; | ||
| 88 | const result = useAtomValue(atom ?? EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM); | ||
| 89 | const refresh = useCallback(() => { | ||
| 90 | if (atom) { | ||
| 91 | appAtomRegistry.refresh(atom); | ||
| 92 | } | ||
| 93 | }, [atom]); | ||
| 94 | |||
| 95 | if (!atom) { | ||
| 96 | return { | ||
| 97 | data: null, | ||
| 98 | error: null, | ||
| 99 | isPending: false, | ||
| 100 | refresh, | ||
| 101 | }; | ||
| 102 | } | ||
| 103 | |||
| 104 | return { | ||
| 105 | data: Option.getOrNull(AsyncResult.value(result)), | ||
| 106 | error: readReviewDiffPreviewError(result), | ||
| 107 | isPending: result.waiting, | ||
| 108 | refresh, | ||
| 109 | }; | ||
| 110 | } | ||
apps/mobile/src/features/review/useReviewSections.ts+65-134| ... | @@ -1,12 +1,12 @@ | ... | @@ -1,12 +1,12 @@ |
| 1 | import { useCallback, useEffect, useMemo, useRef } from "react"; | 1 | import { useCallback, useEffect, useMemo } from "react"; |
| 2 | 2 | ||
| 3 | import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | 5 | import { useCheckpointDiff } from "../../state/queries"; |
| 6 | import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff"; | 6 | import { useEnvironmentQuery } from "../../state/query"; |
| 7 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | 7 | import { reviewEnvironment } from "../../state/review"; |
| 8 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | 8 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 9 | import { useReviewDiffPreview } from "./reviewDiffPreviewState"; | 9 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 10 | import { | 10 | import { |
| 11 | buildReviewSectionItems, | 11 | buildReviewSectionItems, |
| 12 | getDefaultReviewSectionId, | 12 | getDefaultReviewSectionId, |
| ... | @@ -17,29 +17,30 @@ import { | ... | @@ -17,29 +17,30 @@ import { |
| 17 | setReviewAsyncError, | 17 | setReviewAsyncError, |
| 18 | setReviewGitSections, | 18 | setReviewGitSections, |
| 19 | setReviewSelectedSectionId, | 19 | setReviewSelectedSectionId, |
| 20 | setReviewTurnDiffLoading, | ||
| 21 | setReviewTurnDiff, | 20 | setReviewTurnDiff, |
| 21 | setReviewTurnDiffLoading, | ||
| 22 | type ReviewCacheForThread, | 22 | type ReviewCacheForThread, |
| 23 | } from "./reviewState"; | 23 | } from "./reviewState"; |
| 24 | 24 | ||
| 25 | export function useReviewSections(input: { | 25 | export function useReviewSections(input: { |
| 26 | readonly enabled?: boolean; | ||
| 26 | readonly environmentId?: EnvironmentId; | 27 | readonly environmentId?: EnvironmentId; |
| 27 | readonly threadId?: ThreadId; | 28 | readonly threadId?: ThreadId; |
| 28 | readonly reviewCache: ReviewCacheForThread; | 29 | readonly reviewCache: ReviewCacheForThread; |
| 29 | }) { | 30 | }) { |
| 30 | const { environmentId, reviewCache, threadId } = input; | 31 | const { environmentId, reviewCache, threadId } = input; |
| 32 | const enabled = input.enabled ?? true; | ||
| 31 | const selectedThread = useSelectedThreadDetail(); | 33 | const selectedThread = useSelectedThreadDetail(); |
| 32 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | 34 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 33 | const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd }); | 35 | const diffPreview = useEnvironmentQuery( |
| 34 | const refreshDiffPreview = diffPreview.refresh; | 36 | enabled && environmentId !== undefined && selectedThreadCwd !== null |
| 37 | ? reviewEnvironment.diffPreview({ | ||
| 38 | environmentId, | ||
| 39 | input: { cwd: selectedThreadCwd }, | ||
| 40 | }) | ||
| 41 | : null, | ||
| 42 | ); | ||
| 35 | const { loadingTurnIds } = reviewCache.asyncState; | 43 | const { loadingTurnIds } = reviewCache.asyncState; |
| 36 | const error = diffPreview.error ?? reviewCache.asyncState.error; | ||
| 37 | const loadingGitDiffs = diffPreview.isPending; | ||
| 38 | const turnDiffByIdRef = useRef(reviewCache.turnDiffById); | ||
| 39 | |||
| 40 | useEffect(() => { | ||
| 41 | turnDiffByIdRef.current = reviewCache.turnDiffById; | ||
| 42 | }, [reviewCache.turnDiffById]); | ||
| 43 | 44 | ||
| 44 | useEffect(() => { | 45 | useEffect(() => { |
| 45 | if (reviewCache.threadKey && diffPreview.data) { | 46 | if (reviewCache.threadKey && diffPreview.data) { |
| ... | @@ -51,14 +52,16 @@ export function useReviewSections(input: { | ... | @@ -51,14 +52,16 @@ export function useReviewSections(input: { |
| 51 | () => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []), | 52 | () => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []), |
| 52 | [selectedThread?.checkpoints], | 53 | [selectedThread?.checkpoints], |
| 53 | ); | 54 | ); |
| 54 | const checkpointBySectionId = useMemo(() => { | 55 | const checkpointBySectionId = useMemo( |
| 55 | return Object.fromEntries( | 56 | () => |
| 56 | readyCheckpoints.map((checkpoint) => [ | 57 | Object.fromEntries( |
| 57 | getReviewSectionIdForCheckpoint(checkpoint), | 58 | readyCheckpoints.map((checkpoint) => [ |
| 58 | checkpoint, | 59 | getReviewSectionIdForCheckpoint(checkpoint), |
| 59 | ]), | 60 | checkpoint, |
| 60 | ) as Record<string, OrchestrationCheckpointSummary>; | 61 | ]), |
| 61 | }, [readyCheckpoints]); | 62 | ) as Record<string, OrchestrationCheckpointSummary>, |
| 63 | [readyCheckpoints], | ||
| 64 | ); | ||
| 62 | const reviewSections = useMemo( | 65 | const reviewSections = useMemo( |
| 63 | () => | 66 | () => |
| 64 | buildReviewSectionItems({ | 67 | buildReviewSectionItems({ |
| ... | @@ -87,7 +90,6 @@ export function useReviewSections(input: { | ... | @@ -87,7 +90,6 @@ export function useReviewSections(input: { |
| 87 | () => getDefaultReviewSectionId(reviewSections), | 90 | () => getDefaultReviewSectionId(reviewSections), |
| 88 | [reviewSections], | 91 | [reviewSections], |
| 89 | ); | 92 | ); |
| 90 | const hasReviewSections = reviewSections.length > 0; | ||
| 91 | const selectedSectionIdExists = useMemo( | 93 | const selectedSectionIdExists = useMemo( |
| 92 | () => | 94 | () => |
| 93 | reviewCache.selectedSectionId | 95 | reviewCache.selectedSectionId |
| ... | @@ -96,140 +98,69 @@ export function useReviewSections(input: { | ... | @@ -96,140 +98,69 @@ export function useReviewSections(input: { |
| 96 | [reviewCache.selectedSectionId, reviewSections], | 98 | [reviewCache.selectedSectionId, reviewSections], |
| 97 | ); | 99 | ); |
| 98 | 100 | ||
| 99 | const loadTurnDiff = useCallback( | ||
| 100 | async (checkpoint: OrchestrationCheckpointSummary, force = false) => { | ||
| 101 | if (!environmentId || !threadId) { | ||
| 102 | return; | ||
| 103 | } | ||
| 104 | |||
| 105 | const sectionId = getReviewSectionIdForCheckpoint(checkpoint); | ||
| 106 | if (reviewCache.threadKey) { | ||
| 107 | setReviewSelectedSectionId(reviewCache.threadKey, sectionId); | ||
| 108 | } | ||
| 109 | |||
| 110 | if (!force && turnDiffByIdRef.current[sectionId] !== undefined) { | ||
| 111 | return; | ||
| 112 | } | ||
| 113 | |||
| 114 | const target = { | ||
| 115 | environmentId, | ||
| 116 | threadId, | ||
| 117 | fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1), | ||
| 118 | toTurnCount: checkpoint.checkpointTurnCount, | ||
| 119 | ignoreWhitespace: false, | ||
| 120 | cacheScope: sectionId, | ||
| 121 | }; | ||
| 122 | const cached = checkpointDiffManager.getSnapshot(target).data; | ||
| 123 | if (!force && cached) { | ||
| 124 | if (reviewCache.threadKey) { | ||
| 125 | setReviewTurnDiff(reviewCache.threadKey, sectionId, cached.diff); | ||
| 126 | } | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | |||
| 130 | if (!getEnvironmentClient(environmentId)) { | ||
| 131 | if (reviewCache.threadKey) { | ||
| 132 | setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready."); | ||
| 133 | } | ||
| 134 | return; | ||
| 135 | } | ||
| 136 | |||
| 137 | if (reviewCache.threadKey) { | ||
| 138 | setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true); | ||
| 139 | setReviewAsyncError(reviewCache.threadKey, null); | ||
| 140 | } | ||
| 141 | try { | ||
| 142 | const result = await loadCheckpointDiff(target, { force }); | ||
| 143 | if (reviewCache.threadKey) { | ||
| 144 | if (result) { | ||
| 145 | setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff); | ||
| 146 | } | ||
| 147 | } | ||
| 148 | } catch (cause) { | ||
| 149 | if (reviewCache.threadKey) { | ||
| 150 | setReviewAsyncError( | ||
| 151 | reviewCache.threadKey, | ||
| 152 | cause instanceof Error ? cause.message : "Failed to load turn diff.", | ||
| 153 | ); | ||
| 154 | } | ||
| 155 | } finally { | ||
| 156 | if (reviewCache.threadKey) { | ||
| 157 | setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | }, | ||
| 161 | [environmentId, reviewCache.threadKey, threadId], | ||
| 162 | ); | ||
| 163 | |||
| 164 | useEffect(() => { | 101 | useEffect(() => { |
| 165 | if (!hasReviewSections) { | 102 | if ( |
| 166 | return; | 103 | reviewSections.length > 0 && |
| 167 | } | 104 | reviewCache.threadKey && |
| 168 | 105 | (!reviewCache.selectedSectionId || !selectedSectionIdExists) | |
| 169 | if (reviewCache.threadKey && (!reviewCache.selectedSectionId || !selectedSectionIdExists)) { | 106 | ) { |
| 170 | setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId); | 107 | setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId); |
| 171 | } | 108 | } |
| 172 | }, [ | 109 | }, [ |
| 173 | fallbackSectionId, | 110 | fallbackSectionId, |
| 174 | hasReviewSections, | ||
| 175 | reviewCache.selectedSectionId, | 111 | reviewCache.selectedSectionId, |
| 176 | reviewCache.threadKey, | 112 | reviewCache.threadKey, |
| 113 | reviewSections.length, | ||
| 177 | selectedSectionIdExists, | 114 | selectedSectionIdExists, |
| 178 | ]); | 115 | ]); |
| 179 | 116 | ||
| 180 | const latestCheckpoint = readyCheckpoints[0] ?? null; | 117 | let activeCheckpoint = readyCheckpoints[0] ?? null; |
| 181 | const latestSectionId = latestCheckpoint | 118 | if (selectedSection?.kind === "turn") { |
| 182 | ? getReviewSectionIdForCheckpoint(latestCheckpoint) | 119 | activeCheckpoint = checkpointBySectionId[selectedSection.id] ?? activeCheckpoint; |
| 120 | } | ||
| 121 | const activeSectionId = activeCheckpoint | ||
| 122 | ? getReviewSectionIdForCheckpoint(activeCheckpoint) | ||
| 183 | : null; | 123 | : null; |
| 184 | const latestTurnDiffLoaded = latestSectionId | 124 | const activeTurnDiff = useCheckpointDiff({ |
| 185 | ? reviewCache.turnDiffById[latestSectionId] !== undefined | 125 | environmentId: enabled ? (environmentId ?? null) : null, |
| 186 | : true; | 126 | threadId: enabled ? (threadId ?? null) : null, |
| 187 | const latestTurnDiffLoading = latestSectionId ? loadingTurnIds[latestSectionId] === true : false; | 127 | fromTurnCount: |
| 128 | enabled && activeCheckpoint ? Math.max(0, activeCheckpoint.checkpointTurnCount - 1) : null, | ||
| 129 | toTurnCount: enabled ? (activeCheckpoint?.checkpointTurnCount ?? null) : null, | ||
| 130 | ignoreWhitespace: false, | ||
| 131 | }); | ||
| 188 | 132 | ||
| 189 | useEffect(() => { | 133 | useEffect(() => { |
| 190 | if (!latestCheckpoint || !latestSectionId || latestTurnDiffLoaded || latestTurnDiffLoading) { | 134 | if (!reviewCache.threadKey || !activeSectionId) { |
| 191 | return; | 135 | return; |
| 192 | } | 136 | } |
| 193 | 137 | setReviewTurnDiffLoading(reviewCache.threadKey, activeSectionId, activeTurnDiff.isPending); | |
| 194 | void loadTurnDiff(latestCheckpoint); | 138 | }, [activeSectionId, activeTurnDiff.isPending, reviewCache.threadKey]); |
| 195 | }, [ | ||
| 196 | latestCheckpoint, | ||
| 197 | latestSectionId, | ||
| 198 | latestTurnDiffLoaded, | ||
| 199 | latestTurnDiffLoading, | ||
| 200 | loadTurnDiff, | ||
| 201 | ]); | ||
| 202 | |||
| 203 | const selectedTurnCheckpoint = | ||
| 204 | selectedSection?.kind === "turn" ? (checkpointBySectionId[selectedSection.id] ?? null) : null; | ||
| 205 | const selectedTurnDiffMissing = | ||
| 206 | selectedSection?.kind === "turn" && selectedSection.diff === null && selectedTurnCheckpoint; | ||
| 207 | const selectedTurnDiffLoading = | ||
| 208 | selectedSection?.kind === "turn" ? loadingTurnIds[selectedSection.id] === true : false; | ||
| 209 | 139 | ||
| 210 | useEffect(() => { | 140 | useEffect(() => { |
| 211 | if (!selectedTurnDiffMissing || selectedTurnDiffLoading) { | 141 | if (!reviewCache.threadKey || !activeSectionId || !activeTurnDiff.data) { |
| 212 | return; | 142 | return; |
| 213 | } | 143 | } |
| 144 | setReviewTurnDiff(reviewCache.threadKey, activeSectionId, activeTurnDiff.data.diff); | ||
| 145 | setReviewAsyncError(reviewCache.threadKey, null); | ||
| 146 | }, [activeSectionId, activeTurnDiff.data, reviewCache.threadKey]); | ||
| 214 | 147 | ||
| 215 | void loadTurnDiff(selectedTurnDiffMissing); | 148 | useEffect(() => { |
| 216 | }, [loadTurnDiff, selectedTurnDiffLoading, selectedTurnDiffMissing]); | 149 | if (reviewCache.threadKey && activeTurnDiff.error) { |
| 150 | setReviewAsyncError(reviewCache.threadKey, activeTurnDiff.error); | ||
| 151 | } | ||
| 152 | }, [activeTurnDiff.error, reviewCache.threadKey]); | ||
| 217 | 153 | ||
| 218 | const refreshSelectedSection = useCallback(async () => { | 154 | const refreshSelectedSection = useCallback(async () => { |
| 219 | if (!selectedSection) { | 155 | if (!enabled) { |
| 220 | return; | 156 | return; |
| 221 | } | 157 | } |
| 222 | 158 | if (selectedSection?.kind === "turn") { | |
| 223 | if (selectedSection.kind === "turn") { | 159 | activeTurnDiff.refresh(); |
| 224 | const checkpoint = checkpointBySectionId[selectedSection.id]; | ||
| 225 | if (checkpoint) { | ||
| 226 | await loadTurnDiff(checkpoint, true); | ||
| 227 | } | ||
| 228 | return; | 160 | return; |
| 229 | } | 161 | } |
| 230 | 162 | diffPreview.refresh(); | |
| 231 | refreshDiffPreview(); | 163 | }, [activeTurnDiff, diffPreview, enabled, selectedSection?.kind]); |
| 232 | }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]); | ||
| 233 | 164 | ||
| 234 | const selectSection = useCallback( | 165 | const selectSection = useCallback( |
| 235 | (sectionId: string) => { | 166 | (sectionId: string) => { |
| ... | @@ -241,8 +172,8 @@ export function useReviewSections(input: { | ... | @@ -241,8 +172,8 @@ export function useReviewSections(input: { |
| 241 | ); | 172 | ); |
| 242 | 173 | ||
| 243 | return { | 174 | return { |
| 244 | error, | 175 | error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error, |
| 245 | loadingGitDiffs, | 176 | loadingGitDiffs: diffPreview.isPending, |
| 246 | loadingTurnIds, | 177 | loadingTurnIds, |
| 247 | reviewSections, | 178 | reviewSections, |
| 248 | selectedSection, | 179 | selectedSection, |
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx+66-80| ... | @@ -1,18 +1,19 @@ | ... | @@ -1,18 +1,19 @@ |
| 1 | import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; | 1 | import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import { memo, useCallback, useEffect, useRef, useState } from "react"; | 3 | import { memo, useCallback, useEffect, useMemo, useRef } from "react"; |
| 4 | import { Pressable, View } from "react-native"; | 4 | import { Pressable, View } from "react-native"; |
| 5 | 5 | ||
| 6 | import { AppText as Text } from "../../components/AppText"; | 6 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | 7 | import { terminalEnvironment } from "../../state/terminal"; |
| 8 | import { | 8 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 9 | attachTerminalSession, | 9 | import { useAttachedTerminalSession } from "../../state/use-terminal-session"; |
| 10 | useTerminalSession, | ||
| 11 | useTerminalSessionTarget, | ||
| 12 | } from "../../state/use-terminal-session"; | ||
| 13 | import { TerminalSurface } from "./NativeTerminalSurface"; | 10 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 14 | import { hasNativeTerminalSurface } from "./nativeTerminalModule"; | 11 | import { hasNativeTerminalSurface } from "./nativeTerminalModule"; |
| 15 | import { terminalDebugLog } from "./terminalDebugLog"; | 12 | import { |
| 13 | buildThreadTerminalAttachInput, | ||
| 14 | type TerminalGridSize, | ||
| 15 | type ThreadTerminalSubscriptionIdentity, | ||
| 16 | } from "./threadTerminalPanelModel"; | ||
| 16 | 17 | ||
| 17 | interface ThreadTerminalPanelProps { | 18 | interface ThreadTerminalPanelProps { |
| 18 | readonly environmentId: EnvironmentId; | 19 | readonly environmentId: EnvironmentId; |
| ... | @@ -29,108 +30,93 @@ const DEFAULT_TERMINAL_ROWS = 24; | ... | @@ -29,108 +30,93 @@ const DEFAULT_TERMINAL_ROWS = 24; |
| 29 | export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( | 30 | export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( |
| 30 | props: ThreadTerminalPanelProps, | 31 | props: ThreadTerminalPanelProps, |
| 31 | ) { | 32 | ) { |
| 33 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); | ||
| 34 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); | ||
| 32 | const nativeTerminalAvailable = hasNativeTerminalSurface(); | 35 | const nativeTerminalAvailable = hasNativeTerminalSurface(); |
| 33 | const terminalId = DEFAULT_TERMINAL_ID; | 36 | const terminalId = DEFAULT_TERMINAL_ID; |
| 34 | const target = useTerminalSessionTarget({ | 37 | const lastGridSizeRef = useRef<TerminalGridSize>({ |
| 35 | environmentId: props.environmentId, | ||
| 36 | threadId: props.threadId, | ||
| 37 | terminalId, | ||
| 38 | }); | ||
| 39 | const terminal = useTerminalSession(target); | ||
| 40 | const [lastGridSize, setLastGridSize] = useState({ | ||
| 41 | cols: DEFAULT_TERMINAL_COLS, | 38 | cols: DEFAULT_TERMINAL_COLS, |
| 42 | rows: DEFAULT_TERMINAL_ROWS, | 39 | rows: DEFAULT_TERMINAL_ROWS, |
| 43 | }); | 40 | }); |
| 44 | const lastGridSizeRef = useRef(lastGridSize); | 41 | const subscriptionIdentity = useMemo<ThreadTerminalSubscriptionIdentity>( |
| 45 | lastGridSizeRef.current = lastGridSize; | 42 | () => ({ |
| 43 | environmentId: props.environmentId, | ||
| 44 | threadId: props.threadId, | ||
| 45 | terminalId, | ||
| 46 | cwd: props.cwd, | ||
| 47 | worktreePath: props.worktreePath, | ||
| 48 | }), | ||
| 49 | [props.cwd, props.environmentId, props.threadId, props.worktreePath, terminalId], | ||
| 50 | ); | ||
| 51 | const attachInput = useMemo( | ||
| 52 | () => | ||
| 53 | props.visible | ||
| 54 | ? buildThreadTerminalAttachInput(subscriptionIdentity, lastGridSizeRef.current) | ||
| 55 | : null, | ||
| 56 | [props.visible, subscriptionIdentity], | ||
| 57 | ); | ||
| 58 | const terminal = useAttachedTerminalSession({ | ||
| 59 | environmentId: props.environmentId, | ||
| 60 | terminal: attachInput, | ||
| 61 | }); | ||
| 46 | 62 | ||
| 47 | const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; | 63 | const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; |
| 48 | const isRunning = terminal.status === "running" || terminal.status === "starting"; | 64 | const isRunning = terminal.status === "running" || terminal.status === "starting"; |
| 49 | 65 | ||
| 50 | useEffect(() => { | 66 | const sendResize = useCallback( |
| 51 | if (!props.visible) { | 67 | (size: TerminalGridSize) => { |
| 52 | return; | 68 | void resizeTerminal({ |
| 53 | } | ||
| 54 | |||
| 55 | const client = getEnvironmentClient(props.environmentId); | ||
| 56 | if (!client) { | ||
| 57 | terminalDebugLog("panel:attach-skip", { | ||
| 58 | reason: "no-environment-client", | ||
| 59 | environmentId: props.environmentId, | 69 | environmentId: props.environmentId, |
| 70 | input: { | ||
| 71 | threadId: props.threadId, | ||
| 72 | terminalId, | ||
| 73 | cols: size.cols, | ||
| 74 | rows: size.rows, | ||
| 75 | }, | ||
| 60 | }); | 76 | }); |
| 61 | return; | 77 | }, |
| 62 | } | 78 | [props.environmentId, props.threadId, resizeTerminal, terminalId], |
| 63 | 79 | ); | |
| 64 | terminalDebugLog("panel:attach", { | ||
| 65 | environmentId: props.environmentId, | ||
| 66 | threadId: props.threadId, | ||
| 67 | terminalId, | ||
| 68 | }); | ||
| 69 | 80 | ||
| 70 | return attachTerminalSession({ | 81 | useEffect(() => { |
| 71 | environmentId: props.environmentId, | 82 | if (isRunning) { |
| 72 | client, | 83 | sendResize(lastGridSizeRef.current); |
| 73 | terminal: { | 84 | } |
| 74 | threadId: props.threadId, | 85 | }, [isRunning, sendResize]); |
| 75 | terminalId, | ||
| 76 | cwd: props.cwd, | ||
| 77 | worktreePath: props.worktreePath, | ||
| 78 | cols: lastGridSizeRef.current.cols, | ||
| 79 | rows: lastGridSizeRef.current.rows, | ||
| 80 | }, | ||
| 81 | }); | ||
| 82 | }, [ | ||
| 83 | props.cwd, | ||
| 84 | props.environmentId, | ||
| 85 | props.threadId, | ||
| 86 | props.worktreePath, | ||
| 87 | props.visible, | ||
| 88 | terminalId, | ||
| 89 | ]); | ||
| 90 | 86 | ||
| 91 | const handleInput = useCallback( | 87 | const handleInput = useCallback( |
| 92 | (data: string) => { | 88 | (data: string) => { |
| 93 | const client = getEnvironmentClient(props.environmentId); | 89 | if (!isRunning) { |
| 94 | if (!client || !isRunning) { | ||
| 95 | return; | 90 | return; |
| 96 | } | 91 | } |
| 97 | 92 | ||
| 98 | void client.terminal.write({ | 93 | void writeTerminal({ |
| 99 | threadId: props.threadId, | 94 | environmentId: props.environmentId, |
| 100 | terminalId, | 95 | input: { |
| 101 | data, | 96 | threadId: props.threadId, |
| 97 | terminalId, | ||
| 98 | data, | ||
| 99 | }, | ||
| 102 | }); | 100 | }); |
| 103 | }, | 101 | }, |
| 104 | [isRunning, props.environmentId, props.threadId, terminalId], | 102 | [isRunning, props.environmentId, props.threadId, terminalId, writeTerminal], |
| 105 | ); | 103 | ); |
| 106 | 104 | ||
| 107 | const handleResize = useCallback( | 105 | const handleResize = useCallback( |
| 108 | (size: { readonly cols: number; readonly rows: number }) => { | 106 | (size: TerminalGridSize) => { |
| 109 | if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) { | 107 | const previousSize = lastGridSizeRef.current; |
| 108 | if (size.cols === previousSize.cols && size.rows === previousSize.rows) { | ||
| 110 | return; | 109 | return; |
| 111 | } | 110 | } |
| 112 | 111 | ||
| 113 | setLastGridSize(size); | 112 | lastGridSizeRef.current = size; |
| 114 | const client = getEnvironmentClient(props.environmentId); | 113 | if (!isRunning) { |
| 115 | if (!client || !isRunning) { | ||
| 116 | return; | 114 | return; |
| 117 | } | 115 | } |
| 118 | 116 | ||
| 119 | void client.terminal.resize({ | 117 | sendResize(size); |
| 120 | threadId: props.threadId, | ||
| 121 | terminalId, | ||
| 122 | cols: size.cols, | ||
| 123 | rows: size.rows, | ||
| 124 | }); | ||
| 125 | }, | 118 | }, |
| 126 | [ | 119 | [isRunning, sendResize], |
| 127 | isRunning, | ||
| 128 | lastGridSize.cols, | ||
| 129 | lastGridSize.rows, | ||
| 130 | props.environmentId, | ||
| 131 | props.threadId, | ||
| 132 | terminalId, | ||
| 133 | ], | ||
| 134 | ); | 120 | ); |
| 135 | 121 | ||
| 136 | if (!props.visible) { | 122 | if (!props.visible) { |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+389-400| ... | @@ -1,10 +1,5 @@ | ... | @@ -1,10 +1,5 @@ |
| 1 | import { | 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | DEFAULT_TERMINAL_ID, | 2 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 3 | EnvironmentId, | ||
| 4 | type TerminalAttachStreamEvent, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; | ||
| 8 | import { SymbolView } from "expo-symbols"; | 3 | import { SymbolView } from "expo-symbols"; |
| 9 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 4 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 10 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| ... | @@ -24,17 +19,19 @@ import { | ... | @@ -24,17 +19,19 @@ import { |
| 24 | import { EmptyState } from "../../components/EmptyState"; | 19 | import { EmptyState } from "../../components/EmptyState"; |
| 25 | import { GlassSurface } from "../../components/GlassSurface"; | 20 | import { GlassSurface } from "../../components/GlassSurface"; |
| 26 | import { LoadingScreen } from "../../components/LoadingScreen"; | 21 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 22 | import { environmentCatalog } from "../../connection/catalog"; | ||
| 23 | import { useEnvironmentPresentation } from "../../state/presentation"; | ||
| 24 | import { terminalEnvironment } from "../../state/terminal"; | ||
| 25 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 26 | import { useWorkspaceState } from "../../state/workspace"; | ||
| 27 | import { buildThreadTerminalNavigation } from "../../lib/routes"; | 27 | import { buildThreadTerminalNavigation } from "../../lib/routes"; |
| 28 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | ||
| 29 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | ||
| 30 | import { | 28 | import { |
| 31 | attachTerminalSession, | 29 | useAttachedTerminalSession, |
| 32 | useKnownTerminalSessions, | 30 | useKnownTerminalSessions, |
| 33 | useTerminalSession, | ||
| 34 | useTerminalSessionTarget, | ||
| 35 | } from "../../state/use-terminal-session"; | 31 | } from "../../state/use-terminal-session"; |
| 36 | import { useThreadSelection } from "../../state/use-thread-selection"; | 32 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 37 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | 33 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 34 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; | ||
| 38 | import { TerminalSurface } from "./NativeTerminalSurface"; | 35 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 39 | import { getPierreTerminalTheme } from "./terminalTheme"; | 36 | import { getPierreTerminalTheme } from "./terminalTheme"; |
| 40 | import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; | 37 | import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; |
| ... | @@ -44,11 +41,10 @@ import { | ... | @@ -44,11 +41,10 @@ import { |
| 44 | getTerminalSurfaceReplayBuffer, | 41 | getTerminalSurfaceReplayBuffer, |
| 45 | TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, | 42 | TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, |
| 46 | } from "./terminalBufferReplay"; | 43 | } from "./terminalBufferReplay"; |
| 47 | import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; | ||
| 48 | import { | 44 | import { |
| 49 | resolveTerminalOpenLocation, | 45 | resolveTerminalOpenLocation, |
| 50 | stagePendingTerminalLaunch, | ||
| 51 | takePendingTerminalLaunch, | 46 | takePendingTerminalLaunch, |
| 47 | type PendingTerminalLaunch, | ||
| 52 | } from "./terminalLaunchContext"; | 48 | } from "./terminalLaunchContext"; |
| 53 | import { | 49 | import { |
| 54 | basename, | 50 | basename, |
| ... | @@ -158,8 +154,12 @@ function pickRunningTerminalSessionForBootstrap( | ... | @@ -158,8 +154,12 @@ function pickRunningTerminalSessionForBootstrap( |
| 158 | 154 | ||
| 159 | export function ThreadTerminalRouteScreen() { | 155 | export function ThreadTerminalRouteScreen() { |
| 160 | const router = useRouter(); | 156 | const router = useRouter(); |
| 157 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); | ||
| 158 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); | ||
| 159 | const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); | ||
| 160 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); | ||
| 161 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | 161 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; |
| 162 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); | 162 | const { state: workspaceState } = useWorkspaceState(); |
| 163 | const params = useLocalSearchParams<{ | 163 | const params = useLocalSearchParams<{ |
| 164 | environmentId?: string | string[]; | 164 | environmentId?: string | string[]; |
| 165 | threadId?: string | string[]; | 165 | threadId?: string | string[]; |
| ... | @@ -174,6 +174,8 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -174,6 +174,8 @@ export function ThreadTerminalRouteScreen() { |
| 174 | ? EnvironmentId.make(routeEnvironmentIdRaw) | 174 | ? EnvironmentId.make(routeEnvironmentIdRaw) |
| 175 | : null; | 175 | : null; |
| 176 | const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null; | 176 | const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null; |
| 177 | const environment = useEnvironmentPresentation(routeEnvironmentId); | ||
| 178 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; | ||
| 177 | const requestedTerminalId = firstRouteParam(params.terminalId); | 179 | const requestedTerminalId = firstRouteParam(params.terminalId); |
| 178 | const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; | 180 | const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; |
| 179 | const cachedFontSize = getCachedTerminalFontSize(); | 181 | const cachedFontSize = getCachedTerminalFontSize(); |
| ... | @@ -189,6 +191,47 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -189,6 +191,47 @@ export function ThreadTerminalRouteScreen() { |
| 189 | environmentId: selectedThread?.environmentId ?? null, | 191 | environmentId: selectedThread?.environmentId ?? null, |
| 190 | threadId: selectedThread?.id ?? null, | 192 | threadId: selectedThread?.id ?? null, |
| 191 | }); | 193 | }); |
| 194 | const runningSession = useMemo( | ||
| 195 | () => pickRunningTerminalSessionForBootstrap(knownSessions), | ||
| 196 | [knownSessions], | ||
| 197 | ); | ||
| 198 | const activeKnownSession = useMemo( | ||
| 199 | () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, | ||
| 200 | [knownSessions, terminalId], | ||
| 201 | ); | ||
| 202 | const launchTarget = useMemo( | ||
| 203 | () => | ||
| 204 | selectedThread | ||
| 205 | ? { | ||
| 206 | environmentId: selectedThread.environmentId, | ||
| 207 | threadId: selectedThread.id, | ||
| 208 | terminalId, | ||
| 209 | } | ||
| 210 | : null, | ||
| 211 | [selectedThread, terminalId], | ||
| 212 | ); | ||
| 213 | const launchTargetKey = launchTarget | ||
| 214 | ? `${launchTarget.environmentId}:${launchTarget.threadId}:${launchTarget.terminalId}` | ||
| 215 | : null; | ||
| 216 | const [pendingLaunchEntry, setPendingLaunchEntry] = useState<{ | ||
| 217 | readonly key: string | null; | ||
| 218 | readonly launch: PendingTerminalLaunch | null; | ||
| 219 | }>(() => ({ | ||
| 220 | key: launchTargetKey, | ||
| 221 | launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), | ||
| 222 | })); | ||
| 223 | const pendingLaunch = | ||
| 224 | pendingLaunchEntry.key === launchTargetKey ? pendingLaunchEntry.launch : null; | ||
| 225 | const hasResolvedPendingLaunch = pendingLaunchEntry.key === launchTargetKey; | ||
| 226 | const [initialAttachGridEntry, setInitialAttachGridEntry] = useState(() => ({ | ||
| 227 | key: launchTargetKey, | ||
| 228 | size: cachedRouteGridSize ?? { | ||
| 229 | cols: DEFAULT_TERMINAL_COLS, | ||
| 230 | rows: DEFAULT_TERMINAL_ROWS, | ||
| 231 | }, | ||
| 232 | })); | ||
| 233 | const initialAttachGridSize = | ||
| 234 | initialAttachGridEntry.key === launchTargetKey ? initialAttachGridEntry.size : null; | ||
| 192 | const [lastGridSize, setLastGridSize] = useState( | 235 | const [lastGridSize, setLastGridSize] = useState( |
| 193 | cachedRouteGridSize ?? { | 236 | cachedRouteGridSize ?? { |
| 194 | cols: DEFAULT_TERMINAL_COLS, | 237 | cols: DEFAULT_TERMINAL_COLS, |
| ... | @@ -198,11 +241,10 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -198,11 +241,10 @@ export function ThreadTerminalRouteScreen() { |
| 198 | const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); | 241 | const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); |
| 199 | const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); | 242 | const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); |
| 200 | const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); | 243 | const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); |
| 201 | const hasOpenedRef = useRef(false); | ||
| 202 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 244 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 203 | const attachStreamLogCountRef = useRef(0); | ||
| 204 | const firstNonEmptyBufferLoggedRef = useRef(false); | 245 | const firstNonEmptyBufferLoggedRef = useRef(false); |
| 205 | const lastBufferReplayKeyRef = useRef<string | null>(null); | 246 | const lastBufferReplayKeyRef = useRef<string | null>(null); |
| 247 | const sentInitialInputKeyRef = useRef<string | null>(null); | ||
| 206 | const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null); | 248 | const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null); |
| 207 | const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( | 249 | const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( |
| 208 | cachedFontSize !== null, | 250 | cachedFontSize !== null, |
| ... | @@ -216,12 +258,78 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -216,12 +258,78 @@ export function ThreadTerminalRouteScreen() { |
| 216 | terminalId, | 258 | terminalId, |
| 217 | value: null, | 259 | value: null, |
| 218 | }); | 260 | }); |
| 219 | const target = useTerminalSessionTarget({ | 261 | const shouldRedirectToRunningTerminal = |
| 262 | requestedTerminalId === null && | ||
| 263 | runningSession !== null && | ||
| 264 | runningSession.target.terminalId !== terminalId; | ||
| 265 | const launchLocationCandidate = useMemo(() => { | ||
| 266 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | ||
| 267 | return null; | ||
| 268 | } | ||
| 269 | if (pendingLaunch) { | ||
| 270 | return { | ||
| 271 | cwd: pendingLaunch.cwd, | ||
| 272 | worktreePath: pendingLaunch.worktreePath, | ||
| 273 | }; | ||
| 274 | } | ||
| 275 | return resolveTerminalOpenLocation({ | ||
| 276 | terminalLocation: activeKnownSession?.state.summary ?? null, | ||
| 277 | activeSessionLocation: activeKnownSession?.state.summary ?? null, | ||
| 278 | workspaceRoot: selectedThreadProject.workspaceRoot, | ||
| 279 | threadShellWorktreePath: selectedThread.worktreePath ?? null, | ||
| 280 | threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, | ||
| 281 | }); | ||
| 282 | }, [ | ||
| 283 | activeKnownSession?.state.summary, | ||
| 284 | pendingLaunch, | ||
| 285 | selectedThread, | ||
| 286 | selectedThreadDetail?.worktreePath, | ||
| 287 | selectedThreadProject?.workspaceRoot, | ||
| 288 | ]); | ||
| 289 | const [initialLaunchLocationEntry, setInitialLaunchLocationEntry] = useState(() => ({ | ||
| 290 | key: launchTargetKey, | ||
| 291 | location: launchLocationCandidate, | ||
| 292 | })); | ||
| 293 | const launchLocation = | ||
| 294 | initialLaunchLocationEntry.key === launchTargetKey ? initialLaunchLocationEntry.location : null; | ||
| 295 | const terminalAttachInput = useMemo( | ||
| 296 | () => | ||
| 297 | selectedThread !== null && | ||
| 298 | launchLocation !== null && | ||
| 299 | hasResolvedPendingLaunch && | ||
| 300 | initialAttachGridSize !== null && | ||
| 301 | hasResolvedFontPreference && | ||
| 302 | hasMeasuredSurface && | ||
| 303 | isEnvironmentReady && | ||
| 304 | !shouldRedirectToRunningTerminal | ||
| 305 | ? { | ||
| 306 | threadId: selectedThread.id, | ||
| 307 | terminalId, | ||
| 308 | cwd: launchLocation.cwd, | ||
| 309 | worktreePath: launchLocation.worktreePath, | ||
| 310 | cols: initialAttachGridSize.cols, | ||
| 311 | rows: initialAttachGridSize.rows, | ||
| 312 | ...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}), | ||
| 313 | ...(pendingLaunch ? { restartIfNotRunning: true } : {}), | ||
| 314 | } | ||
| 315 | : null, | ||
| 316 | [ | ||
| 317 | hasMeasuredSurface, | ||
| 318 | hasResolvedFontPreference, | ||
| 319 | hasResolvedPendingLaunch, | ||
| 320 | initialAttachGridSize, | ||
| 321 | isEnvironmentReady, | ||
| 322 | launchLocation, | ||
| 323 | pendingLaunch, | ||
| 324 | selectedThread, | ||
| 325 | shouldRedirectToRunningTerminal, | ||
| 326 | terminalId, | ||
| 327 | ], | ||
| 328 | ); | ||
| 329 | const terminal = useAttachedTerminalSession({ | ||
| 220 | environmentId: selectedThread?.environmentId ?? null, | 330 | environmentId: selectedThread?.environmentId ?? null, |
| 221 | threadId: selectedThread?.id ?? null, | 331 | terminal: terminalAttachInput, |
| 222 | terminalId, | ||
| 223 | }); | 332 | }); |
| 224 | const terminal = useTerminalSession(target); | ||
| 225 | const terminalKey = selectedThread | 333 | const terminalKey = selectedThread |
| 226 | ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` | 334 | ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` |
| 227 | : terminalId; | 335 | : terminalId; |
| ... | @@ -293,23 +401,6 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -293,23 +401,6 @@ export function ThreadTerminalRouteScreen() { |
| 293 | () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), | 401 | () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), |
| 294 | [selectedEnvironmentConnection?.environmentLabel], | 402 | [selectedEnvironmentConnection?.environmentLabel], |
| 295 | ); | 403 | ); |
| 296 | const runningSession = useMemo( | ||
| 297 | () => pickRunningTerminalSessionForBootstrap(knownSessions), | ||
| 298 | [knownSessions], | ||
| 299 | ); | ||
| 300 | const activeKnownSession = useMemo( | ||
| 301 | () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, | ||
| 302 | [knownSessions, terminalId], | ||
| 303 | ); | ||
| 304 | |||
| 305 | const terminalAttachLaunchHintsRef = useRef({ | ||
| 306 | terminalSummary: terminal.summary, | ||
| 307 | activeKnownSummary: activeKnownSession?.state.summary ?? null, | ||
| 308 | }); | ||
| 309 | terminalAttachLaunchHintsRef.current = { | ||
| 310 | terminalSummary: terminal.summary, | ||
| 311 | activeKnownSummary: activeKnownSession?.state.summary ?? null, | ||
| 312 | }; | ||
| 313 | 404 | ||
| 314 | const terminalTheme = getPierreTerminalTheme(appearanceScheme); | 405 | const terminalTheme = getPierreTerminalTheme(appearanceScheme); |
| 315 | const pendingModifier = | 406 | const pendingModifier = |
| ... | @@ -406,145 +497,88 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -406,145 +497,88 @@ export function ThreadTerminalRouteScreen() { |
| 406 | ], | 497 | ], |
| 407 | ); | 498 | ); |
| 408 | 499 | ||
| 409 | const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => { | 500 | useEffect(() => { |
| 410 | const n = ++attachStreamLogCountRef.current; | 501 | if (pendingLaunchEntry.key === launchTargetKey) { |
| 411 | if (event.type === "output" && n > 32 && n % 64 !== 0) { | ||
| 412 | return; | 502 | return; |
| 413 | } | 503 | } |
| 414 | if (event.type === "snapshot") { | 504 | setPendingLaunchEntry({ |
| 415 | terminalDebugLog("attach:stream", { | 505 | key: launchTargetKey, |
| 416 | n, | 506 | launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), |
| 417 | type: event.type, | 507 | }); |
| 418 | status: event.snapshot.status, | 508 | }, [launchTarget, launchTargetKey, pendingLaunchEntry.key]); |
| 419 | historyLen: event.snapshot.history.length, | 509 | |
| 420 | cwd: event.snapshot.cwd, | 510 | useEffect(() => { |
| 421 | }); | 511 | if (initialAttachGridEntry.key === launchTargetKey) { |
| 422 | return; | 512 | return; |
| 423 | } | 513 | } |
| 424 | if (event.type === "output") { | 514 | setInitialAttachGridEntry({ |
| 425 | terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length }); | 515 | key: launchTargetKey, |
| 516 | size: cachedRouteGridSize ?? { | ||
| 517 | cols: DEFAULT_TERMINAL_COLS, | ||
| 518 | rows: DEFAULT_TERMINAL_ROWS, | ||
| 519 | }, | ||
| 520 | }); | ||
| 521 | }, [cachedRouteGridSize, initialAttachGridEntry.key, launchTargetKey]); | ||
| 522 | |||
| 523 | useEffect(() => { | ||
| 524 | if ( | ||
| 525 | initialLaunchLocationEntry.key === launchTargetKey && | ||
| 526 | initialLaunchLocationEntry.location !== null | ||
| 527 | ) { | ||
| 426 | return; | 528 | return; |
| 427 | } | 529 | } |
| 428 | terminalDebugLog("attach:stream", { n, type: event.type }); | 530 | if (initialLaunchLocationEntry.key === launchTargetKey && launchLocationCandidate === null) { |
| 429 | }, []); | 531 | return; |
| 430 | |||
| 431 | const attachTerminal = useCallback(() => { | ||
| 432 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | ||
| 433 | terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" }); | ||
| 434 | return null; | ||
| 435 | } | 532 | } |
| 533 | setInitialLaunchLocationEntry({ | ||
| 534 | key: launchTargetKey, | ||
| 535 | location: launchLocationCandidate, | ||
| 536 | }); | ||
| 537 | }, [ | ||
| 538 | initialLaunchLocationEntry.key, | ||
| 539 | initialLaunchLocationEntry.location, | ||
| 540 | launchLocationCandidate, | ||
| 541 | launchTargetKey, | ||
| 542 | ]); | ||
| 436 | 543 | ||
| 437 | const client = getEnvironmentClient(selectedThread.environmentId); | 544 | useEffect(() => { |
| 438 | if (!client) { | 545 | if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { |
| 439 | terminalDebugLog("attach:abort", { | 546 | return; |
| 440 | reason: "no-environment-client", | ||
| 441 | environmentId: selectedThread.environmentId, | ||
| 442 | }); | ||
| 443 | return null; | ||
| 444 | } | 547 | } |
| 548 | router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId)); | ||
| 549 | }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]); | ||
| 445 | 550 | ||
| 446 | const pendingLaunchTarget = { | 551 | useEffect(() => { |
| 552 | const initialInput = pendingLaunch?.initialInput; | ||
| 553 | if ( | ||
| 554 | !initialInput || | ||
| 555 | !selectedThread || | ||
| 556 | terminal.version === 0 || | ||
| 557 | sentInitialInputKeyRef.current === launchTargetKey | ||
| 558 | ) { | ||
| 559 | return; | ||
| 560 | } | ||
| 561 | sentInitialInputKeyRef.current = launchTargetKey; | ||
| 562 | void writeTerminal({ | ||
| 447 | environmentId: selectedThread.environmentId, | 563 | environmentId: selectedThread.environmentId, |
| 448 | threadId: selectedThread.id, | 564 | input: { |
| 449 | terminalId, | ||
| 450 | }; | ||
| 451 | const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget); | ||
| 452 | let initialInputSent = false; | ||
| 453 | |||
| 454 | try { | ||
| 455 | const launchLocation = pendingLaunch | ||
| 456 | ? { | ||
| 457 | cwd: pendingLaunch.cwd, | ||
| 458 | worktreePath: pendingLaunch.worktreePath, | ||
| 459 | } | ||
| 460 | : resolveTerminalOpenLocation({ | ||
| 461 | terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary, | ||
| 462 | activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary, | ||
| 463 | workspaceRoot: selectedThreadProject.workspaceRoot, | ||
| 464 | threadShellWorktreePath: selectedThread.worktreePath ?? null, | ||
| 465 | threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, | ||
| 466 | }); | ||
| 467 | |||
| 468 | terminalDebugLog("attach:start", { | ||
| 469 | terminalId, | ||
| 470 | threadId: selectedThread.id, | 565 | threadId: selectedThread.id, |
| 471 | cols: lastGridSize.cols, | 566 | terminalId, |
| 472 | rows: lastGridSize.rows, | 567 | data: initialInput, |
| 473 | cwd: launchLocation.cwd, | 568 | }, |
| 474 | worktreePath: launchLocation.worktreePath, | 569 | }); |
| 475 | }); | ||
| 476 | |||
| 477 | return attachTerminalSession({ | ||
| 478 | environmentId: selectedThread.environmentId, | ||
| 479 | client, | ||
| 480 | terminal: { | ||
| 481 | threadId: selectedThread.id, | ||
| 482 | terminalId, | ||
| 483 | cwd: launchLocation.cwd, | ||
| 484 | worktreePath: launchLocation.worktreePath, | ||
| 485 | cols: lastGridSize.cols, | ||
| 486 | rows: lastGridSize.rows, | ||
| 487 | env: pendingLaunch?.env, | ||
| 488 | ...(pendingLaunch ? { restartIfNotRunning: true } : {}), | ||
| 489 | }, | ||
| 490 | onEvent: logAttachStreamEvent, | ||
| 491 | onSnapshot: () => { | ||
| 492 | if (!pendingLaunch?.initialInput || initialInputSent) { | ||
| 493 | return; | ||
| 494 | } | ||
| 495 | |||
| 496 | initialInputSent = true; | ||
| 497 | void client.terminal.write({ | ||
| 498 | threadId: selectedThread.id, | ||
| 499 | terminalId, | ||
| 500 | data: pendingLaunch.initialInput, | ||
| 501 | }); | ||
| 502 | }, | ||
| 503 | }); | ||
| 504 | } catch (error) { | ||
| 505 | terminalDebugLog("attach:error", { | ||
| 506 | message: error instanceof Error ? error.message : String(error), | ||
| 507 | }); | ||
| 508 | if (pendingLaunch) { | ||
| 509 | stagePendingTerminalLaunch({ | ||
| 510 | target: pendingLaunchTarget, | ||
| 511 | launch: pendingLaunch, | ||
| 512 | }); | ||
| 513 | } | ||
| 514 | |||
| 515 | throw error; | ||
| 516 | } | ||
| 517 | }, [ | 570 | }, [ |
| 518 | lastGridSize.cols, | 571 | launchTargetKey, |
| 519 | lastGridSize.rows, | 572 | pendingLaunch?.initialInput, |
| 520 | logAttachStreamEvent, | ||
| 521 | selectedThreadDetail?.worktreePath, | ||
| 522 | selectedThread, | 573 | selectedThread, |
| 523 | selectedThreadProject?.workspaceRoot, | 574 | terminal.version, |
| 524 | terminalId, | 575 | terminalId, |
| 576 | writeTerminal, | ||
| 525 | ]); | 577 | ]); |
| 526 | 578 | ||
| 527 | const attachTerminalRef = useRef(attachTerminal); | ||
| 528 | attachTerminalRef.current = attachTerminal; | ||
| 529 | const selectedThreadRef = useRef(selectedThread); | ||
| 530 | selectedThreadRef.current = selectedThread; | ||
| 531 | const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject); | ||
| 532 | selectedThreadProjectBootstrapRef.current = selectedThreadProject; | ||
| 533 | const runningSessionRef = useRef(runningSession); | ||
| 534 | runningSessionRef.current = runningSession; | ||
| 535 | const terminalBootstrapRef = useRef({ | ||
| 536 | status: terminal.status, | ||
| 537 | bufferLen: terminal.buffer.length, | ||
| 538 | }); | ||
| 539 | terminalBootstrapRef.current = { | ||
| 540 | status: terminal.status, | ||
| 541 | bufferLen: terminal.buffer.length, | ||
| 542 | }; | ||
| 543 | |||
| 544 | useEffect(() => { | 579 | useEffect(() => { |
| 545 | hasOpenedRef.current = false; | ||
| 546 | attachStreamLogCountRef.current = 0; | ||
| 547 | firstNonEmptyBufferLoggedRef.current = false; | 580 | firstNonEmptyBufferLoggedRef.current = false; |
| 581 | sentInitialInputKeyRef.current = null; | ||
| 548 | }, [terminalKey]); | 582 | }, [terminalKey]); |
| 549 | 583 | ||
| 550 | const clearBufferReplayTimer = useCallback(() => { | 584 | const clearBufferReplayTimer = useCallback(() => { |
| ... | @@ -638,99 +672,22 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -638,99 +672,22 @@ export function ThreadTerminalRouteScreen() { |
| 638 | }); | 672 | }); |
| 639 | }, [fontSize, hasResolvedFontPreference]); | 673 | }, [fontSize, hasResolvedFontPreference]); |
| 640 | 674 | ||
| 641 | // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change. | ||
| 642 | // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when | ||
| 643 | // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup | ||
| 644 | // → detach immediately after the first snapshot. | ||
| 645 | useEffect(() => { | ||
| 646 | if (!hasResolvedFontPreference || !hasMeasuredSurface) { | ||
| 647 | return; | ||
| 648 | } | ||
| 649 | |||
| 650 | const thread = selectedThreadRef.current; | ||
| 651 | const project = selectedThreadProjectBootstrapRef.current; | ||
| 652 | const running = runningSessionRef.current; | ||
| 653 | const termSnap = terminalBootstrapRef.current; | ||
| 654 | |||
| 655 | const bootstrapAction = resolveTerminalRouteBootstrap({ | ||
| 656 | hasThread: thread !== null, | ||
| 657 | hasWorkspaceRoot: Boolean(project?.workspaceRoot), | ||
| 658 | hasOpened: hasOpenedRef.current, | ||
| 659 | requestedTerminalId, | ||
| 660 | currentTerminalId: terminalId, | ||
| 661 | runningTerminalId: running?.target.terminalId ?? null, | ||
| 662 | currentTerminalStatus: termSnap.status, | ||
| 663 | // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`; | ||
| 664 | // treating summary as "hydrated" skipped attach while status was running → empty surface. | ||
| 665 | hasCurrentTerminalHydration: termSnap.bufferLen > 0, | ||
| 666 | }); | ||
| 667 | if (bootstrapAction.kind !== "idle") { | ||
| 668 | terminalDebugLog("bootstrap:action", { | ||
| 669 | kind: bootstrapAction.kind, | ||
| 670 | hasOpenedBefore: hasOpenedRef.current, | ||
| 671 | hasHydration: termSnap.bufferLen > 0, | ||
| 672 | terminalStatus: termSnap.status, | ||
| 673 | bufLen: termSnap.bufferLen, | ||
| 674 | }); | ||
| 675 | } | ||
| 676 | if (bootstrapAction.kind === "idle" || !thread) { | ||
| 677 | return; | ||
| 678 | } | ||
| 679 | |||
| 680 | if (bootstrapAction.kind === "redirect") { | ||
| 681 | router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId)); | ||
| 682 | return; | ||
| 683 | } | ||
| 684 | |||
| 685 | hasOpenedRef.current = true; | ||
| 686 | try { | ||
| 687 | const detach = attachTerminalRef.current(); | ||
| 688 | terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) }); | ||
| 689 | if (!detach) { | ||
| 690 | hasOpenedRef.current = false; | ||
| 691 | return; | ||
| 692 | } | ||
| 693 | return () => { | ||
| 694 | detach(); | ||
| 695 | hasOpenedRef.current = false; | ||
| 696 | terminalDebugLog("bootstrap:unsubscribe"); | ||
| 697 | }; | ||
| 698 | } catch (error) { | ||
| 699 | hasOpenedRef.current = false; | ||
| 700 | terminalDebugLog("bootstrap:attach-threw", { | ||
| 701 | message: error instanceof Error ? error.message : String(error), | ||
| 702 | }); | ||
| 703 | return; | ||
| 704 | } | ||
| 705 | }, [ | ||
| 706 | hasMeasuredSurface, | ||
| 707 | hasResolvedFontPreference, | ||
| 708 | requestedTerminalId, | ||
| 709 | router, | ||
| 710 | selectedThread?.environmentId, | ||
| 711 | selectedThread?.id, | ||
| 712 | selectedThreadProject?.workspaceRoot, | ||
| 713 | terminalId, | ||
| 714 | ]); | ||
| 715 | |||
| 716 | const writeInput = useCallback( | 675 | const writeInput = useCallback( |
| 717 | (data: string) => { | 676 | (data: string) => { |
| 718 | if (!selectedThread || !isRunning) { | 677 | if (!selectedThread || !isRunning) { |
| 719 | return; | 678 | return; |
| 720 | } | 679 | } |
| 721 | 680 | ||
| 722 | const client = getEnvironmentClient(selectedThread.environmentId); | 681 | void writeTerminal({ |
| 723 | if (!client) { | 682 | environmentId: selectedThread.environmentId, |
| 724 | return; | 683 | input: { |
| 725 | } | 684 | threadId: selectedThread.id, |
| 726 | 685 | terminalId, | |
| 727 | void client.terminal.write({ | 686 | data, |
| 728 | threadId: selectedThread.id, | 687 | }, |
| 729 | terminalId, | ||
| 730 | data, | ||
| 731 | }); | 688 | }); |
| 732 | }, | 689 | }, |
| 733 | [isRunning, selectedThread, terminalId], | 690 | [isRunning, selectedThread, terminalId, writeTerminal], |
| 734 | ); | 691 | ); |
| 735 | 692 | ||
| 736 | const handleInput = useCallback( | 693 | const handleInput = useCallback( |
| ... | @@ -782,16 +739,14 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -782,16 +739,14 @@ export function ThreadTerminalRouteScreen() { |
| 782 | return; | 739 | return; |
| 783 | } | 740 | } |
| 784 | 741 | ||
| 785 | const client = getEnvironmentClient(selectedThread.environmentId); | 742 | void resizeTerminal({ |
| 786 | if (!client) { | 743 | environmentId: selectedThread.environmentId, |
| 787 | return; | 744 | input: { |
| 788 | } | 745 | threadId: selectedThread.id, |
| 789 | 746 | terminalId, | |
| 790 | void client.terminal.resize({ | 747 | cols: size.cols, |
| 791 | threadId: selectedThread.id, | 748 | rows: size.rows, |
| 792 | terminalId, | 749 | }, |
| 793 | cols: size.cols, | ||
| 794 | rows: size.rows, | ||
| 795 | }); | 750 | }); |
| 796 | }, | 751 | }, |
| 797 | [ | 752 | [ |
| ... | @@ -802,6 +757,7 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -802,6 +757,7 @@ export function ThreadTerminalRouteScreen() { |
| 802 | readyBufferReplayKey, | 757 | readyBufferReplayKey, |
| 803 | routeEnvironmentId, | 758 | routeEnvironmentId, |
| 804 | routeThreadId, | 759 | routeThreadId, |
| 760 | resizeTerminal, | ||
| 805 | scheduleBufferReplayReady, | 761 | scheduleBufferReplayReady, |
| 806 | selectedThread, | 762 | selectedThread, |
| 807 | terminalId, | 763 | terminalId, |
| ... | @@ -855,17 +811,15 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -855,17 +811,15 @@ export function ThreadTerminalRouteScreen() { |
| 855 | return; | 811 | return; |
| 856 | } | 812 | } |
| 857 | 813 | ||
| 858 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 859 | if (!client) { | ||
| 860 | return; | ||
| 861 | } | ||
| 862 | |||
| 863 | setPendingModifierState({ terminalId, value: null }); | 814 | setPendingModifierState({ terminalId, value: null }); |
| 864 | void client.terminal.clear({ | 815 | void clearTerminal({ |
| 865 | threadId: selectedThread.id, | 816 | environmentId: selectedThread.environmentId, |
| 866 | terminalId, | 817 | input: { |
| 818 | threadId: selectedThread.id, | ||
| 819 | terminalId, | ||
| 820 | }, | ||
| 867 | }); | 821 | }); |
| 868 | }, [selectedThread, terminalId]); | 822 | }, [clearTerminal, selectedThread, terminalId]); |
| 869 | 823 | ||
| 870 | const handleToolbarActionPress = useCallback( | 824 | const handleToolbarActionPress = useCallback( |
| 871 | (action: TerminalToolbarAction) => { | 825 | (action: TerminalToolbarAction) => { |
| ... | @@ -905,9 +859,14 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -905,9 +859,14 @@ export function ThreadTerminalRouteScreen() { |
| 905 | const handleShowKeyboard = useCallback(() => { | 859 | const handleShowKeyboard = useCallback(() => { |
| 906 | setKeyboardFocusRequest((current) => current + 1); | 860 | setKeyboardFocusRequest((current) => current + 1); |
| 907 | }, []); | 861 | }, []); |
| 862 | const handleRetryEnvironment = useCallback(() => { | ||
| 863 | if (routeEnvironmentId !== null) { | ||
| 864 | void retryEnvironment(routeEnvironmentId); | ||
| 865 | } | ||
| 866 | }, [retryEnvironment, routeEnvironmentId]); | ||
| 908 | 867 | ||
| 909 | if (!selectedThread) { | 868 | if (!selectedThread) { |
| 910 | if (isLoadingSavedConnection) { | 869 | if (workspaceState.isLoadingConnections) { |
| 911 | return <LoadingScreen message="Opening terminal…" />; | 870 | return <LoadingScreen message="Opening terminal…" />; |
| 912 | } | 871 | } |
| 913 | 872 | ||
| ... | @@ -932,6 +891,10 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -932,6 +891,10 @@ export function ThreadTerminalRouteScreen() { |
| 932 | ); | 891 | ); |
| 933 | } | 892 | } |
| 934 | 893 | ||
| 894 | if (!environment.isReady && environment.presentation === null) { | ||
| 895 | return <LoadingScreen message="Opening terminal…" />; | ||
| 896 | } | ||
| 897 | |||
| 935 | return ( | 898 | return ( |
| 936 | <> | 899 | <> |
| 937 | <Stack.Screen | 900 | <Stack.Screen |
| ... | @@ -980,152 +943,178 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -980,152 +943,178 @@ export function ThreadTerminalRouteScreen() { |
| 980 | }} | 943 | }} |
| 981 | /> | 944 | /> |
| 982 | 945 | ||
| 983 | <Stack.Toolbar placement="right"> | 946 | {isEnvironmentReady ? ( |
| 984 | <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground> | 947 | <Stack.Toolbar placement="right"> |
| 985 | <Stack.Toolbar.Label> | 948 | <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground> |
| 986 | {getTerminalStatusLabel({ | 949 | <Stack.Toolbar.Label> |
| 987 | status: terminal.status, | 950 | {getTerminalStatusLabel({ |
| 988 | hasRunningSubprocess: terminal.hasRunningSubprocess, | 951 | status: terminal.status, |
| 989 | })} | 952 | hasRunningSubprocess: terminal.hasRunningSubprocess, |
| 990 | </Stack.Toolbar.Label> | 953 | })} |
| 991 | <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size"> | 954 | </Stack.Toolbar.Label> |
| 992 | <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label> | 955 | <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size"> |
| 993 | <Stack.Toolbar.MenuAction | 956 | <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label> |
| 994 | disabled={fontSize <= MIN_TERMINAL_FONT_SIZE} | 957 | <Stack.Toolbar.MenuAction |
| 995 | discoverabilityLabel="Decrease terminal text size" | 958 | disabled={fontSize <= MIN_TERMINAL_FONT_SIZE} |
| 996 | onPress={handleDecreaseFontSize} | 959 | discoverabilityLabel="Decrease terminal text size" |
| 997 | > | 960 | onPress={handleDecreaseFontSize} |
| 998 | <Stack.Toolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label> | 961 | > |
| 999 | </Stack.Toolbar.MenuAction> | 962 | <Stack.Toolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label> |
| 963 | </Stack.Toolbar.MenuAction> | ||
| 964 | <Stack.Toolbar.MenuAction | ||
| 965 | disabled={fontSize >= MAX_TERMINAL_FONT_SIZE} | ||
| 966 | discoverabilityLabel="Increase terminal text size" | ||
| 967 | onPress={handleIncreaseFontSize} | ||
| 968 | > | ||
| 969 | <Stack.Toolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label> | ||
| 970 | </Stack.Toolbar.MenuAction> | ||
| 971 | </Stack.Toolbar.Menu> | ||
| 972 | {terminalMenuSessions.map((session) => ( | ||
| 973 | <Stack.Toolbar.MenuAction | ||
| 974 | key={session.terminalId} | ||
| 975 | icon={session.terminalId === terminalId ? "checkmark" : "terminal"} | ||
| 976 | onPress={() => handleSelectTerminal(session.terminalId)} | ||
| 977 | subtitle={[ | ||
| 978 | getTerminalStatusLabel({ status: session.status }), | ||
| 979 | basename(session.cwd), | ||
| 980 | ] | ||
| 981 | .filter(Boolean) | ||
| 982 | .join(" · ")} | ||
| 983 | > | ||
| 984 | <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> | ||
| 985 | </Stack.Toolbar.MenuAction> | ||
| 986 | ))} | ||
| 1000 | <Stack.Toolbar.MenuAction | 987 | <Stack.Toolbar.MenuAction |
| 1001 | disabled={fontSize >= MAX_TERMINAL_FONT_SIZE} | 988 | icon="plus" |
| 1002 | discoverabilityLabel="Increase terminal text size" | 989 | onPress={handleOpenNewTerminal} |
| 1003 | onPress={handleIncreaseFontSize} | 990 | subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} |
| 1004 | > | 991 | > |
| 1005 | <Stack.Toolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label> | 992 | <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> |
| 1006 | </Stack.Toolbar.MenuAction> | 993 | </Stack.Toolbar.MenuAction> |
| 1007 | </Stack.Toolbar.Menu> | 994 | </Stack.Toolbar.Menu> |
| 1008 | {terminalMenuSessions.map((session) => ( | 995 | </Stack.Toolbar> |
| 1009 | <Stack.Toolbar.MenuAction | 996 | ) : null} |
| 1010 | key={session.terminalId} | ||
| 1011 | icon={session.terminalId === terminalId ? "checkmark" : "terminal"} | ||
| 1012 | onPress={() => handleSelectTerminal(session.terminalId)} | ||
| 1013 | subtitle={[getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] | ||
| 1014 | .filter(Boolean) | ||
| 1015 | .join(" · ")} | ||
| 1016 | > | ||
| 1017 | <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> | ||
| 1018 | </Stack.Toolbar.MenuAction> | ||
| 1019 | ))} | ||
| 1020 | <Stack.Toolbar.MenuAction | ||
| 1021 | icon="plus" | ||
| 1022 | onPress={handleOpenNewTerminal} | ||
| 1023 | subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} | ||
| 1024 | > | ||
| 1025 | <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> | ||
| 1026 | </Stack.Toolbar.MenuAction> | ||
| 1027 | </Stack.Toolbar.Menu> | ||
| 1028 | </Stack.Toolbar> | ||
| 1029 | 997 | ||
| 1030 | <View style={{ flex: 1, backgroundColor: terminalTheme.background }}> | 998 | <View style={{ flex: 1, backgroundColor: terminalTheme.background }}> |
| 1031 | <View style={{ flex: 1, paddingBottom: terminalBottomInset }}> | 999 | {!isEnvironmentReady ? ( |
| 1032 | <TerminalSurface | 1000 | <EnvironmentConnectionNotice |
| 1033 | buffer={terminalSurfaceBuffer} | 1001 | environmentLabel={ |
| 1034 | fontSize={fontSize} | 1002 | environment.presentation?.entry.target.label ?? |
| 1035 | isRunning={isRunning} | 1003 | selectedEnvironmentConnection?.environmentLabel ?? |
| 1036 | keyboardFocusRequest={keyboardFocusRequest} | 1004 | "Environment" |
| 1037 | onInput={handleInput} | 1005 | } |
| 1038 | onResize={handleResize} | 1006 | connection={ |
| 1039 | style={{ flex: 1 }} | 1007 | environment.presentation?.connection ?? { |
| 1040 | terminalKey={terminalKey} | 1008 | phase: "available", |
| 1009 | error: null, | ||
| 1010 | traceId: null, | ||
| 1011 | } | ||
| 1012 | } | ||
| 1013 | resourceName="terminal" | ||
| 1014 | onRetry={handleRetryEnvironment} | ||
| 1041 | /> | 1015 | /> |
| 1042 | </View> | 1016 | ) : ( |
| 1017 | <> | ||
| 1018 | <View style={{ flex: 1, paddingBottom: terminalBottomInset }}> | ||
| 1019 | <TerminalSurface | ||
| 1020 | buffer={terminalSurfaceBuffer} | ||
| 1021 | fontSize={fontSize} | ||
| 1022 | isRunning={isRunning} | ||
| 1023 | keyboardFocusRequest={keyboardFocusRequest} | ||
| 1024 | onInput={handleInput} | ||
| 1025 | onResize={handleResize} | ||
| 1026 | style={{ flex: 1 }} | ||
| 1027 | terminalKey={terminalKey} | ||
| 1028 | /> | ||
| 1029 | </View> | ||
| 1043 | 1030 | ||
| 1044 | {isAccessoryVisible ? ( | 1031 | {isAccessoryVisible ? ( |
| 1045 | <KeyboardStickyView | 1032 | <KeyboardStickyView |
| 1046 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | 1033 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 1047 | offset={{ closed: 0, opened: 0 }} | 1034 | offset={{ closed: 0, opened: 0 }} |
| 1048 | > | 1035 | > |
| 1049 | <View | 1036 | <View |
| 1050 | style={{ | 1037 | style={{ |
| 1051 | backgroundColor: terminalTheme.background, | 1038 | backgroundColor: terminalTheme.background, |
| 1052 | borderTopColor: terminalTheme.border, | 1039 | borderTopColor: terminalTheme.border, |
| 1053 | borderTopWidth: 1, | 1040 | borderTopWidth: 1, |
| 1054 | minHeight: TERMINAL_ACCESSORY_HEIGHT, | 1041 | minHeight: TERMINAL_ACCESSORY_HEIGHT, |
| 1055 | }} | 1042 | }} |
| 1056 | > | ||
| 1057 | <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}> | ||
| 1058 | <ComposerToolbarScroller | ||
| 1059 | contentPaddingRight={2} | ||
| 1060 | fadeOpaque={terminalTheme.background} | ||
| 1061 | fadeTransparent={`${terminalTheme.background}00`} | ||
| 1062 | > | 1043 | > |
| 1063 | {terminalToolbarActions.map((action) => { | 1044 | <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}> |
| 1064 | const active = | 1045 | <ComposerToolbarScroller |
| 1065 | action.kind === "modifier" && pendingModifier === action.modifier; | 1046 | contentPaddingRight={2} |
| 1066 | 1047 | fadeOpaque={terminalTheme.background} | |
| 1067 | return ( | 1048 | fadeTransparent={`${terminalTheme.background}00`} |
| 1068 | <ComposerToolbarButton | 1049 | > |
| 1069 | key={action.key} | 1050 | {terminalToolbarActions.map((action) => { |
| 1070 | active={active} | 1051 | const active = |
| 1071 | label={action.label} | 1052 | action.kind === "modifier" && pendingModifier === action.modifier; |
| 1072 | maxWidth={120} | 1053 | |
| 1073 | minWidth={action.label.length > 1 ? 56 : 44} | 1054 | return ( |
| 1074 | onPress={() => handleToolbarActionPress(action)} | 1055 | <ComposerToolbarButton |
| 1075 | showChevron={false} | 1056 | key={action.key} |
| 1076 | textTransform={ | 1057 | active={active} |
| 1077 | action.kind === "modifier" || action.kind === "clear" | 1058 | label={action.label} |
| 1078 | ? "uppercase" | 1059 | maxWidth={120} |
| 1079 | : "none" | 1060 | minWidth={action.label.length > 1 ? 56 : 44} |
| 1080 | } | 1061 | onPress={() => handleToolbarActionPress(action)} |
| 1081 | /> | 1062 | showChevron={false} |
| 1082 | ); | 1063 | textTransform={ |
| 1083 | })} | 1064 | action.kind === "modifier" || action.kind === "clear" |
| 1084 | </ComposerToolbarScroller> | 1065 | ? "uppercase" |
| 1085 | <ComposerToolbarButton | 1066 | : "none" |
| 1086 | accessibilityLabel="Dismiss keyboard" | 1067 | } |
| 1087 | icon={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }} | 1068 | /> |
| 1088 | onPress={handleDismissKeyboard} | 1069 | ); |
| 1089 | showChevron={false} | 1070 | })} |
| 1090 | /> | 1071 | </ComposerToolbarScroller> |
| 1091 | </ComposerToolbarRow> | 1072 | <ComposerToolbarButton |
| 1092 | </View> | 1073 | accessibilityLabel="Dismiss keyboard" |
| 1093 | </KeyboardStickyView> | 1074 | icon={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }} |
| 1094 | ) : !keyboardState.isVisible ? ( | 1075 | onPress={handleDismissKeyboard} |
| 1095 | <Pressable | 1076 | showChevron={false} |
| 1096 | accessibilityLabel="Show keyboard" | 1077 | /> |
| 1097 | accessibilityRole="button" | 1078 | </ComposerToolbarRow> |
| 1098 | onPress={handleShowKeyboard} | 1079 | </View> |
| 1099 | style={({ pressed }) => ({ | 1080 | </KeyboardStickyView> |
| 1100 | bottom: 16, | 1081 | ) : !keyboardState.isVisible ? ( |
| 1101 | borderRadius: 28, | 1082 | <Pressable |
| 1102 | opacity: pressed ? 0.72 : 1, | 1083 | accessibilityLabel="Show keyboard" |
| 1103 | position: "absolute", | 1084 | accessibilityRole="button" |
| 1104 | right: 16, | 1085 | onPress={handleShowKeyboard} |
| 1105 | })} | 1086 | style={({ pressed }) => ({ |
| 1106 | > | 1087 | bottom: 16, |
| 1107 | <GlassSurface | 1088 | borderRadius: 28, |
| 1108 | chrome="none" | 1089 | opacity: pressed ? 0.72 : 1, |
| 1109 | glassEffectStyle="regular" | 1090 | position: "absolute", |
| 1110 | tintColor="transparent" | 1091 | right: 16, |
| 1111 | style={{ | 1092 | })} |
| 1112 | alignItems: "center", | 1093 | > |
| 1113 | borderRadius: 24, | 1094 | <GlassSurface |
| 1114 | height: 48, | 1095 | chrome="none" |
| 1115 | justifyContent: "center", | 1096 | glassEffectStyle="regular" |
| 1116 | width: 48, | 1097 | tintColor="transparent" |
| 1117 | }} | 1098 | style={{ |
| 1118 | pointerEvents="none" | 1099 | alignItems: "center", |
| 1119 | > | 1100 | borderRadius: 24, |
| 1120 | <SymbolView | 1101 | height: 48, |
| 1121 | name={{ ios: "keyboard", android: "keyboard" }} | 1102 | justifyContent: "center", |
| 1122 | size={20} | 1103 | width: 48, |
| 1123 | tintColor={terminalTheme.foreground} | 1104 | }} |
| 1124 | type="monochrome" | 1105 | pointerEvents="none" |
| 1125 | /> | 1106 | > |
| 1126 | </GlassSurface> | 1107 | <SymbolView |
| 1127 | </Pressable> | 1108 | name={{ ios: "keyboard", android: "keyboard" }} |
| 1128 | ) : null} | 1109 | size={20} |
| 1110 | tintColor={terminalTheme.foreground} | ||
| 1111 | type="monochrome" | ||
| 1112 | /> | ||
| 1113 | </GlassSurface> | ||
| 1114 | </Pressable> | ||
| 1115 | ) : null} | ||
| 1116 | </> | ||
| 1117 | )} | ||
| 1129 | </View> | 1118 | </View> |
| 1130 | </> | 1119 | </> |
| 1131 | ); | 1120 | ); |
apps/mobile/src/features/terminal/terminalMenu.test.ts+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; | 3 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 4 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; | 4 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 5 | 5 | ||
| 6 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | 6 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
apps/mobile/src/features/terminal/terminalMenu.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; | 1 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 2 | import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; | 2 | import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; |
| 3 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | 3 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; |
| 4 | import * as Arr from "effect/Array"; | 4 | import * as Arr from "effect/Array"; |
apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | buildThreadTerminalAttachInput, | ||
| 6 | threadTerminalSubscriptionKey, | ||
| 7 | type ThreadTerminalSubscriptionIdentity, | ||
| 8 | } from "./threadTerminalPanelModel"; | ||
| 9 | |||
| 10 | const identity: ThreadTerminalSubscriptionIdentity = { | ||
| 11 | environmentId: EnvironmentId.make("env-1"), | ||
| 12 | threadId: ThreadId.make("thread-1"), | ||
| 13 | terminalId: "default", | ||
| 14 | cwd: "/repo", | ||
| 15 | worktreePath: "/repo", | ||
| 16 | }; | ||
| 17 | |||
| 18 | describe("threadTerminalSubscriptionKey", () => { | ||
| 19 | it("does not include mutable terminal dimensions", () => { | ||
| 20 | const initialAttach = buildThreadTerminalAttachInput(identity, { cols: 80, rows: 24 }); | ||
| 21 | const resizedAttach = buildThreadTerminalAttachInput(identity, { cols: 132, rows: 40 }); | ||
| 22 | |||
| 23 | expect(initialAttach).not.toEqual(resizedAttach); | ||
| 24 | expect(threadTerminalSubscriptionKey({ ...identity, ...initialAttach })).toBe( | ||
| 25 | threadTerminalSubscriptionKey({ ...identity, ...resizedAttach }), | ||
| 26 | ); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it.each([ | ||
| 30 | ["environment", { environmentId: EnvironmentId.make("env-2") }], | ||
| 31 | ["thread", { threadId: ThreadId.make("thread-2") }], | ||
| 32 | ["terminal", { terminalId: "term-2" }], | ||
| 33 | ["cwd", { cwd: "/repo/packages/app" }], | ||
| 34 | ["worktree", { worktreePath: "/repo/worktrees/feature" }], | ||
| 35 | ])("changes when the %s identity changes", (_label, update) => { | ||
| 36 | expect(threadTerminalSubscriptionKey({ ...identity, ...update })).not.toBe( | ||
| 37 | threadTerminalSubscriptionKey(identity), | ||
| 38 | ); | ||
| 39 | }); | ||
| 40 | }); | ||
apps/mobile/src/features/terminal/threadTerminalPanelModel.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface ThreadTerminalSubscriptionIdentity { | ||
| 4 | readonly environmentId: EnvironmentId; | ||
| 5 | readonly threadId: TerminalAttachInput["threadId"]; | ||
| 6 | readonly terminalId: TerminalAttachInput["terminalId"]; | ||
| 7 | readonly cwd: string; | ||
| 8 | readonly worktreePath: string | null; | ||
| 9 | } | ||
| 10 | |||
| 11 | export interface TerminalGridSize { | ||
| 12 | readonly cols: number; | ||
| 13 | readonly rows: number; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function threadTerminalSubscriptionKey( | ||
| 17 | identity: ThreadTerminalSubscriptionIdentity, | ||
| 18 | ): string { | ||
| 19 | return JSON.stringify([ | ||
| 20 | identity.environmentId, | ||
| 21 | identity.threadId, | ||
| 22 | identity.terminalId, | ||
| 23 | identity.cwd, | ||
| 24 | identity.worktreePath, | ||
| 25 | ]); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function buildThreadTerminalAttachInput( | ||
| 29 | identity: ThreadTerminalSubscriptionIdentity, | ||
| 30 | gridSize: TerminalGridSize, | ||
| 31 | ): TerminalAttachInput { | ||
| 32 | return { | ||
| 33 | threadId: identity.threadId, | ||
| 34 | terminalId: identity.terminalId, | ||
| 35 | cwd: identity.cwd, | ||
| 36 | worktreePath: identity.worktreePath, | ||
| 37 | cols: gridSize.cols, | ||
| 38 | rows: gridSize.rows, | ||
| 39 | }; | ||
| 40 | } | ||
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+58-106| ... | @@ -1,11 +1,15 @@ | ... | @@ -1,11 +1,15 @@ |
| 1 | import { Stack, useRouter } from "expo-router"; | 1 | import { Stack, useRouter } from "expo-router"; |
| 2 | import { useCallback, useEffect, useMemo, useRef } from "react"; | 2 | import { useCallback, useEffect, useMemo, useRef } from "react"; |
| 3 | import { InteractionManager, View, useColorScheme } from "react-native"; | 3 | import { Alert, InteractionManager, View, useColorScheme } from "react-native"; |
| 4 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; | 4 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | import { useThemeColor } from "../../lib/useThemeColor"; | 6 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 7 | 7 | ||
| 8 | import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; | 8 | import { EnvironmentId } from "@t3tools/contracts"; |
| 9 | import { | ||
| 10 | isAtomCommandInterrupted, | ||
| 11 | squashAtomCommandFailure, | ||
| 12 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 9 | 13 | ||
| 10 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; | 14 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 11 | import { | 15 | import { |
| ... | @@ -19,27 +23,16 @@ import { ControlPillMenu } from "../../components/ControlPill"; | ... | @@ -19,27 +23,16 @@ import { ControlPillMenu } from "../../components/ControlPill"; |
| 19 | import { ProviderIcon } from "../../components/ProviderIcon"; | 23 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 20 | 24 | ||
| 21 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | 25 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 26 | import { | ||
| 27 | applyProviderOptionMenuEvent, | ||
| 28 | buildProviderOptionMenuActions, | ||
| 29 | providerOptionsConfigurationLabel, | ||
| 30 | resolveProviderOptionDescriptors, | ||
| 31 | } from "../../lib/providerOptions"; | ||
| 22 | import { buildThreadRoutePath } from "../../lib/routes"; | 32 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 23 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 33 | import { useProjects } from "../../state/entities"; |
| 24 | import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; | ||
| 25 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 34 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 26 | import { useProjectActions } from "./use-project-actions"; | 35 | import { useCreateProjectThread } from "./use-project-actions"; |
| 27 | |||
| 28 | function withModelSelectionOption( | ||
| 29 | selection: ModelSelection, | ||
| 30 | id: string, | ||
| 31 | value: string | boolean | undefined, | ||
| 32 | ): ModelSelection { | ||
| 33 | const options = (selection.options ?? []).filter((option) => option.id !== id); | ||
| 34 | return { | ||
| 35 | ...selection, | ||
| 36 | options: value === undefined ? options : [...options, { id, value }], | ||
| 37 | }; | ||
| 38 | } | ||
| 39 | |||
| 40 | function formatTitleCase(value: string): string { | ||
| 41 | return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; | ||
| 42 | } | ||
| 43 | 36 | ||
| 44 | function formatWorkspaceLabel(input: { | 37 | function formatWorkspaceLabel(input: { |
| 45 | readonly workspaceMode: string; | 38 | readonly workspaceMode: string; |
| ... | @@ -59,8 +52,8 @@ export function NewTaskDraftScreen(props: { | ... | @@ -59,8 +52,8 @@ export function NewTaskDraftScreen(props: { |
| 59 | readonly projectId?: string; | 52 | readonly projectId?: string; |
| 60 | }; | 53 | }; |
| 61 | }) { | 54 | }) { |
| 62 | const { projects } = useRemoteCatalog(); | 55 | const projects = useProjects(); |
| 63 | const { onCreateThreadWithOptions } = useProjectActions(); | 56 | const createProjectThread = useCreateProjectThread(); |
| 64 | const flow = useNewTaskFlow(); | 57 | const flow = useNewTaskFlow(); |
| 65 | const router = useRouter(); | 58 | const router = useRouter(); |
| 66 | const insets = useSafeAreaInsets(); | 59 | const insets = useSafeAreaInsets(); |
| ... | @@ -169,39 +162,18 @@ export function NewTaskDraftScreen(props: { | ... | @@ -169,39 +162,18 @@ export function NewTaskDraftScreen(props: { |
| 169 | })), | 162 | })), |
| 170 | [flow.providerGroups, flow.selectedModel], | 163 | [flow.providerGroups, flow.selectedModel], |
| 171 | ); | 164 | ); |
| 165 | const providerOptionDescriptors = useMemo( | ||
| 166 | () => | ||
| 167 | resolveProviderOptionDescriptors({ | ||
| 168 | capabilities: flow.selectedModelOption?.capabilities, | ||
| 169 | selections: flow.selectedModel?.options, | ||
| 170 | }), | ||
| 171 | [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], | ||
| 172 | ); | ||
| 172 | 173 | ||
| 173 | const optionsMenuActions = useMemo( | 174 | const optionsMenuActions = useMemo( |
| 174 | () => [ | 175 | () => [ |
| 175 | { | 176 | ...buildProviderOptionMenuActions(providerOptionDescriptors), |
| 176 | id: "options-effort", | ||
| 177 | title: "Effort", | ||
| 178 | subtitle: `${flow.effort.charAt(0).toUpperCase()}${flow.effort.slice(1)}`, | ||
| 179 | subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ | ||
| 180 | id: `options:effort:${level}`, | ||
| 181 | title: `${level}${level === "high" ? " (default)" : ""}`, | ||
| 182 | state: flow.effort === level ? ("on" as const) : undefined, | ||
| 183 | })), | ||
| 184 | }, | ||
| 185 | { | ||
| 186 | id: "options-fast-mode", | ||
| 187 | title: "Fast Mode", | ||
| 188 | subtitle: flow.fastMode ? "On" : "Off", | ||
| 189 | subactions: ([false, true] as const).map((value) => ({ | ||
| 190 | id: `options:fast-mode:${value ? "on" : "off"}`, | ||
| 191 | title: value ? "On" : "Off", | ||
| 192 | state: flow.fastMode === value ? ("on" as const) : undefined, | ||
| 193 | })), | ||
| 194 | }, | ||
| 195 | { | ||
| 196 | id: "options-context-window", | ||
| 197 | title: "Context Window", | ||
| 198 | subtitle: flow.contextWindow, | ||
| 199 | subactions: (["200k", "1M"] as const).map((value) => ({ | ||
| 200 | id: `options:context-window:${value}`, | ||
| 201 | title: `${value}${value === "1M" ? " (default)" : ""}`, | ||
| 202 | state: flow.contextWindow === value ? ("on" as const) : undefined, | ||
| 203 | })), | ||
| 204 | }, | ||
| 205 | { | 177 | { |
| 206 | id: "options-runtime", | 178 | id: "options-runtime", |
| 207 | title: "Runtime", | 179 | title: "Runtime", |
| ... | @@ -241,7 +213,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -241,7 +213,7 @@ export function NewTaskDraftScreen(props: { |
| 241 | }), | 213 | }), |
| 242 | }, | 214 | }, |
| 243 | ], | 215 | ], |
| 244 | [flow.contextWindow, flow.effort, flow.fastMode, flow.interactionMode, flow.runtimeMode], | 216 | [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors], |
| 245 | ); | 217 | ); |
| 246 | 218 | ||
| 247 | const workspaceMenuActions = useMemo(() => { | 219 | const workspaceMenuActions = useMemo(() => { |
| ... | @@ -302,14 +274,10 @@ export function NewTaskDraftScreen(props: { | ... | @@ -302,14 +274,10 @@ export function NewTaskDraftScreen(props: { |
| 302 | flow.availableBranches.find((branch) => branch.current)?.name ?? | 274 | flow.availableBranches.find((branch) => branch.current)?.name ?? |
| 303 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? | 275 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? |
| 304 | null; | 276 | null; |
| 305 | const configurationLabel = useMemo(() => { | 277 | const configurationLabel = useMemo( |
| 306 | const parts = [ | 278 | () => providerOptionsConfigurationLabel(providerOptionDescriptors), |
| 307 | formatTitleCase(flow.effort), | 279 | [providerOptionDescriptors], |
| 308 | flow.fastMode ? "Fast" : null, | 280 | ); |
| 309 | flow.contextWindow !== "1M" ? flow.contextWindow : null, | ||
| 310 | ].filter((part): part is string => Boolean(part)); | ||
| 311 | return parts.length > 0 ? parts.join(" · ") : "Configuration"; | ||
| 312 | }, [flow.contextWindow, flow.effort, flow.fastMode]); | ||
| 313 | const workspaceLabel = useMemo( | 281 | const workspaceLabel = useMemo( |
| 314 | () => | 282 | () => |
| 315 | formatWorkspaceLabel({ | 283 | formatWorkspaceLabel({ |
| ... | @@ -338,16 +306,9 @@ export function NewTaskDraftScreen(props: { | ... | @@ -338,16 +306,9 @@ export function NewTaskDraftScreen(props: { |
| 338 | } | 306 | } |
| 339 | 307 | ||
| 340 | function handleOptionsMenuAction(event: string) { | 308 | function handleOptionsMenuAction(event: string) { |
| 341 | if (event.startsWith("options:effort:")) { | 309 | const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); |
| 342 | flow.setEffort(event.slice("options:effort:".length) as typeof flow.effort); | 310 | if (providerOptions) { |
| 343 | return; | 311 | flow.setSelectedModelOptions(providerOptions); |
| 344 | } | ||
| 345 | if (event.startsWith("options:fast-mode:")) { | ||
| 346 | flow.setFastMode(event.endsWith(":on")); | ||
| 347 | return; | ||
| 348 | } | ||
| 349 | if (event.startsWith("options:context-window:")) { | ||
| 350 | flow.setContextWindow(event.slice("options:context-window:".length)); | ||
| 351 | return; | 312 | return; |
| 352 | } | 313 | } |
| 353 | if (event.startsWith("options:runtime:")) { | 314 | if (event.startsWith("options:runtime:")) { |
| ... | @@ -415,42 +376,33 @@ export function NewTaskDraftScreen(props: { | ... | @@ -415,42 +376,33 @@ export function NewTaskDraftScreen(props: { |
| 415 | } | 376 | } |
| 416 | 377 | ||
| 417 | flow.setSubmitting(true); | 378 | flow.setSubmitting(true); |
| 418 | try { | 379 | const result = await createProjectThread({ |
| 419 | const modelWithOptions: ModelSelection = | 380 | project: flow.selectedProject, |
| 420 | flow.selectedModelOption?.providerDriver === "claudeAgent" | 381 | modelSelection: flow.selectedModel, |
| 421 | ? withModelSelectionOption( | 382 | envMode: flow.workspaceMode, |
| 422 | withModelSelectionOption( | 383 | branch: flow.selectedBranchName, |
| 423 | withModelSelectionOption(flow.selectedModel, "effort", flow.effort), | 384 | worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath, |
| 424 | "fastMode", | 385 | runtimeMode: flow.runtimeMode, |
| 425 | flow.fastMode || undefined, | 386 | interactionMode: flow.interactionMode, |
| 426 | ), | 387 | initialMessageText: flow.prompt.trim(), |
| 427 | "contextWindow", | 388 | initialAttachments: flow.attachments, |
| 428 | flow.contextWindow, | 389 | }); |
| 429 | ) | 390 | flow.setSubmitting(false); |
| 430 | : flow.selectedModelOption?.providerDriver === "codex" | 391 | |
| 431 | ? withModelSelectionOption(flow.selectedModel, "fastMode", flow.fastMode || undefined) | 392 | if (result._tag === "Failure") { |
| 432 | : flow.selectedModel; | 393 | if (!isAtomCommandInterrupted(result)) { |
| 433 | 394 | const error = squashAtomCommandFailure(result); | |
| 434 | const createdThread = await onCreateThreadWithOptions({ | 395 | Alert.alert( |
| 435 | project: flow.selectedProject, | 396 | "Could not start task", |
| 436 | modelSelection: modelWithOptions, | 397 | error instanceof Error ? error.message : "The task could not be started.", |
| 437 | envMode: flow.workspaceMode, | 398 | ); |
| 438 | branch: flow.selectedBranchName, | ||
| 439 | worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath, | ||
| 440 | runtimeMode: flow.runtimeMode, | ||
| 441 | interactionMode: flow.interactionMode, | ||
| 442 | initialMessageText: flow.prompt.trim(), | ||
| 443 | initialAttachments: flow.attachments, | ||
| 444 | }); | ||
| 445 | |||
| 446 | if (createdThread) { | ||
| 447 | flow.setPrompt(""); | ||
| 448 | flow.clearAttachments(); | ||
| 449 | router.replace(buildThreadRoutePath(createdThread)); | ||
| 450 | } | 399 | } |
| 451 | } finally { | 400 | return; |
| 452 | flow.setSubmitting(false); | ||
| 453 | } | 401 | } |
| 402 | |||
| 403 | flow.setPrompt(""); | ||
| 404 | flow.clearAttachments(); | ||
| 405 | router.replace(buildThreadRoutePath(result.value)); | ||
| 454 | } | 406 | } |
| 455 | 407 | ||
| 456 | if (!selectedProject) { | 408 | if (!selectedProject) { |
apps/mobile/src/features/threads/PendingApprovalCard.tsx+1-1| ... | @@ -10,7 +10,7 @@ export interface PendingApprovalCardProps { | ... | @@ -10,7 +10,7 @@ export interface PendingApprovalCardProps { |
| 10 | readonly onRespond: ( | 10 | readonly onRespond: ( |
| 11 | requestId: ApprovalRequestId, | 11 | requestId: ApprovalRequestId, |
| 12 | decision: ProviderApprovalDecision, | 12 | decision: ProviderApprovalDecision, |
| 13 | ) => Promise<void>; | 13 | ) => Promise<unknown>; |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | export function PendingApprovalCard(props: PendingApprovalCardProps) { | 16 | export function PendingApprovalCard(props: PendingApprovalCardProps) { |
apps/mobile/src/features/threads/PendingUserInputCard.tsx+1-1| ... | @@ -20,7 +20,7 @@ export interface PendingUserInputCardProps { | ... | @@ -20,7 +20,7 @@ export interface PendingUserInputCardProps { |
| 20 | questionId: string, | 20 | questionId: string, |
| 21 | customAnswer: string, | 21 | customAnswer: string, |
| 22 | ) => void; | 22 | ) => void; |
| 23 | readonly onSubmit: () => Promise<void>; | 23 | readonly onSubmit: () => Promise<unknown>; |
| 24 | } | 24 | } |
| 25 | 25 | ||
| 26 | export function PendingUserInputCard(props: PendingUserInputCardProps) { | 26 | export function PendingUserInputCard(props: PendingUserInputCardProps) { |
apps/mobile/src/features/threads/ThreadComposer.tsx+129-128| ... | @@ -2,7 +2,7 @@ import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass | ... | @@ -2,7 +2,7 @@ import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass |
| 2 | import type { | 2 | import type { |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | ModelSelection, | 4 | ModelSelection, |
| 5 | OrchestrationThread, | 5 | OrchestrationThreadShell, |
| 6 | ProviderInteractionMode, | 6 | ProviderInteractionMode, |
| 7 | RuntimeMode, | 7 | RuntimeMode, |
| 8 | ServerConfig as T3ServerConfig, | 8 | ServerConfig as T3ServerConfig, |
| ... | @@ -15,7 +15,14 @@ import { | ... | @@ -15,7 +15,14 @@ import { |
| 15 | } from "@t3tools/shared/composerTrigger"; | 15 | } from "@t3tools/shared/composerTrigger"; |
| 16 | import type { ReactNode } from "react"; | 16 | import type { ReactNode } from "react"; |
| 17 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; | 17 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 18 | import { Image, Pressable, useColorScheme, View, type ViewStyle } from "react-native"; | 18 | import { |
| 19 | ActivityIndicator, | ||
| 20 | Image, | ||
| 21 | Pressable, | ||
| 22 | useColorScheme, | ||
| 23 | View, | ||
| 24 | type ViewStyle, | ||
| 25 | } from "react-native"; | ||
| 19 | import ImageViewing from "react-native-image-viewing"; | 26 | import ImageViewing from "react-native-image-viewing"; |
| 20 | import { useThemeColor } from "../../lib/useThemeColor"; | 27 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 21 | 28 | ||
| ... | @@ -43,11 +50,12 @@ import { | ... | @@ -43,11 +50,12 @@ import { |
| 43 | scoreQueryMatch, | 50 | scoreQueryMatch, |
| 44 | } from "@t3tools/shared/searchRanking"; | 51 | } from "@t3tools/shared/searchRanking"; |
| 45 | import { | 52 | import { |
| 46 | getModelSelectionBooleanOptionValue, | 53 | applyProviderOptionMenuEvent, |
| 47 | getModelSelectionStringOptionValue, | 54 | buildProviderOptionMenuActions, |
| 48 | } from "@t3tools/shared/model"; | 55 | providerOptionsConfigurationLabel, |
| 56 | resolveProviderOptionDescriptors, | ||
| 57 | } from "../../lib/providerOptions"; | ||
| 49 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; | 58 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; |
| 50 | import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; | ||
| 51 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | 59 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; |
| 52 | 60 | ||
| 53 | /** | 61 | /** |
| ... | @@ -68,7 +76,9 @@ export interface ThreadComposerProps { | ... | @@ -68,7 +76,9 @@ export interface ThreadComposerProps { |
| 68 | readonly placeholder: string; | 76 | readonly placeholder: string; |
| 69 | readonly bottomInset?: number; | 77 | readonly bottomInset?: number; |
| 70 | readonly connectionState: RemoteClientConnectionState; | 78 | readonly connectionState: RemoteClientConnectionState; |
| 71 | readonly selectedThread: OrchestrationThread; | 79 | readonly connectionError: string | null; |
| 80 | readonly environmentLabel: string | null; | ||
| 81 | readonly selectedThread: OrchestrationThreadShell; | ||
| 72 | readonly serverConfig: T3ServerConfig | null; | 82 | readonly serverConfig: T3ServerConfig | null; |
| 73 | readonly queueCount: number; | 83 | readonly queueCount: number; |
| 74 | readonly activeThreadBusy: boolean; | 84 | readonly activeThreadBusy: boolean; |
| ... | @@ -79,11 +89,12 @@ export interface ThreadComposerProps { | ... | @@ -79,11 +89,12 @@ export interface ThreadComposerProps { |
| 79 | readonly onPickDraftImages: () => Promise<void>; | 89 | readonly onPickDraftImages: () => Promise<void>; |
| 80 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 90 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 81 | readonly onRemoveDraftImage: (imageId: string) => void; | 91 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 82 | readonly onStopThread: () => Promise<void>; | 92 | readonly onStopThread: () => void; |
| 83 | readonly onSendMessage: () => void; | 93 | readonly onSendMessage: () => Promise<void>; |
| 84 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise<void>; | 94 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; |
| 85 | readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => Promise<void>; | 95 | readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; |
| 86 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => Promise<void>; | 96 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; |
| 97 | readonly onReconnectEnvironment: () => void; | ||
| 87 | readonly onExpandedChange?: (expanded: boolean) => void; | 98 | readonly onExpandedChange?: (expanded: boolean) => void; |
| 88 | } | 99 | } |
| 89 | 100 | ||
| ... | @@ -126,21 +137,67 @@ function ComposerSurface(props: { | ... | @@ -126,21 +137,67 @@ function ComposerSurface(props: { |
| 126 | ); | 137 | ); |
| 127 | } | 138 | } |
| 128 | 139 | ||
| 129 | function withModelSelectionOption( | 140 | function composerConnectionStatus(input: { |
| 130 | selection: ModelSelection, | 141 | readonly connectionError: string | null; |
| 131 | id: string, | 142 | readonly connectionState: RemoteClientConnectionState; |
| 132 | value: string | boolean | undefined, | 143 | readonly environmentLabel: string | null; |
| 133 | ): ModelSelection { | 144 | }): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null { |
| 134 | const options = (selection.options ?? []).filter((option) => option.id !== id); | 145 | const environmentLabel = input.environmentLabel ?? "Environment"; |
| 135 | return { | 146 | |
| 136 | ...selection, | 147 | switch (input.connectionState) { |
| 137 | options: value === undefined ? options : [...options, { id, value }], | 148 | case "connecting": |
| 138 | }; | 149 | case "reconnecting": |
| 150 | return { | ||
| 151 | kind: "reconnecting", | ||
| 152 | label: | ||
| 153 | input.connectionError === null | ||
| 154 | ? `Reconnecting to ${environmentLabel}...` | ||
| 155 | : `Failed to connect. Retrying ${environmentLabel}...`, | ||
| 156 | }; | ||
| 157 | case "offline": | ||
| 158 | return { kind: "unavailable", label: "You are offline" }; | ||
| 159 | case "error": | ||
| 160 | return { | ||
| 161 | kind: "unavailable", | ||
| 162 | label: input.connectionError | ||
| 163 | ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` | ||
| 164 | : `Failed to connect to ${environmentLabel}`, | ||
| 165 | }; | ||
| 166 | case "available": | ||
| 167 | return { kind: "unavailable", label: `${environmentLabel} is not connected` }; | ||
| 168 | case "connected": | ||
| 169 | return null; | ||
| 170 | } | ||
| 139 | } | 171 | } |
| 140 | 172 | ||
| 141 | function formatTitleCase(value: string): string { | 173 | const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { |
| 142 | return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; | 174 | readonly onPress: () => void; |
| 143 | } | 175 | readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string }; |
| 176 | }) { | ||
| 177 | const isReconnecting = props.status.kind === "reconnecting"; | ||
| 178 | |||
| 179 | return ( | ||
| 180 | <View className="items-center pb-2"> | ||
| 181 | <Pressable | ||
| 182 | accessibilityRole="button" | ||
| 183 | onPress={props.onPress} | ||
| 184 | className="max-w-full flex-row items-center gap-2 rounded-full bg-white/90 px-3 py-2 shadow-sm active:opacity-70 dark:bg-neutral-900/90" | ||
| 185 | > | ||
| 186 | {isReconnecting ? ( | ||
| 187 | <ActivityIndicator size="small" color="#8e8e93" /> | ||
| 188 | ) : ( | ||
| 189 | <View className="h-2 w-2 rounded-full bg-red-500" /> | ||
| 190 | )} | ||
| 191 | <Text | ||
| 192 | className="max-w-[260px] text-[13px] font-t3-bold leading-[17px] text-foreground" | ||
| 193 | numberOfLines={1} | ||
| 194 | > | ||
| 195 | {props.status.label} | ||
| 196 | </Text> | ||
| 197 | </Pressable> | ||
| 198 | </View> | ||
| 199 | ); | ||
| 200 | }); | ||
| 144 | 201 | ||
| 145 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | 202 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 146 | const isDarkMode = useColorScheme() === "dark"; | 203 | const isDarkMode = useColorScheme() === "dark"; |
| ... | @@ -154,7 +211,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -154,7 +211,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 154 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | 211 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); |
| 155 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; | 212 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; |
| 156 | const isExpanded = isFocused; | 213 | const isExpanded = isFocused; |
| 157 | const canSend = props.connectionState === "ready" && hasContent; | 214 | const canSend = hasContent; |
| 158 | 215 | ||
| 159 | const onPressImage = useCallback( | 216 | const onPressImage = useCallback( |
| 160 | (uri: string) => { | 217 | (uri: string) => { |
| ... | @@ -182,13 +239,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -182,13 +239,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 182 | }, [onExpandedChange]); | 239 | }, [onExpandedChange]); |
| 183 | const showStopAction = | 240 | const showStopAction = |
| 184 | props.selectedThread.session?.status === "running" || | 241 | props.selectedThread.session?.status === "running" || |
| 185 | props.selectedThread.session?.status === "starting" || | 242 | props.selectedThread.session?.status === "starting"; |
| 186 | props.queueCount > 0; | ||
| 187 | 243 | ||
| 188 | const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send"; | 244 | const sendLabel = |
| 245 | props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 | ||
| 246 | ? "Queue" | ||
| 247 | : "Send"; | ||
| 189 | const currentModelSelection = props.selectedThread.modelSelection; | 248 | const currentModelSelection = props.selectedThread.modelSelection; |
| 190 | const currentRuntimeMode = props.selectedThread.runtimeMode; | 249 | const currentRuntimeMode = props.selectedThread.runtimeMode; |
| 191 | const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; | 250 | const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; |
| 251 | const connectionStatus = composerConnectionStatus({ | ||
| 252 | connectionError: props.connectionError, | ||
| 253 | connectionState: props.connectionState, | ||
| 254 | environmentLabel: props.environmentLabel, | ||
| 255 | }); | ||
| 192 | const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; | 256 | const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; |
| 193 | const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; | 257 | const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; |
| 194 | const selectedProviderStatus = useMemo(() => { | 258 | const selectedProviderStatus = useMemo(() => { |
| ... | @@ -200,18 +264,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -200,18 +264,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 200 | ); | 264 | ); |
| 201 | }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); | 265 | }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); |
| 202 | 266 | ||
| 203 | // Extract current model options (effort, fastMode, contextWindow) | ||
| 204 | const selectedProviderDriver = selectedProviderStatus?.driver ?? null; | ||
| 205 | const currentEffort = | ||
| 206 | selectedProviderDriver === "claudeAgent" | ||
| 207 | ? (getModelSelectionStringOptionValue(currentModelSelection, "effort") ?? "high") | ||
| 208 | : "high"; | ||
| 209 | const currentFastMode = | ||
| 210 | getModelSelectionBooleanOptionValue(currentModelSelection, "fastMode") ?? false; | ||
| 211 | const currentContextWindow = | ||
| 212 | selectedProviderDriver === "claudeAgent" | ||
| 213 | ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") | ||
| 214 | : "1M"; | ||
| 215 | // ── Trigger detection ──────────────────────────────────── | 267 | // ── Trigger detection ──────────────────────────────────── |
| 216 | const [composerSelection, setComposerSelection] = useState(() => ({ | 268 | const [composerSelection, setComposerSelection] = useState(() => ({ |
| 217 | start: props.draftMessage.length, | 269 | start: props.draftMessage.length, |
| ... | @@ -394,8 +446,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -394,8 +446,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 394 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; | 446 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; |
| 395 | 447 | ||
| 396 | const handleSend = useCallback(() => { | 448 | const handleSend = useCallback(() => { |
| 397 | onSendMessage(); | 449 | void onSendMessage().then(() => { |
| 398 | inputRef.current?.blur(); | 450 | inputRef.current?.blur(); |
| 451 | }); | ||
| 399 | }, [onSendMessage]); | 452 | }, [onSendMessage]); |
| 400 | const handleCommandSelect = useCallback( | 453 | const handleCommandSelect = useCallback( |
| 401 | (item: ComposerCommandItem) => { | 454 | (item: ComposerCommandItem) => { |
| ... | @@ -413,7 +466,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -413,7 +466,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 413 | ); | 466 | ); |
| 414 | setComposerSelection({ start: result.cursor, end: result.cursor }); | 467 | setComposerSelection({ start: result.cursor, end: result.cursor }); |
| 415 | onChangeDraftMessage(result.text); | 468 | onChangeDraftMessage(result.text); |
| 416 | void onUpdateInteractionMode(item.command); | 469 | onUpdateInteractionMode(item.command); |
| 417 | return; | 470 | return; |
| 418 | } | 471 | } |
| 419 | 472 | ||
| ... | @@ -452,14 +505,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -452,14 +505,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 452 | option.selection.instanceId === currentModelSelection.instanceId && | 505 | option.selection.instanceId === currentModelSelection.instanceId && |
| 453 | option.selection.model === currentModelSelection.model, | 506 | option.selection.model === currentModelSelection.model, |
| 454 | ) ?? null; | 507 | ) ?? null; |
| 455 | const configurationLabel = useMemo(() => { | 508 | const providerOptionDescriptors = useMemo( |
| 456 | const parts = [ | 509 | () => |
| 457 | formatTitleCase(currentEffort), | 510 | resolveProviderOptionDescriptors({ |
| 458 | currentFastMode ? "Fast" : null, | 511 | capabilities: currentModelOption?.capabilities, |
| 459 | currentContextWindow !== "1M" ? currentContextWindow : null, | 512 | selections: currentModelSelection.options, |
| 460 | ].filter((part): part is string => Boolean(part)); | 513 | }), |
| 461 | return parts.length > 0 ? parts.join(" · ") : "Configuration"; | 514 | [currentModelOption?.capabilities, currentModelSelection.options], |
| 462 | }, [currentContextWindow, currentEffort, currentFastMode]); | 515 | ); |
| 516 | const configurationLabel = useMemo( | ||
| 517 | () => providerOptionsConfigurationLabel(providerOptionDescriptors), | ||
| 518 | [providerOptionDescriptors], | ||
| 519 | ); | ||
| 463 | const modelMenuActions = useMemo( | 520 | const modelMenuActions = useMemo( |
| 464 | () => | 521 | () => |
| 465 | providerGroups.map((group) => ({ | 522 | providerGroups.map((group) => ({ |
| ... | @@ -486,36 +543,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -486,36 +543,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 486 | // ── Options menu ───────────────────────────────────────── | 543 | // ── Options menu ───────────────────────────────────────── |
| 487 | const optionsMenuActions = useMemo( | 544 | const optionsMenuActions = useMemo( |
| 488 | () => [ | 545 | () => [ |
| 489 | { | 546 | ...buildProviderOptionMenuActions(providerOptionDescriptors), |
| 490 | id: "options-effort", | ||
| 491 | title: "Effort", | ||
| 492 | subtitle: `${currentEffort.charAt(0).toUpperCase()}${currentEffort.slice(1)}`, | ||
| 493 | subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ | ||
| 494 | id: `options:effort:${level}`, | ||
| 495 | title: `${level}${level === "high" ? " (default)" : ""}`, | ||
| 496 | state: currentEffort === level ? ("on" as const) : undefined, | ||
| 497 | })), | ||
| 498 | }, | ||
| 499 | { | ||
| 500 | id: "options-fast-mode", | ||
| 501 | title: "Fast Mode", | ||
| 502 | subtitle: currentFastMode ? "On" : "Off", | ||
| 503 | subactions: ([false, true] as const).map((value) => ({ | ||
| 504 | id: `options:fast-mode:${value ? "on" : "off"}`, | ||
| 505 | title: value ? "On" : "Off", | ||
| 506 | state: currentFastMode === value ? ("on" as const) : undefined, | ||
| 507 | })), | ||
| 508 | }, | ||
| 509 | { | ||
| 510 | id: "options-context-window", | ||
| 511 | title: "Context Window", | ||
| 512 | subtitle: currentContextWindow, | ||
| 513 | subactions: (["200k", "1M"] as const).map((value) => ({ | ||
| 514 | id: `options:context-window:${value}`, | ||
| 515 | title: `${value}${value === "1M" ? " (default)" : ""}`, | ||
| 516 | state: currentContextWindow === value ? ("on" as const) : undefined, | ||
| 517 | })), | ||
| 518 | }, | ||
| 519 | { | 547 | { |
| 520 | id: "options-runtime", | 548 | id: "options-runtime", |
| 521 | title: "Runtime", | 549 | title: "Runtime", |
| ... | @@ -555,13 +583,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -555,13 +583,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 555 | }), | 583 | }), |
| 556 | }, | 584 | }, |
| 557 | ], | 585 | ], |
| 558 | [ | 586 | [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors], |
| 559 | currentEffort, | ||
| 560 | currentFastMode, | ||
| 561 | currentContextWindow, | ||
| 562 | currentRuntimeMode, | ||
| 563 | currentInteractionMode, | ||
| 564 | ], | ||
| 565 | ); | 587 | ); |
| 566 | 588 | ||
| 567 | // ── Menu handlers ──────────────────────────────────────── | 589 | // ── Menu handlers ──────────────────────────────────────── |
| ... | @@ -572,51 +594,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -572,51 +594,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 572 | const modelKey = event.slice("model:".length); | 594 | const modelKey = event.slice("model:".length); |
| 573 | const option = modelOptions.find((o) => o.key === modelKey); | 595 | const option = modelOptions.find((o) => o.key === modelKey); |
| 574 | if (option) { | 596 | if (option) { |
| 575 | void props.onUpdateModelSelection(option.selection); | 597 | props.onUpdateModelSelection(option.selection); |
| 576 | } | 598 | } |
| 577 | } | 599 | } |
| 578 | 600 | ||
| 579 | function handleOptionsMenuAction(event: string) { | 601 | function handleOptionsMenuAction(event: string) { |
| 580 | if (event.startsWith("options:effort:")) { | 602 | const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); |
| 581 | const effort = event.slice("options:effort:".length); | 603 | if (providerOptions) { |
| 582 | const updated: ModelSelection = | 604 | props.onUpdateModelSelection({ |
| 583 | selectedProviderDriver === "claudeAgent" | 605 | ...currentModelSelection, |
| 584 | ? withModelSelectionOption( | 606 | options: providerOptions, |
| 585 | currentModelSelection, | 607 | }); |
| 586 | "effort", | ||
| 587 | effort as typeof currentEffort, | ||
| 588 | ) | ||
| 589 | : currentModelSelection; | ||
| 590 | void props.onUpdateModelSelection(updated); | ||
| 591 | return; | ||
| 592 | } | ||
| 593 | if (event.startsWith("options:fast-mode:")) { | ||
| 594 | const fastMode = event.endsWith(":on"); | ||
| 595 | const nextFast = fastMode || undefined; | ||
| 596 | if (selectedProviderDriver === "opencode") { | ||
| 597 | return; | ||
| 598 | } | ||
| 599 | const updated = withModelSelectionOption(currentModelSelection, "fastMode", nextFast); | ||
| 600 | void props.onUpdateModelSelection(updated); | ||
| 601 | return; | ||
| 602 | } | ||
| 603 | if (event.startsWith("options:context-window:")) { | ||
| 604 | const contextWindow = event.slice("options:context-window:".length); | ||
| 605 | const updated: ModelSelection = | ||
| 606 | selectedProviderDriver === "claudeAgent" | ||
| 607 | ? withModelSelectionOption(currentModelSelection, "contextWindow", contextWindow) | ||
| 608 | : currentModelSelection; | ||
| 609 | void props.onUpdateModelSelection(updated); | ||
| 610 | return; | 608 | return; |
| 611 | } | 609 | } |
| 612 | if (event.startsWith("options:runtime:")) { | 610 | if (event.startsWith("options:runtime:")) { |
| 613 | const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode; | 611 | const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode; |
| 614 | void props.onUpdateRuntimeMode(runtimeMode); | 612 | props.onUpdateRuntimeMode(runtimeMode); |
| 615 | return; | 613 | return; |
| 616 | } | 614 | } |
| 617 | if (event.startsWith("options:interaction:")) { | 615 | if (event.startsWith("options:interaction:")) { |
| 618 | const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode; | 616 | const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode; |
| 619 | void props.onUpdateInteractionMode(interactionMode); | 617 | props.onUpdateInteractionMode(interactionMode); |
| 620 | } | 618 | } |
| 621 | } | 619 | } |
| 622 | 620 | ||
| ... | @@ -652,6 +650,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -652,6 +650,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 652 | </View> | 650 | </View> |
| 653 | ) : null} | 651 | ) : null} |
| 654 | 652 | ||
| 653 | {connectionStatus ? ( | ||
| 654 | <ComposerConnectionStatusPill | ||
| 655 | status={connectionStatus} | ||
| 656 | onPress={props.onReconnectEnvironment} | ||
| 657 | /> | ||
| 658 | ) : null} | ||
| 659 | |||
| 655 | <ComposerSurface | 660 | <ComposerSurface |
| 656 | isDarkMode={isDarkMode} | 661 | isDarkMode={isDarkMode} |
| 657 | style={ | 662 | style={ |
| ... | @@ -755,11 +760,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -755,11 +760,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 755 | ) : null} | 760 | ) : null} |
| 756 | {!isExpanded ? ( | 761 | {!isExpanded ? ( |
| 757 | showStopAction ? ( | 762 | showStopAction ? ( |
| 758 | <ControlPill | 763 | <ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} /> |
| 759 | icon="stop.fill" | ||
| 760 | variant="danger" | ||
| 761 | onPress={() => void props.onStopThread()} | ||
| 762 | /> | ||
| 763 | ) : ( | 764 | ) : ( |
| 764 | <ControlPill | 765 | <ControlPill |
| 765 | icon="arrow.up" | 766 | icon="arrow.up" |
| ... | @@ -809,7 +810,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -809,7 +810,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 809 | <ComposerToolbarButton | 810 | <ComposerToolbarButton |
| 810 | icon="stop.fill" | 811 | icon="stop.fill" |
| 811 | variant="danger" | 812 | variant="danger" |
| 812 | onPress={() => void props.onStopThread()} | 813 | onPress={props.onStopThread} |
| 813 | showChevron={false} | 814 | showChevron={false} |
| 814 | /> | 815 | /> |
| 815 | ) : null} | 816 | ) : null} |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+22-18| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 1 | import type { | 2 | import type { |
| 2 | ApprovalRequestId, | 3 | ApprovalRequestId, |
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| 4 | ModelSelection, | 5 | ModelSelection, |
| 5 | OrchestrationThread, | 6 | OrchestrationThreadShell, |
| 6 | ProviderApprovalDecision, | 7 | ProviderApprovalDecision, |
| 7 | ProviderInteractionMode, | 8 | ProviderInteractionMode, |
| 8 | RuntimeMode, | 9 | RuntimeMode, |
| ... | @@ -23,7 +24,7 @@ import { AppText as Text } from "../../components/AppText"; | ... | @@ -23,7 +24,7 @@ import { AppText as Text } from "../../components/AppText"; |
| 23 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 24 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 24 | import type { StatusTone } from "../../components/StatusPill"; | 25 | import type { StatusTone } from "../../components/StatusPill"; |
| 25 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 26 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 26 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | 27 | import type { LayoutVariant } from "../../lib/layout"; |
| 27 | import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; | 28 | import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; |
| 28 | import type { | 29 | import type { |
| 29 | PendingApproval, | 30 | PendingApproval, |
| ... | @@ -39,13 +40,14 @@ import { | ... | @@ -39,13 +40,14 @@ import { |
| 39 | ThreadComposer, | 40 | ThreadComposer, |
| 40 | } from "./ThreadComposer"; | 41 | } from "./ThreadComposer"; |
| 41 | import { ThreadFeed } from "./ThreadFeed"; | 42 | import { ThreadFeed } from "./ThreadFeed"; |
| 43 | import type { ThreadContentPresentation } from "./threadContentPresentation"; | ||
| 42 | 44 | ||
| 43 | export interface ThreadDetailScreenProps { | 45 | export interface ThreadDetailScreenProps { |
| 44 | readonly selectedThread: OrchestrationThread; | 46 | readonly selectedThread: OrchestrationThreadShell; |
| 47 | readonly contentPresentation: ThreadContentPresentation; | ||
| 45 | readonly screenTone: StatusTone; | 48 | readonly screenTone: StatusTone; |
| 46 | readonly connectionError: string | null; | 49 | readonly connectionError: string | null; |
| 47 | readonly httpBaseUrl: string | null; | 50 | readonly environmentLabel: string | null; |
| 48 | readonly bearerToken: string | null; | ||
| 49 | readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; | 51 | readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; |
| 50 | readonly activeWorkStartedAt: string | null; | 52 | readonly activeWorkStartedAt: string | null; |
| 51 | readonly activePendingApproval: PendingApproval | null; | 53 | readonly activePendingApproval: PendingApproval | null; |
| ... | @@ -56,30 +58,29 @@ export interface ThreadDetailScreenProps { | ... | @@ -56,30 +58,29 @@ export interface ThreadDetailScreenProps { |
| 56 | readonly respondingUserInputId: ApprovalRequestId | null; | 58 | readonly respondingUserInputId: ApprovalRequestId | null; |
| 57 | readonly draftMessage: string; | 59 | readonly draftMessage: string; |
| 58 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 60 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 59 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; | 61 | readonly connectionStateLabel: EnvironmentConnectionPhase; |
| 60 | readonly activeThreadBusy: boolean; | 62 | readonly activeThreadBusy: boolean; |
| 61 | readonly environmentId: EnvironmentId; | 63 | readonly environmentId: EnvironmentId; |
| 62 | readonly projectWorkspaceRoot: string | null; | 64 | readonly projectWorkspaceRoot: string | null; |
| 63 | readonly selectedThreadQueueCount: number; | 65 | readonly selectedThreadQueueCount: number; |
| 64 | readonly serverConfig: T3ServerConfig | null; | 66 | readonly serverConfig: T3ServerConfig | null; |
| 65 | readonly layoutVariant?: MobileLayoutVariant; | 67 | readonly layoutVariant?: LayoutVariant; |
| 66 | readonly onOpenDrawer: () => void; | 68 | readonly onOpenDrawer: () => void; |
| 67 | readonly onOpenConnectionEditor: () => void; | 69 | readonly onOpenConnectionEditor: () => void; |
| 68 | readonly onChangeDraftMessage: (value: string) => void; | 70 | readonly onChangeDraftMessage: (value: string) => void; |
| 69 | readonly onPickDraftImages: () => Promise<void>; | 71 | readonly onPickDraftImages: () => Promise<void>; |
| 70 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 72 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 71 | readonly onRemoveDraftImage: (imageId: string) => void; | 73 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 72 | readonly onStopThread: () => Promise<void>; | 74 | readonly onStopThread: () => void; |
| 73 | readonly onSendMessage: () => void; | 75 | readonly onSendMessage: () => Promise<void>; |
| 74 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>; | 76 | readonly onReconnectEnvironment: () => void; |
| 75 | readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise<void>; | 77 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; |
| 76 | readonly onUpdateThreadInteractionMode: ( | 78 | readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; |
| 77 | interactionMode: ProviderInteractionMode, | 79 | readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; |
| 78 | ) => Promise<void>; | ||
| 79 | readonly onRespondToApproval: ( | 80 | readonly onRespondToApproval: ( |
| 80 | requestId: ApprovalRequestId, | 81 | requestId: ApprovalRequestId, |
| 81 | decision: ProviderApprovalDecision, | 82 | decision: ProviderApprovalDecision, |
| 82 | ) => Promise<void>; | 83 | ) => Promise<unknown>; |
| 83 | readonly onSelectUserInputOption: ( | 84 | readonly onSelectUserInputOption: ( |
| 84 | requestId: ApprovalRequestId, | 85 | requestId: ApprovalRequestId, |
| 85 | questionId: string, | 86 | questionId: string, |
| ... | @@ -90,7 +91,7 @@ export interface ThreadDetailScreenProps { | ... | @@ -90,7 +91,7 @@ export interface ThreadDetailScreenProps { |
| 90 | questionId: string, | 91 | questionId: string, |
| 91 | customAnswer: string, | 92 | customAnswer: string, |
| 92 | ) => void; | 93 | ) => void; |
| 93 | readonly onSubmitUserInput: () => Promise<void>; | 94 | readonly onSubmitUserInput: () => Promise<unknown>; |
| 94 | readonly showContent?: boolean; | 95 | readonly showContent?: boolean; |
| 95 | } | 96 | } |
| 96 | 97 | ||
| ... | @@ -306,10 +307,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -306,10 +307,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 306 | > | 307 | > |
| 307 | <ThreadFeed | 308 | <ThreadFeed |
| 308 | key={props.selectedThread.id} | 309 | key={props.selectedThread.id} |
| 310 | environmentId={props.environmentId} | ||
| 309 | threadId={props.selectedThread.id} | 311 | threadId={props.selectedThread.id} |
| 310 | feed={props.selectedThreadFeed} | 312 | feed={props.selectedThreadFeed} |
| 311 | httpBaseUrl={props.httpBaseUrl} | 313 | contentPresentation={props.contentPresentation} |
| 312 | bearerToken={props.bearerToken} | ||
| 313 | agentLabel={agentLabel} | 314 | agentLabel={agentLabel} |
| 314 | latestTurn={props.selectedThread.latestTurn} | 315 | latestTurn={props.selectedThread.latestTurn} |
| 315 | contentTopInset={headerHeight} | 316 | contentTopInset={headerHeight} |
| ... | @@ -363,6 +364,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -363,6 +364,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 363 | draftAttachments={props.draftAttachments} | 364 | draftAttachments={props.draftAttachments} |
| 364 | placeholder="Ask the repo agent, or run a command…" | 365 | placeholder="Ask the repo agent, or run a command…" |
| 365 | connectionState={props.connectionStateLabel} | 366 | connectionState={props.connectionStateLabel} |
| 367 | connectionError={props.connectionError} | ||
| 368 | environmentLabel={props.environmentLabel} | ||
| 366 | selectedThread={props.selectedThread} | 369 | selectedThread={props.selectedThread} |
| 367 | serverConfig={props.serverConfig} | 370 | serverConfig={props.serverConfig} |
| 368 | queueCount={props.selectedThreadQueueCount} | 371 | queueCount={props.selectedThreadQueueCount} |
| ... | @@ -376,6 +379,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -376,6 +379,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 376 | onRemoveDraftImage={props.onRemoveDraftImage} | 379 | onRemoveDraftImage={props.onRemoveDraftImage} |
| 377 | onStopThread={props.onStopThread} | 380 | onStopThread={props.onStopThread} |
| 378 | onSendMessage={props.onSendMessage} | 381 | onSendMessage={props.onSendMessage} |
| 382 | onReconnectEnvironment={props.onReconnectEnvironment} | ||
| 379 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | 383 | onUpdateModelSelection={props.onUpdateThreadModelSelection} |
| 380 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | 384 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} |
| 381 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | 385 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} |
apps/mobile/src/features/threads/ThreadFeed.tsx+112-65| ... | @@ -2,7 +2,7 @@ import * as Clipboard from "expo-clipboard"; | ... | @@ -2,7 +2,7 @@ import * as Clipboard from "expo-clipboard"; |
| 2 | import * as Haptics from "expo-haptics"; | 2 | import * as Haptics from "expo-haptics"; |
| 3 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; | 3 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; |
| 4 | import { type LegendListRef } from "@legendapp/list/react-native"; | 4 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 5 | import type { ThreadId, TurnId } from "@t3tools/contracts"; | 5 | import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 6 | import { SymbolView } from "expo-symbols"; | 6 | import { SymbolView } from "expo-symbols"; |
| 7 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 7 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 8 | import { | 8 | import { |
| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | type PartialMarkdownTheme, | 12 | type PartialMarkdownTheme, |
| 13 | } from "react-native-nitro-markdown"; | 13 | } from "react-native-nitro-markdown"; |
| 14 | import { | 14 | import { |
| 15 | ActivityIndicator, | ||
| 15 | Image, | 16 | Image, |
| 16 | Linking, | 17 | Linking, |
| 17 | type NativeScrollEvent, | 18 | type NativeScrollEvent, |
| ... | @@ -37,7 +38,6 @@ import { | ... | @@ -37,7 +38,6 @@ import { |
| 37 | } from "../../native/SelectableMarkdownText"; | 38 | } from "../../native/SelectableMarkdownText"; |
| 38 | 39 | ||
| 39 | import { AppText as Text } from "../../components/AppText"; | 40 | import { AppText as Text } from "../../components/AppText"; |
| 40 | import { EmptyState } from "../../components/EmptyState"; | ||
| 41 | import { CopyTextButton } from "../../components/CopyTextButton"; | 41 | import { CopyTextButton } from "../../components/CopyTextButton"; |
| 42 | import { | 42 | import { |
| 43 | parseReviewCommentMessageSegments, | 43 | parseReviewCommentMessageSegments, |
| ... | @@ -53,7 +53,7 @@ import { | ... | @@ -53,7 +53,7 @@ import { |
| 53 | } from "../review/nativeReviewDiffAdapter"; | 53 | } from "../review/nativeReviewDiffAdapter"; |
| 54 | import { buildReviewParsedDiff } from "../review/reviewModel"; | 54 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 55 | import { cn } from "../../lib/cn"; | 55 | import { cn } from "../../lib/cn"; |
| 56 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | 56 | import type { LayoutVariant } from "../../lib/layout"; |
| 57 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 57 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 58 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | 58 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 59 | import { | 59 | import { |
| ... | @@ -63,7 +63,8 @@ import { | ... | @@ -63,7 +63,8 @@ import { |
| 63 | } from "../../lib/threadActivity"; | 63 | } from "../../lib/threadActivity"; |
| 64 | import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout"; | 64 | import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout"; |
| 65 | import { relativeTime } from "../../lib/time"; | 65 | import { relativeTime } from "../../lib/time"; |
| 66 | import { messageImageUrl } from "./threadPresentation"; | 66 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 67 | import { useAssetUrl } from "../../state/assets"; | ||
| 67 | 68 | ||
| 68 | const THREAD_FEED_END_THRESHOLD = 80; | 69 | const THREAD_FEED_END_THRESHOLD = 80; |
| 69 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { | 70 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { |
| ... | @@ -80,19 +81,45 @@ function formatMessageTime(input: string): string { | ... | @@ -80,19 +81,45 @@ function formatMessageTime(input: string): string { |
| 80 | } | 81 | } |
| 81 | 82 | ||
| 82 | export interface ThreadFeedProps { | 83 | export interface ThreadFeedProps { |
| 84 | readonly environmentId: EnvironmentId; | ||
| 83 | readonly threadId: ThreadId; | 85 | readonly threadId: ThreadId; |
| 84 | readonly feed: ReadonlyArray<ThreadFeedEntry>; | 86 | readonly feed: ReadonlyArray<ThreadFeedEntry>; |
| 85 | readonly httpBaseUrl: string | null; | 87 | readonly contentPresentation: ThreadContentPresentation; |
| 86 | readonly bearerToken: string | null; | ||
| 87 | readonly agentLabel: string; | 88 | readonly agentLabel: string; |
| 88 | readonly latestTurn: ThreadFeedLatestTurn | null; | 89 | readonly latestTurn: ThreadFeedLatestTurn | null; |
| 89 | readonly contentTopInset?: number; | 90 | readonly contentTopInset?: number; |
| 90 | readonly contentBottomInset?: number; | 91 | readonly contentBottomInset?: number; |
| 91 | readonly layoutVariant?: MobileLayoutVariant; | 92 | readonly layoutVariant?: LayoutVariant; |
| 92 | readonly composerExpanded?: boolean; | 93 | readonly composerExpanded?: boolean; |
| 93 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | 94 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 94 | } | 95 | } |
| 95 | 96 | ||
| 97 | function MessageAttachmentImage(props: { | ||
| 98 | readonly environmentId: EnvironmentId; | ||
| 99 | readonly attachmentId: string; | ||
| 100 | readonly className: string; | ||
| 101 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | ||
| 102 | }) { | ||
| 103 | const uri = useAssetUrl(props.environmentId, { | ||
| 104 | _tag: "attachment", | ||
| 105 | attachmentId: props.attachmentId, | ||
| 106 | }); | ||
| 107 | |||
| 108 | if (uri === null) { | ||
| 109 | return ( | ||
| 110 | <View className={`${props.className} items-center justify-center`}> | ||
| 111 | <ActivityIndicator /> | ||
| 112 | </View> | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | |||
| 116 | return ( | ||
| 117 | <TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}> | ||
| 118 | <Image source={{ uri }} className={props.className} resizeMode="cover" /> | ||
| 119 | </TouchableOpacity> | ||
| 120 | ); | ||
| 121 | } | ||
| 122 | |||
| 96 | function stripShellWrapper(value: string): string { | 123 | function stripShellWrapper(value: string): string { |
| 97 | const trimmed = value.trim(); | 124 | const trimmed = value.trim(); |
| 98 | const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); | 125 | const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); |
| ... | @@ -654,7 +681,7 @@ function useMarkdownStyles(): MarkdownStyleSets { | ... | @@ -654,7 +681,7 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 654 | 681 | ||
| 655 | function renderFeedEntry( | 682 | function renderFeedEntry( |
| 656 | info: { item: ThreadFeedEntry; index: number }, | 683 | info: { item: ThreadFeedEntry; index: number }, |
| 657 | props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl" | "skills"> & { | 684 | props: Pick<ThreadFeedProps, "environmentId" | "skills"> & { |
| 658 | readonly copiedRowId: string | null; | 685 | readonly copiedRowId: string | null; |
| 659 | readonly expandedWorkGroups: Record<string, boolean>; | 686 | readonly expandedWorkGroups: Record<string, boolean>; |
| 660 | readonly expandedWorkRows: Record<string, boolean>; | 687 | readonly expandedWorkRows: Record<string, boolean>; |
| ... | @@ -733,26 +760,14 @@ function renderFeedEntry( | ... | @@ -733,26 +760,14 @@ function renderFeedEntry( |
| 733 | /> | 760 | /> |
| 734 | ) : null} | 761 | ) : null} |
| 735 | {attachments.map((attachment) => { | 762 | {attachments.map((attachment) => { |
| 736 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); | ||
| 737 | if (!uri) { | ||
| 738 | return null; | ||
| 739 | } | ||
| 740 | const headers = props.bearerToken | ||
| 741 | ? { Authorization: `Bearer ${props.bearerToken}` } | ||
| 742 | : undefined; | ||
| 743 | |||
| 744 | return ( | 763 | return ( |
| 745 | <TouchableOpacity | 764 | <MessageAttachmentImage |
| 746 | key={attachment.id} | 765 | key={attachment.id} |
| 747 | activeOpacity={0.7} | 766 | environmentId={props.environmentId} |
| 748 | onPress={() => props.onPressImage(uri, headers)} | 767 | attachmentId={attachment.id} |
| 749 | > | 768 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" |
| 750 | <Image | 769 | onPressImage={props.onPressImage} |
| 751 | source={{ uri, ...(headers ? { headers } : {}) }} | 770 | /> |
| 752 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" | ||
| 753 | resizeMode="cover" | ||
| 754 | /> | ||
| 755 | </TouchableOpacity> | ||
| 756 | ); | 771 | ); |
| 757 | })} | 772 | })} |
| 758 | </View> | 773 | </View> |
| ... | @@ -801,27 +816,14 @@ function renderFeedEntry( | ... | @@ -801,27 +816,14 @@ function renderFeedEntry( |
| 801 | ) | 816 | ) |
| 802 | ) : null} | 817 | ) : null} |
| 803 | {attachments.map((attachment) => { | 818 | {attachments.map((attachment) => { |
| 804 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); | ||
| 805 | if (!uri) { | ||
| 806 | return null; | ||
| 807 | } | ||
| 808 | const headers = props.bearerToken | ||
| 809 | ? { Authorization: `Bearer ${props.bearerToken}` } | ||
| 810 | : undefined; | ||
| 811 | |||
| 812 | return ( | 819 | return ( |
| 813 | <TouchableOpacity | 820 | <MessageAttachmentImage |
| 814 | key={attachment.id} | 821 | key={attachment.id} |
| 815 | activeOpacity={0.7} | 822 | environmentId={props.environmentId} |
| 816 | className="mt-1.5" | 823 | attachmentId={attachment.id} |
| 817 | onPress={() => props.onPressImage(uri, headers)} | 824 | className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800" |
| 818 | > | 825 | onPressImage={props.onPressImage} |
| 819 | <Image | 826 | /> |
| 820 | source={{ uri, ...(headers ? { headers } : {}) }} | ||
| 821 | className="aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800" | ||
| 822 | resizeMode="cover" | ||
| 823 | /> | ||
| 824 | </TouchableOpacity> | ||
| 825 | ); | 827 | ); |
| 826 | })} | 828 | })} |
| 827 | {showAssistantMeta ? ( | 829 | {showAssistantMeta ? ( |
| ... | @@ -1220,6 +1222,37 @@ function compactFileName(filePath: string): string { | ... | @@ -1220,6 +1222,37 @@ function compactFileName(filePath: string): string { |
| 1220 | return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; | 1222 | return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; |
| 1221 | } | 1223 | } |
| 1222 | 1224 | ||
| 1225 | function ThreadFeedPlaceholder(props: { | ||
| 1226 | readonly bottomInset: number; | ||
| 1227 | readonly detail: string; | ||
| 1228 | readonly horizontalPadding: number; | ||
| 1229 | readonly loading?: boolean; | ||
| 1230 | readonly title: string; | ||
| 1231 | readonly topInset: number; | ||
| 1232 | }) { | ||
| 1233 | return ( | ||
| 1234 | <View | ||
| 1235 | style={{ | ||
| 1236 | flex: 1, | ||
| 1237 | flexGrow: 1, | ||
| 1238 | alignItems: "center", | ||
| 1239 | justifyContent: "center", | ||
| 1240 | paddingTop: props.topInset, | ||
| 1241 | paddingBottom: props.bottomInset, | ||
| 1242 | paddingHorizontal: props.horizontalPadding + 24, | ||
| 1243 | }} | ||
| 1244 | > | ||
| 1245 | <View className="max-w-[320px] items-center gap-2"> | ||
| 1246 | {props.loading ? <ActivityIndicator style={{ marginBottom: 6 }} /> : null} | ||
| 1247 | <Text className="text-center font-t3-bold text-lg text-foreground">{props.title}</Text> | ||
| 1248 | <Text className="text-center text-sm leading-5 text-foreground-secondary"> | ||
| 1249 | {props.detail} | ||
| 1250 | </Text> | ||
| 1251 | </View> | ||
| 1252 | </View> | ||
| 1253 | ); | ||
| 1254 | } | ||
| 1255 | |||
| 1223 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | 1256 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1224 | const listRef = useRef<LegendListRef>(null); | 1257 | const listRef = useRef<LegendListRef>(null); |
| 1225 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 1258 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| ... | @@ -1446,9 +1479,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1446,9 +1479,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1446 | const renderItem = useCallback( | 1479 | const renderItem = useCallback( |
| 1447 | (info: { item: ThreadFeedEntry; index: number }) => | 1480 | (info: { item: ThreadFeedEntry; index: number }) => |
| 1448 | renderFeedEntry(info, { | 1481 | renderFeedEntry(info, { |
| 1449 | bearerToken: props.bearerToken, | 1482 | environmentId: props.environmentId, |
| 1450 | copiedRowId, | 1483 | copiedRowId, |
| 1451 | httpBaseUrl: props.httpBaseUrl, | ||
| 1452 | expandedWorkGroups, | 1484 | expandedWorkGroups, |
| 1453 | expandedWorkRows, | 1485 | expandedWorkRows, |
| 1454 | terminalAssistantMessageIds, | 1486 | terminalAssistantMessageIds, |
| ... | @@ -1481,30 +1513,45 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1481,30 +1513,45 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1481 | onToggleTurnFold, | 1513 | onToggleTurnFold, |
| 1482 | onToggleWorkGroup, | 1514 | onToggleWorkGroup, |
| 1483 | onToggleWorkRow, | 1515 | onToggleWorkRow, |
| 1484 | props.bearerToken, | 1516 | props.environmentId, |
| 1485 | props.httpBaseUrl, | ||
| 1486 | props.skills, | 1517 | props.skills, |
| 1487 | ], | 1518 | ], |
| 1488 | ); | 1519 | ); |
| 1489 | 1520 | ||
| 1521 | if (props.contentPresentation.kind === "loading") { | ||
| 1522 | return ( | ||
| 1523 | <ThreadFeedPlaceholder | ||
| 1524 | title="Loading conversation" | ||
| 1525 | detail="Fetching the latest messages from this environment." | ||
| 1526 | loading | ||
| 1527 | topInset={topContentInset} | ||
| 1528 | bottomInset={bottomContentInset} | ||
| 1529 | horizontalPadding={horizontalPadding} | ||
| 1530 | /> | ||
| 1531 | ); | ||
| 1532 | } | ||
| 1533 | |||
| 1534 | if (props.contentPresentation.kind === "unavailable") { | ||
| 1535 | return ( | ||
| 1536 | <ThreadFeedPlaceholder | ||
| 1537 | title={props.contentPresentation.title} | ||
| 1538 | detail={props.contentPresentation.detail} | ||
| 1539 | topInset={topContentInset} | ||
| 1540 | bottomInset={bottomContentInset} | ||
| 1541 | horizontalPadding={horizontalPadding} | ||
| 1542 | /> | ||
| 1543 | ); | ||
| 1544 | } | ||
| 1545 | |||
| 1490 | if (props.feed.length === 0) { | 1546 | if (props.feed.length === 0) { |
| 1491 | return ( | 1547 | return ( |
| 1492 | <ScrollView | 1548 | <ThreadFeedPlaceholder |
| 1493 | style={{ flex: 1 }} | 1549 | title="No conversation yet" |
| 1494 | contentInsetAdjustmentBehavior="never" | 1550 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." |
| 1495 | contentInset={{ top: topContentInset, bottom: bottomContentInset }} | 1551 | topInset={topContentInset} |
| 1496 | contentOffset={{ x: 0, y: -topContentInset }} | 1552 | bottomInset={bottomContentInset} |
| 1497 | scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }} | 1553 | horizontalPadding={horizontalPadding} |
| 1498 | contentContainerStyle={{ | 1554 | /> |
| 1499 | flexGrow: 1, | ||
| 1500 | paddingHorizontal: horizontalPadding, | ||
| 1501 | }} | ||
| 1502 | > | ||
| 1503 | <EmptyState | ||
| 1504 | title="No conversation yet" | ||
| 1505 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." | ||
| 1506 | /> | ||
| 1507 | </ScrollView> | ||
| 1508 | ); | 1555 | ); |
| 1509 | } | 1556 | } |
| 1510 | 1557 |
apps/mobile/src/features/threads/ThreadGitControls.tsx+1-1| ... | @@ -9,7 +9,7 @@ import { | ... | @@ -9,7 +9,7 @@ import { |
| 9 | type GitActionRequestInput, | 9 | type GitActionRequestInput, |
| 10 | requiresDefaultBranchConfirmation, | 10 | requiresDefaultBranchConfirmation, |
| 11 | resolveQuickAction, | 11 | resolveQuickAction, |
| 12 | } from "@t3tools/client-runtime"; | 12 | } from "@t3tools/client-runtime/state/vcs"; |
| 13 | import { useLocalSearchParams, useRouter } from "expo-router"; | 13 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 14 | import Stack from "expo-router/stack"; | 14 | import Stack from "expo-router/stack"; |
| 15 | import { useCallback, useMemo } from "react"; | 15 | import { useCallback, useMemo } from "react"; |
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+119-96| ... | @@ -1,6 +1,13 @@ | ... | @@ -1,6 +1,13 @@ |
| 1 | import { SymbolView } from "expo-symbols"; | 1 | import { SymbolView } from "expo-symbols"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; | 3 | import { |
| 4 | type ColorValue, | ||
| 5 | Modal, | ||
| 6 | Pressable, | ||
| 7 | ScrollView, | ||
| 8 | useWindowDimensions, | ||
| 9 | View, | ||
| 10 | } from "react-native"; | ||
| 4 | import * as Arr from "effect/Array"; | 11 | import * as Arr from "effect/Array"; |
| 5 | import * as Order from "effect/Order"; | 12 | import * as Order from "effect/Order"; |
| 6 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 13 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| ... | @@ -15,21 +22,19 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -15,21 +22,19 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 15 | 22 | ||
| 16 | import { AppText as Text } from "../../components/AppText"; | 23 | import { AppText as Text } from "../../components/AppText"; |
| 17 | import { StatusPill } from "../../components/StatusPill"; | 24 | import { StatusPill } from "../../components/StatusPill"; |
| 25 | import { useProjects, useThreadShells } from "../../state/entities"; | ||
| 18 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | 26 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 19 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 27 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 20 | import { relativeTime } from "../../lib/time"; | 28 | import { relativeTime } from "../../lib/time"; |
| 21 | import { threadStatusTone } from "./threadPresentation"; | 29 | import { threadStatusTone } from "./threadPresentation"; |
| 22 | import { | 30 | import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 23 | EnvironmentScopedProjectShell, | ||
| 24 | EnvironmentScopedThreadShell, | ||
| 25 | } from "@t3tools/client-runtime"; | ||
| 26 | 31 | ||
| 27 | const threadActivityOrder = Order.mapInput( | 32 | const threadActivityOrder = Order.mapInput( |
| 28 | Order.Struct({ | 33 | Order.Struct({ |
| 29 | activityAt: Order.flip(Order.Number), | 34 | activityAt: Order.flip(Order.Number), |
| 30 | title: Order.String, | 35 | title: Order.String, |
| 31 | }), | 36 | }), |
| 32 | (thread: EnvironmentScopedThreadShell) => ({ | 37 | (thread: EnvironmentThreadShell) => ({ |
| 33 | activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), | 38 | activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), |
| 34 | title: thread.title, | 39 | title: thread.title, |
| 35 | }), | 40 | }), |
| ... | @@ -37,11 +42,9 @@ const threadActivityOrder = Order.mapInput( | ... | @@ -37,11 +42,9 @@ const threadActivityOrder = Order.mapInput( |
| 37 | 42 | ||
| 38 | export function ThreadNavigationDrawer(props: { | 43 | export function ThreadNavigationDrawer(props: { |
| 39 | readonly visible: boolean; | 44 | readonly visible: boolean; |
| 40 | readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>; | ||
| 41 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | ||
| 42 | readonly selectedThreadKey: string | null; | 45 | readonly selectedThreadKey: string | null; |
| 43 | readonly onClose: () => void; | 46 | readonly onClose: () => void; |
| 44 | readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; | 47 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 45 | readonly onStartNewTask: () => void; | 48 | readonly onStartNewTask: () => void; |
| 46 | }) { | 49 | }) { |
| 47 | const insets = useSafeAreaInsets(); | 50 | const insets = useSafeAreaInsets(); |
| ... | @@ -57,26 +60,6 @@ export function ThreadNavigationDrawer(props: { | ... | @@ -57,26 +60,6 @@ export function ThreadNavigationDrawer(props: { |
| 57 | const primaryForeground = useThemeColor("--color-primary-foreground"); | 60 | const primaryForeground = useThemeColor("--color-primary-foreground"); |
| 58 | const borderSubtleColor = useThemeColor("--color-border-subtle"); | 61 | const borderSubtleColor = useThemeColor("--color-border-subtle"); |
| 59 | 62 | ||
| 60 | const repositoryGroups = useMemo( | ||
| 61 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), | ||
| 62 | [props.projects, props.threads], | ||
| 63 | ); | ||
| 64 | const groupedThreads = useMemo( | ||
| 65 | () => | ||
| 66 | repositoryGroups.map((group) => { | ||
| 67 | const threads: EnvironmentScopedThreadShell[] = []; | ||
| 68 | for (const projectGroup of group.projects) { | ||
| 69 | threads.push(...projectGroup.threads); | ||
| 70 | } | ||
| 71 | return { | ||
| 72 | key: group.key, | ||
| 73 | title: group.projects[0]?.project.title ?? group.title, | ||
| 74 | threads: Arr.sort(threads, threadActivityOrder), | ||
| 75 | }; | ||
| 76 | }), | ||
| 77 | [repositoryGroups], | ||
| 78 | ); | ||
| 79 | |||
| 80 | useEffect(() => { | 63 | useEffect(() => { |
| 81 | if (props.visible) { | 64 | if (props.visible) { |
| 82 | setMounted(true); | 65 | setMounted(true); |
| ... | @@ -186,76 +169,116 @@ export function ThreadNavigationDrawer(props: { | ... | @@ -186,76 +169,116 @@ export function ThreadNavigationDrawer(props: { |
| 186 | </Pressable> | 169 | </Pressable> |
| 187 | </View> | 170 | </View> |
| 188 | 171 | ||
| 189 | <ScrollView | 172 | <ThreadNavigationDrawerContent |
| 190 | showsVerticalScrollIndicator={false} | 173 | bottomInset={Math.max(insets.bottom, 18)} |
| 191 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 12 }} | 174 | borderSubtleColor={borderSubtleColor} |
| 192 | contentContainerStyle={{ | 175 | selectedThreadKey={props.selectedThreadKey} |
| 193 | gap: 20, | 176 | onClose={props.onClose} |
| 194 | paddingHorizontal: 14, | 177 | onSelectThread={props.onSelectThread} |
| 195 | }} | 178 | /> |
| 196 | > | ||
| 197 | {groupedThreads.map((group) => ( | ||
| 198 | <View key={group.key} className="gap-3"> | ||
| 199 | <Text | ||
| 200 | className="px-1 text-[15px] font-t3-bold text-foreground-muted" | ||
| 201 | style={{ letterSpacing: -0.2 }} | ||
| 202 | > | ||
| 203 | {group.title} | ||
| 204 | </Text> | ||
| 205 | |||
| 206 | <View className="overflow-hidden rounded-[22px] bg-card"> | ||
| 207 | {group.threads.length === 0 ? ( | ||
| 208 | <View className="px-4 py-4"> | ||
| 209 | <Text className="text-[14px] font-medium text-foreground-tertiary"> | ||
| 210 | No threads yet | ||
| 211 | </Text> | ||
| 212 | </View> | ||
| 213 | ) : ( | ||
| 214 | group.threads.map((thread, index) => { | ||
| 215 | const threadKey = scopedThreadKey(thread.environmentId, thread.id); | ||
| 216 | const selected = props.selectedThreadKey === threadKey; | ||
| 217 | |||
| 218 | return ( | ||
| 219 | <Pressable | ||
| 220 | key={threadKey} | ||
| 221 | onPress={() => { | ||
| 222 | props.onSelectThread(thread); | ||
| 223 | props.onClose(); | ||
| 224 | }} | ||
| 225 | style={{ | ||
| 226 | paddingHorizontal: 16, | ||
| 227 | paddingVertical: 15, | ||
| 228 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 229 | borderTopColor: borderSubtleColor, | ||
| 230 | backgroundColor: selected ? undefined : "transparent", | ||
| 231 | }} | ||
| 232 | className={selected ? "bg-subtle" : undefined} | ||
| 233 | > | ||
| 234 | <View className="flex-row items-start justify-between gap-3"> | ||
| 235 | <View className="flex-1 gap-1"> | ||
| 236 | <Text className="text-[16px] font-t3-bold" numberOfLines={1}> | ||
| 237 | {thread.title} | ||
| 238 | </Text> | ||
| 239 | <Text | ||
| 240 | className="text-[13px] font-medium text-foreground-muted" | ||
| 241 | numberOfLines={1} | ||
| 242 | > | ||
| 243 | {relativeTime(thread.updatedAt ?? thread.createdAt)} | ||
| 244 | </Text> | ||
| 245 | </View> | ||
| 246 | <StatusPill {...threadStatusTone(thread)} /> | ||
| 247 | </View> | ||
| 248 | </Pressable> | ||
| 249 | ); | ||
| 250 | }) | ||
| 251 | )} | ||
| 252 | </View> | ||
| 253 | </View> | ||
| 254 | ))} | ||
| 255 | </ScrollView> | ||
| 256 | </Animated.View> | 179 | </Animated.View> |
| 257 | </GestureDetector> | 180 | </GestureDetector> |
| 258 | </View> | 181 | </View> |
| 259 | </Modal> | 182 | </Modal> |
| 260 | ); | 183 | ); |
| 261 | } | 184 | } |
| 185 | |||
| 186 | function ThreadNavigationDrawerContent(props: { | ||
| 187 | readonly bottomInset: number; | ||
| 188 | readonly borderSubtleColor: ColorValue; | ||
| 189 | readonly selectedThreadKey: string | null; | ||
| 190 | readonly onClose: () => void; | ||
| 191 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | ||
| 192 | }) { | ||
| 193 | const projects = useProjects(); | ||
| 194 | const threads = useThreadShells(); | ||
| 195 | const repositoryGroups = useMemo( | ||
| 196 | () => groupProjectsByRepository({ projects, threads }), | ||
| 197 | [projects, threads], | ||
| 198 | ); | ||
| 199 | const groupedThreads = useMemo( | ||
| 200 | () => | ||
| 201 | repositoryGroups.map((group) => { | ||
| 202 | const threads: EnvironmentThreadShell[] = []; | ||
| 203 | for (const projectGroup of group.projects) { | ||
| 204 | threads.push(...projectGroup.threads); | ||
| 205 | } | ||
| 206 | return { | ||
| 207 | key: group.key, | ||
| 208 | title: group.projects[0]?.project.title ?? group.title, | ||
| 209 | threads: Arr.sort(threads, threadActivityOrder), | ||
| 210 | }; | ||
| 211 | }), | ||
| 212 | [repositoryGroups], | ||
| 213 | ); | ||
| 214 | |||
| 215 | return ( | ||
| 216 | <ScrollView | ||
| 217 | showsVerticalScrollIndicator={false} | ||
| 218 | contentInset={{ bottom: props.bottomInset + 12 }} | ||
| 219 | contentContainerStyle={{ | ||
| 220 | gap: 20, | ||
| 221 | paddingHorizontal: 14, | ||
| 222 | }} | ||
| 223 | > | ||
| 224 | {groupedThreads.map((group) => ( | ||
| 225 | <View key={group.key} className="gap-3"> | ||
| 226 | <Text | ||
| 227 | className="px-1 text-[15px] font-t3-bold text-foreground-muted" | ||
| 228 | style={{ letterSpacing: -0.2 }} | ||
| 229 | > | ||
| 230 | {group.title} | ||
| 231 | </Text> | ||
| 232 | |||
| 233 | <View className="overflow-hidden rounded-[22px] bg-card"> | ||
| 234 | {group.threads.length === 0 ? ( | ||
| 235 | <View className="px-4 py-4"> | ||
| 236 | <Text className="text-[14px] font-medium text-foreground-tertiary"> | ||
| 237 | No threads yet | ||
| 238 | </Text> | ||
| 239 | </View> | ||
| 240 | ) : ( | ||
| 241 | group.threads.map((thread, index) => { | ||
| 242 | const threadKey = scopedThreadKey(thread.environmentId, thread.id); | ||
| 243 | const selected = props.selectedThreadKey === threadKey; | ||
| 244 | |||
| 245 | return ( | ||
| 246 | <Pressable | ||
| 247 | key={threadKey} | ||
| 248 | onPress={() => { | ||
| 249 | props.onSelectThread(thread); | ||
| 250 | props.onClose(); | ||
| 251 | }} | ||
| 252 | style={{ | ||
| 253 | paddingHorizontal: 16, | ||
| 254 | paddingVertical: 15, | ||
| 255 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 256 | borderTopColor: props.borderSubtleColor, | ||
| 257 | backgroundColor: selected ? undefined : "transparent", | ||
| 258 | }} | ||
| 259 | className={selected ? "bg-subtle" : undefined} | ||
| 260 | > | ||
| 261 | <View className="flex-row items-start justify-between gap-3"> | ||
| 262 | <View className="flex-1 gap-1"> | ||
| 263 | <Text className="text-[16px] font-t3-bold" numberOfLines={1}> | ||
| 264 | {thread.title} | ||
| 265 | </Text> | ||
| 266 | <Text | ||
| 267 | className="text-[13px] font-medium text-foreground-muted" | ||
| 268 | numberOfLines={1} | ||
| 269 | > | ||
| 270 | {relativeTime(thread.updatedAt ?? thread.createdAt)} | ||
| 271 | </Text> | ||
| 272 | </View> | ||
| 273 | <StatusPill {...threadStatusTone(thread)} /> | ||
| 274 | </View> | ||
| 275 | </Pressable> | ||
| 276 | ); | ||
| 277 | }) | ||
| 278 | )} | ||
| 279 | </View> | ||
| 280 | </View> | ||
| 281 | ))} | ||
| 282 | </ScrollView> | ||
| 283 | ); | ||
| 284 | } |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+133-51| ... | @@ -1,14 +1,20 @@ | ... | @@ -1,14 +1,20 @@ |
| 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 2 | import { useCallback, useMemo, useState } from "react"; | 2 | import { useCallback, useMemo, useState } from "react"; |
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 5 | import { pipe } from "effect/Function"; | 4 | import { |
| 6 | import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; | 5 | EnvironmentId, |
| 6 | type ModelSelection, | ||
| 7 | type ProjectScript, | ||
| 8 | type ProviderInteractionMode, | ||
| 9 | type RuntimeMode, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 7 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 11 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 8 | import { Pressable, ScrollView, Text as RNText, View } from "react-native"; | 12 | import { Pressable, ScrollView, Text as RNText, View } from "react-native"; |
| 13 | import { useWorkspaceState } from "../../state/workspace"; | ||
| 9 | import { useThemeColor } from "../../lib/useThemeColor"; | 14 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 10 | import { useVcsStatus } from "../../state/use-vcs-status"; | 15 | import { useEnvironmentQuery } from "../../state/query"; |
| 11 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; | 16 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; |
| 17 | import { vcsEnvironment } from "../../state/vcs"; | ||
| 12 | 18 | ||
| 13 | import { EmptyState } from "../../components/EmptyState"; | 19 | import { EmptyState } from "../../components/EmptyState"; |
| 14 | import { LoadingScreen } from "../../components/LoadingScreen"; | 20 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| ... | @@ -16,13 +22,13 @@ import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/r | ... | @@ -16,13 +22,13 @@ import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/r |
| 16 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 22 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 17 | import { connectionTone } from "../connection/connectionTone"; | 23 | import { connectionTone } from "../connection/connectionTone"; |
| 18 | 24 | ||
| 19 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 20 | import { | 25 | import { |
| 26 | useRemoteConnections, | ||
| 21 | useRemoteConnectionStatus, | 27 | useRemoteConnectionStatus, |
| 22 | useRemoteEnvironmentState, | 28 | useRemoteEnvironmentRuntime, |
| 23 | } from "../../state/use-remote-environment-registry"; | 29 | } from "../../state/use-remote-environment-registry"; |
| 24 | import { useKnownTerminalSessions } from "../../state/use-terminal-session"; | 30 | import { useKnownTerminalSessions } from "../../state/use-terminal-session"; |
| 25 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | 31 | import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; |
| 26 | import { useThreadSelection } from "../../state/use-thread-selection"; | 32 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 27 | import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; | 33 | import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; |
| 28 | import { | 34 | import { |
| ... | @@ -38,12 +44,14 @@ import { terminalDebugLog } from "../terminal/terminalDebugLog"; | ... | @@ -38,12 +44,14 @@ import { terminalDebugLog } from "../terminal/terminalDebugLog"; |
| 38 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; | 44 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 39 | import { ThreadGitControls } from "./ThreadGitControls"; | 45 | import { ThreadGitControls } from "./ThreadGitControls"; |
| 40 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; | 46 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| 41 | import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands"; | 47 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 42 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; | 48 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; |
| 43 | import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; | 49 | import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; |
| 44 | import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; | 50 | import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; |
| 45 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | 51 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 46 | import { useThreadComposerState } from "../../state/use-thread-composer-state"; | 52 | import { useThreadComposerState } from "../../state/use-thread-composer-state"; |
| 53 | import { threadEnvironment } from "../../state/threads"; | ||
| 54 | import { projectThreadContentPresentation } from "./threadContentPresentation"; | ||
| 47 | 55 | ||
| 48 | function firstRouteParam(value: string | string[] | undefined): string | null { | 56 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 49 | if (Array.isArray(value)) { | 57 | if (Array.isArray(value)) { |
| ... | @@ -58,22 +66,31 @@ function OpeningThreadLoadingScreen() { | ... | @@ -58,22 +66,31 @@ function OpeningThreadLoadingScreen() { |
| 58 | } | 66 | } |
| 59 | 67 | ||
| 60 | export function ThreadRouteScreen() { | 68 | export function ThreadRouteScreen() { |
| 61 | const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } = | 69 | const { state: workspaceState } = useWorkspaceState(); |
| 62 | useRemoteEnvironmentState(); | 70 | const { connectionState } = useRemoteConnectionStatus(); |
| 63 | const { connectionState, connectionError: aggregateConnectionError } = | 71 | const { onReconnectEnvironment } = useRemoteConnections(); |
| 64 | useRemoteConnectionStatus(); | ||
| 65 | const { projects, threads } = useRemoteCatalog(); | ||
| 66 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = | 72 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = |
| 67 | useThreadSelection(); | 73 | useThreadSelection(); |
| 68 | const selectedThreadDetail = useSelectedThreadDetail(); | 74 | const selectedThreadDetailState = useSelectedThreadDetailState(); |
| 75 | const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); | ||
| 69 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | 76 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 70 | const composer = useThreadComposerState(); | 77 | const composer = useThreadComposerState(); |
| 71 | const gitState = useSelectedThreadGitState(); | 78 | const gitState = useSelectedThreadGitState(); |
| 72 | const gitActions = useSelectedThreadGitActions(); | 79 | const gitActions = useSelectedThreadGitActions(); |
| 73 | const requests = useSelectedThreadRequests(); | 80 | const requests = useSelectedThreadRequests(); |
| 74 | const commands = useSelectedThreadCommands({ | 81 | const updateThreadMetadata = useAtomCommand( |
| 75 | refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus, | 82 | threadEnvironment.updateMetadata, |
| 76 | }); | 83 | "thread metadata update", |
| 84 | ); | ||
| 85 | const setThreadRuntimeMode = useAtomCommand( | ||
| 86 | threadEnvironment.setRuntimeMode, | ||
| 87 | "thread runtime mode", | ||
| 88 | ); | ||
| 89 | const setThreadInteractionMode = useAtomCommand( | ||
| 90 | threadEnvironment.setInteractionMode, | ||
| 91 | "thread interaction mode", | ||
| 92 | ); | ||
| 93 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); | ||
| 77 | const router = useRouter(); | 94 | const router = useRouter(); |
| 78 | const params = useLocalSearchParams<{ | 95 | const params = useLocalSearchParams<{ |
| 79 | environmentId?: string | string[]; | 96 | environmentId?: string | string[]; |
| ... | @@ -83,12 +100,10 @@ export function ThreadRouteScreen() { | ... | @@ -83,12 +100,10 @@ export function ThreadRouteScreen() { |
| 83 | const environmentIdRaw = firstRouteParam(params.environmentId); | 100 | const environmentIdRaw = firstRouteParam(params.environmentId); |
| 84 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; | 101 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; |
| 85 | const threadId = firstRouteParam(params.threadId); | 102 | const threadId = firstRouteParam(params.threadId); |
| 86 | const routeEnvironmentRuntime = environmentId | 103 | const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); |
| 87 | ? (environmentStateById[environmentId] ?? null) | 104 | const routeConnectionState = |
| 88 | : null; | 105 | routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); |
| 89 | const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? connectionState; | 106 | const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; |
| 90 | const routeConnectionError = | ||
| 91 | pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; | ||
| 92 | 107 | ||
| 93 | /* ─── Native header theming ──────────────────────────────────────── */ | 108 | /* ─── Native header theming ──────────────────────────────────────── */ |
| 94 | const iconColor = String(useThemeColor("--color-icon")); | 109 | const iconColor = String(useThemeColor("--color-icon")); |
| ... | @@ -96,10 +111,14 @@ export function ThreadRouteScreen() { | ... | @@ -96,10 +111,14 @@ export function ThreadRouteScreen() { |
| 96 | const secondaryFg = String(useThemeColor("--color-foreground-secondary")); | 111 | const secondaryFg = String(useThemeColor("--color-foreground-secondary")); |
| 97 | 112 | ||
| 98 | /* ─── Git status for native header trigger ───────────────────────── */ | 113 | /* ─── Git status for native header trigger ───────────────────────── */ |
| 99 | const gitStatus = useVcsStatus({ | 114 | const gitStatus = useEnvironmentQuery( |
| 100 | environmentId: selectedThread?.environmentId ?? null, | 115 | selectedThread !== null && selectedThreadCwd !== null |
| 101 | cwd: selectedThreadCwd, | 116 | ? vcsEnvironment.status({ |
| 102 | }); | 117 | environmentId: selectedThread.environmentId, |
| 118 | input: { cwd: selectedThreadCwd }, | ||
| 119 | }) | ||
| 120 | : null, | ||
| 121 | ); | ||
| 103 | const knownTerminalSessions = useKnownTerminalSessions({ | 122 | const knownTerminalSessions = useKnownTerminalSessions({ |
| 104 | environmentId: selectedThread?.environmentId ?? null, | 123 | environmentId: selectedThread?.environmentId ?? null, |
| 105 | threadId: selectedThread?.id ?? null, | 124 | threadId: selectedThread?.id ?? null, |
| ... | @@ -113,6 +132,12 @@ export function ThreadRouteScreen() { | ... | @@ -113,6 +132,12 @@ export function ThreadRouteScreen() { |
| 113 | [knownTerminalSessions, selectedThreadProject?.workspaceRoot], | 132 | [knownTerminalSessions, selectedThreadProject?.workspaceRoot], |
| 114 | ); | 133 | ); |
| 115 | const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; | 134 | const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; |
| 135 | const handleReconnectEnvironment = useCallback(() => { | ||
| 136 | if (!environmentId) { | ||
| 137 | return; | ||
| 138 | } | ||
| 139 | onReconnectEnvironment(environmentId); | ||
| 140 | }, [environmentId, onReconnectEnvironment]); | ||
| 116 | 141 | ||
| 117 | /* ─── Git action progress (for overlay banner) ──────────────────── */ | 142 | /* ─── Git action progress (for overlay banner) ──────────────────── */ |
| 118 | const gitActionProgressTarget = useMemo( | 143 | const gitActionProgressTarget = useMemo( |
| ... | @@ -131,6 +156,69 @@ export function ThreadRouteScreen() { | ... | @@ -131,6 +156,69 @@ export function ThreadRouteScreen() { |
| 131 | const handleOpenConnectionEditor = useCallback(() => { | 156 | const handleOpenConnectionEditor = useCallback(() => { |
| 132 | void router.push("/connections"); | 157 | void router.push("/connections"); |
| 133 | }, [router]); | 158 | }, [router]); |
| 159 | const handleUpdateThreadModelSelection = useCallback( | ||
| 160 | (modelSelection: ModelSelection) => { | ||
| 161 | if (!selectedThread) { | ||
| 162 | return; | ||
| 163 | } | ||
| 164 | return updateThreadMetadata({ | ||
| 165 | environmentId: selectedThread.environmentId, | ||
| 166 | input: { | ||
| 167 | threadId: selectedThread.id, | ||
| 168 | modelSelection, | ||
| 169 | }, | ||
| 170 | }); | ||
| 171 | }, | ||
| 172 | [selectedThread, updateThreadMetadata], | ||
| 173 | ); | ||
| 174 | const handleUpdateThreadRuntimeMode = useCallback( | ||
| 175 | (runtimeMode: RuntimeMode) => { | ||
| 176 | if (!selectedThread) { | ||
| 177 | return; | ||
| 178 | } | ||
| 179 | return setThreadRuntimeMode({ | ||
| 180 | environmentId: selectedThread.environmentId, | ||
| 181 | input: { | ||
| 182 | threadId: selectedThread.id, | ||
| 183 | runtimeMode, | ||
| 184 | }, | ||
| 185 | }); | ||
| 186 | }, | ||
| 187 | [selectedThread, setThreadRuntimeMode], | ||
| 188 | ); | ||
| 189 | const handleUpdateThreadInteractionMode = useCallback( | ||
| 190 | (interactionMode: ProviderInteractionMode) => { | ||
| 191 | if (!selectedThread) { | ||
| 192 | return; | ||
| 193 | } | ||
| 194 | return setThreadInteractionMode({ | ||
| 195 | environmentId: selectedThread.environmentId, | ||
| 196 | input: { | ||
| 197 | threadId: selectedThread.id, | ||
| 198 | interactionMode, | ||
| 199 | }, | ||
| 200 | }); | ||
| 201 | }, | ||
| 202 | [selectedThread, setThreadInteractionMode], | ||
| 203 | ); | ||
| 204 | const handleStopThread = useCallback(() => { | ||
| 205 | if ( | ||
| 206 | !selectedThread || | ||
| 207 | (selectedThread.session?.status !== "running" && | ||
| 208 | selectedThread.session?.status !== "starting") | ||
| 209 | ) { | ||
| 210 | return; | ||
| 211 | } | ||
| 212 | return interruptThreadTurn({ | ||
| 213 | environmentId: selectedThread.environmentId, | ||
| 214 | input: { | ||
| 215 | threadId: selectedThread.id, | ||
| 216 | ...(selectedThread.session.activeTurnId | ||
| 217 | ? { turnId: selectedThread.session.activeTurnId } | ||
| 218 | : {}), | ||
| 219 | }, | ||
| 220 | }); | ||
| 221 | }, [interruptThreadTurn, selectedThread]); | ||
| 134 | 222 | ||
| 135 | const handleOpenTerminal = useCallback( | 223 | const handleOpenTerminal = useCallback( |
| 136 | (nextTerminalId?: string | null) => { | 224 | (nextTerminalId?: string | null) => { |
| ... | @@ -238,7 +326,7 @@ export function ThreadRouteScreen() { | ... | @@ -238,7 +326,7 @@ export function ThreadRouteScreen() { |
| 238 | 326 | ||
| 239 | if (!selectedThread) { | 327 | if (!selectedThread) { |
| 240 | const stillHydrating = | 328 | const stillHydrating = |
| 241 | isLoadingSavedConnection || | 329 | workspaceState.isLoadingConnections || |
| 242 | routeConnectionState === "connecting" || | 330 | routeConnectionState === "connecting" || |
| 243 | routeConnectionState === "reconnecting"; | 331 | routeConnectionState === "reconnecting"; |
| 244 | 332 | ||
| ... | @@ -265,19 +353,14 @@ export function ThreadRouteScreen() { | ... | @@ -265,19 +353,14 @@ export function ThreadRouteScreen() { |
| 265 | ); | 353 | ); |
| 266 | } | 354 | } |
| 267 | 355 | ||
| 268 | if (!selectedThreadDetail) { | ||
| 269 | return <OpeningThreadLoadingScreen />; | ||
| 270 | } | ||
| 271 | |||
| 272 | const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); | 356 | const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); |
| 273 | const serverConfig = | 357 | const contentPresentation = projectThreadContentPresentation({ |
| 274 | routeEnvironmentRuntime?.serverConfig ?? | 358 | hasDetail: selectedThreadDetail !== null, |
| 275 | pipe( | 359 | detailError: Option.getOrNull(selectedThreadDetailState.error), |
| 276 | Object.values(environmentStateById), | 360 | detailDeleted: selectedThreadDetailState.status === "deleted", |
| 277 | Arr.map((runtime) => runtime.serverConfig), | 361 | connectionState: routeConnectionState, |
| 278 | Arr.findFirst((value) => value !== null), | 362 | }); |
| 279 | Option.getOrNull, | 363 | const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; |
| 280 | ); | ||
| 281 | 364 | ||
| 282 | const headerSubtitle = [ | 365 | const headerSubtitle = [ |
| 283 | selectedThreadProject?.title ?? null, | 366 | selectedThreadProject?.title ?? null, |
| ... | @@ -313,7 +396,7 @@ export function ThreadRouteScreen() { | ... | @@ -313,7 +396,7 @@ export function ThreadRouteScreen() { |
| 313 | letterSpacing: -0.4, | 396 | letterSpacing: -0.4, |
| 314 | }} | 397 | }} |
| 315 | > | 398 | > |
| 316 | {selectedThreadDetail.title} | 399 | {selectedThread.title} |
| 317 | </RNText> | 400 | </RNText> |
| 318 | <RNText | 401 | <RNText |
| 319 | numberOfLines={1} | 402 | numberOfLines={1} |
| ... | @@ -333,7 +416,7 @@ export function ThreadRouteScreen() { | ... | @@ -333,7 +416,7 @@ export function ThreadRouteScreen() { |
| 333 | /> | 416 | /> |
| 334 | 417 | ||
| 335 | <ThreadGitControls | 418 | <ThreadGitControls |
| 336 | currentBranch={selectedThreadDetail.branch} | 419 | currentBranch={selectedThread.branch} |
| 337 | gitStatus={gitStatus.data} | 420 | gitStatus={gitStatus.data} |
| 338 | gitOperationLabel={gitState.gitOperationLabel} | 421 | gitOperationLabel={gitState.gitOperationLabel} |
| 339 | canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)} | 422 | canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)} |
| ... | @@ -350,11 +433,11 @@ export function ThreadRouteScreen() { | ... | @@ -350,11 +433,11 @@ export function ThreadRouteScreen() { |
| 350 | 433 | ||
| 351 | <View className="flex-1 bg-screen"> | 434 | <View className="flex-1 bg-screen"> |
| 352 | <ThreadDetailScreen | 435 | <ThreadDetailScreen |
| 353 | selectedThread={selectedThreadDetail} | 436 | selectedThread={selectedThread} |
| 437 | contentPresentation={contentPresentation} | ||
| 354 | screenTone={connectionTone(routeConnectionState)} | 438 | screenTone={connectionTone(routeConnectionState)} |
| 355 | connectionError={routeConnectionError} | 439 | connectionError={routeConnectionError} |
| 356 | httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null} | 440 | environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null} |
| 357 | bearerToken={selectedEnvironmentConnection?.bearerToken ?? null} | ||
| 358 | selectedThreadFeed={composer.selectedThreadFeed} | 441 | selectedThreadFeed={composer.selectedThreadFeed} |
| 359 | activeWorkStartedAt={composer.activeWorkStartedAt} | 442 | activeWorkStartedAt={composer.activeWorkStartedAt} |
| 360 | activePendingApproval={requests.activePendingApproval} | 443 | activePendingApproval={requests.activePendingApproval} |
| ... | @@ -377,11 +460,12 @@ export function ThreadRouteScreen() { | ... | @@ -377,11 +460,12 @@ export function ThreadRouteScreen() { |
| 377 | onNativePasteImages={composer.onNativePasteImages} | 460 | onNativePasteImages={composer.onNativePasteImages} |
| 378 | onRemoveDraftImage={composer.onRemoveDraftImage} | 461 | onRemoveDraftImage={composer.onRemoveDraftImage} |
| 379 | serverConfig={serverConfig} | 462 | serverConfig={serverConfig} |
| 380 | onStopThread={commands.onStopThread} | 463 | onStopThread={handleStopThread} |
| 381 | onSendMessage={composer.onSendMessage} | 464 | onSendMessage={composer.onSendMessage} |
| 382 | onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection} | 465 | onReconnectEnvironment={handleReconnectEnvironment} |
| 383 | onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode} | 466 | onUpdateThreadModelSelection={handleUpdateThreadModelSelection} |
| 384 | onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode} | 467 | onUpdateThreadRuntimeMode={handleUpdateThreadRuntimeMode} |
| 468 | onUpdateThreadInteractionMode={handleUpdateThreadInteractionMode} | ||
| 385 | onRespondToApproval={requests.onRespondToApproval} | 469 | onRespondToApproval={requests.onRespondToApproval} |
| 386 | onSelectUserInputOption={requests.onSelectUserInputOption} | 470 | onSelectUserInputOption={requests.onSelectUserInputOption} |
| 387 | onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} | 471 | onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} |
| ... | @@ -390,8 +474,6 @@ export function ThreadRouteScreen() { | ... | @@ -390,8 +474,6 @@ export function ThreadRouteScreen() { |
| 390 | 474 | ||
| 391 | <ThreadNavigationDrawer | 475 | <ThreadNavigationDrawer |
| 392 | visible={drawerVisible} | 476 | visible={drawerVisible} |
| 393 | projects={projects} | ||
| 394 | threads={threads} | ||
| 395 | selectedThreadKey={selectedThreadKey} | 477 | selectedThreadKey={selectedThreadKey} |
| 396 | onClose={() => setDrawerVisible(false)} | 478 | onClose={() => setDrawerVisible(false)} |
| 397 | onSelectThread={(thread) => { | 479 | onSelectThread={(thread) => { |
apps/mobile/src/features/threads/claudeEffortOptions.ts deleted-10| ... | @@ -1,10 +0,0 @@ | ||
| 1 | export const CLAUDE_AGENT_EFFORT_OPTIONS = [ | ||
| 2 | "low", | ||
| 3 | "medium", | ||
| 4 | "high", | ||
| 5 | "xhigh", | ||
| 6 | "max", | ||
| 7 | "ultrathink", | ||
| 8 | ] as const; | ||
| 9 | |||
| 10 | export type ClaudeAgentEffort = (typeof CLAUDE_AGENT_EFFORT_OPTIONS)[number]; | ||
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+10-5| ... | @@ -6,11 +6,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -6,11 +6,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | import { useThemeColor } from "../../../lib/useThemeColor"; | 6 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 7 | 7 | ||
| 8 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; | 8 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 9 | import { useVcsStatus } from "../../../state/use-vcs-status"; | 9 | import { useEnvironmentQuery } from "../../../state/query"; |
| 10 | import { useThreadSelection } from "../../../state/use-thread-selection"; | 10 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 11 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; | 11 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| 12 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; | 12 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| 13 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; | 13 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; |
| 14 | import { vcsEnvironment } from "../../../state/vcs"; | ||
| 14 | import { SheetActionButton } from "./gitSheetComponents"; | 15 | import { SheetActionButton } from "./gitSheetComponents"; |
| 15 | 16 | ||
| 16 | export function GitBranchesSheet() { | 17 | export function GitBranchesSheet() { |
| ... | @@ -27,10 +28,14 @@ export function GitBranchesSheet() { | ... | @@ -27,10 +28,14 @@ export function GitBranchesSheet() { |
| 27 | const foregroundColor = useThemeColor("--color-foreground"); | 28 | const foregroundColor = useThemeColor("--color-foreground"); |
| 28 | const subtleStrongColor = useThemeColor("--color-subtle-strong"); | 29 | const subtleStrongColor = useThemeColor("--color-subtle-strong"); |
| 29 | 30 | ||
| 30 | const gitStatus = useVcsStatus({ | 31 | const gitStatus = useEnvironmentQuery( |
| 31 | environmentId: selectedThread?.environmentId ?? null, | 32 | selectedThread !== null && selectedThreadCwd !== null |
| 32 | cwd: selectedThreadCwd, | 33 | ? vcsEnvironment.status({ |
| 33 | }); | 34 | environmentId: selectedThread.environmentId, |
| 35 | input: { cwd: selectedThreadCwd }, | ||
| 36 | }) | ||
| 37 | : null, | ||
| 38 | ); | ||
| 34 | 39 | ||
| 35 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; | 40 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; |
| 36 | const currentWorktreePath = selectedThreadWorktreePath; | 41 | const currentWorktreePath = selectedThreadWorktreePath; |
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+10-5| ... | @@ -5,11 +5,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -5,11 +5,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 5 | import { useThemeColor } from "../../../lib/useThemeColor"; | 5 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 6 | 6 | ||
| 7 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; | 7 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 8 | import { useVcsStatus } from "../../../state/use-vcs-status"; | 8 | import { useEnvironmentQuery } from "../../../state/query"; |
| 9 | import { useThreadSelection } from "../../../state/use-thread-selection"; | 9 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 10 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; | 10 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| 11 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; | 11 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| 12 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; | 12 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; |
| 13 | import { vcsEnvironment } from "../../../state/vcs"; | ||
| 13 | import { SheetActionButton } from "./gitSheetComponents"; | 14 | import { SheetActionButton } from "./gitSheetComponents"; |
| 14 | 15 | ||
| 15 | export function GitCommitSheet() { | 16 | export function GitCommitSheet() { |
| ... | @@ -27,10 +28,14 @@ export function GitCommitSheet() { | ... | @@ -27,10 +28,14 @@ export function GitCommitSheet() { |
| 27 | const inputBg = useThemeColor("--color-input"); | 28 | const inputBg = useThemeColor("--color-input"); |
| 28 | const foregroundColor = useThemeColor("--color-foreground"); | 29 | const foregroundColor = useThemeColor("--color-foreground"); |
| 29 | 30 | ||
| 30 | const gitStatus = useVcsStatus({ | 31 | const gitStatus = useEnvironmentQuery( |
| 31 | environmentId: selectedThread?.environmentId ?? null, | 32 | selectedThread !== null && selectedThreadCwd !== null |
| 32 | cwd: selectedThreadCwd, | 33 | ? vcsEnvironment.status({ |
| 33 | }); | 34 | environmentId: selectedThread.environmentId, |
| 35 | input: { cwd: selectedThreadCwd }, | ||
| 36 | }) | ||
| 37 | : null, | ||
| 38 | ); | ||
| 34 | 39 | ||
| 35 | const busy = gitState.gitOperationLabel !== null; | 40 | const busy = gitState.gitOperationLabel !== null; |
| 36 | const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; | 41 | const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; |
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime"; | 1 | import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/state/vcs"; |
| 2 | import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; | 2 | import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; |
| 3 | import * as Arr from "effect/Array"; | 3 | import * as Arr from "effect/Array"; |
| 4 | import * as Result from "effect/Result"; | 4 | import * as Result from "effect/Result"; |
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+11-6| ... | @@ -3,7 +3,7 @@ import { | ... | @@ -3,7 +3,7 @@ import { |
| 3 | buildMenuItems, | 3 | buildMenuItems, |
| 4 | getGitActionDisabledReason, | 4 | getGitActionDisabledReason, |
| 5 | requiresDefaultBranchConfirmation, | 5 | requiresDefaultBranchConfirmation, |
| 6 | } from "@t3tools/client-runtime"; | 6 | } from "@t3tools/client-runtime/state/vcs"; |
| 7 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 7 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 8 | import { useLocalSearchParams, useRouter } from "expo-router"; | 8 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 9 | import { SymbolView } from "expo-symbols"; | 9 | import { SymbolView } from "expo-symbols"; |
| ... | @@ -14,11 +14,12 @@ import { useThemeColor } from "../../../lib/useThemeColor"; | ... | @@ -14,11 +14,12 @@ import { useThemeColor } from "../../../lib/useThemeColor"; |
| 14 | 14 | ||
| 15 | import { AppText as Text } from "../../../components/AppText"; | 15 | import { AppText as Text } from "../../../components/AppText"; |
| 16 | import { buildThreadReviewRoutePath } from "../../../lib/routes"; | 16 | import { buildThreadReviewRoutePath } from "../../../lib/routes"; |
| 17 | import { useVcsStatus } from "../../../state/use-vcs-status"; | 17 | import { useEnvironmentQuery } from "../../../state/query"; |
| 18 | import { useThreadSelection } from "../../../state/use-thread-selection"; | 18 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 19 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; | 19 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| 20 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; | 20 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| 21 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; | 21 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; |
| 22 | import { vcsEnvironment } from "../../../state/vcs"; | ||
| 22 | import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; | 23 | import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; |
| 23 | 24 | ||
| 24 | export function GitOverviewSheet() { | 25 | export function GitOverviewSheet() { |
| ... | @@ -36,10 +37,14 @@ export function GitOverviewSheet() { | ... | @@ -36,10 +37,14 @@ export function GitOverviewSheet() { |
| 36 | const iconColor = useThemeColor("--color-icon"); | 37 | const iconColor = useThemeColor("--color-icon"); |
| 37 | const borderColor = useThemeColor("--color-border"); | 38 | const borderColor = useThemeColor("--color-border"); |
| 38 | 39 | ||
| 39 | const gitStatus = useVcsStatus({ | 40 | const gitStatus = useEnvironmentQuery( |
| 40 | environmentId: selectedThread?.environmentId ?? null, | 41 | selectedThread !== null && selectedThreadCwd !== null |
| 41 | cwd: selectedThreadCwd, | 42 | ? vcsEnvironment.status({ |
| 42 | }); | 43 | environmentId: selectedThread.environmentId, |
| 44 | input: { cwd: selectedThreadCwd }, | ||
| 45 | }) | ||
| 46 | : null, | ||
| 47 | ); | ||
| 43 | 48 | ||
| 44 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; | 49 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; |
| 45 | const currentWorktreePath = selectedThreadWorktreePath; | 50 | const currentWorktreePath = selectedThreadWorktreePath; |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+97-116| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import React, { useCallback, useMemo, useRef, useState } from "react"; |
| 2 | 2 | ||
| 3 | import type { | 3 | import type { |
| 4 | EnvironmentId, | 4 | EnvironmentId, |
| 5 | ModelSelection, | 5 | ModelSelection, |
| 6 | ProviderInteractionMode, | 6 | ProviderInteractionMode, |
| 7 | ProviderOptionSelection, | ||
| 7 | RuntimeMode, | 8 | RuntimeMode, |
| 8 | ServerProviderSkill, | 9 | ServerProviderSkill, |
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| ... | @@ -11,6 +12,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tool | ... | @@ -11,6 +12,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tool |
| 11 | import * as Arr from "effect/Array"; | 12 | import * as Arr from "effect/Array"; |
| 12 | import { pipe } from "effect/Function"; | 13 | import { pipe } from "effect/Function"; |
| 13 | 14 | ||
| 15 | import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; | ||
| 14 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 16 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 15 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | 17 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 16 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 18 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| ... | @@ -23,21 +25,17 @@ import { | ... | @@ -23,21 +25,17 @@ import { |
| 23 | setComposerDraftText, | 25 | setComposerDraftText, |
| 24 | useComposerDraft, | 26 | useComposerDraft, |
| 25 | } from "../../state/use-composer-drafts"; | 27 | } from "../../state/use-composer-drafts"; |
| 26 | import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs"; | 28 | import { useBranches } from "../../state/queries"; |
| 27 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 28 | import { | 29 | import { |
| 29 | setPendingConnectionError, | 30 | setPendingConnectionError, |
| 30 | useRemoteEnvironmentState, | 31 | useSavedRemoteConnections, |
| 31 | } from "../../state/use-remote-environment-registry"; | 32 | } from "../../state/use-remote-environment-registry"; |
| 32 | import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; | 33 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 33 | import type { ClaudeAgentEffort } from "./claudeEffortOptions"; | 34 | import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 34 | 35 | ||
| 35 | type WorkspaceMode = "local" | "worktree"; | 36 | type WorkspaceMode = "local" | "worktree"; |
| 36 | 37 | ||
| 37 | function normalizeSelectedWorktreePath( | 38 | function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { |
| 38 | project: EnvironmentScopedProjectShell, | ||
| 39 | branch: VcsRef, | ||
| 40 | ): string | null { | ||
| 41 | if (!branch.worktreePath) { | 39 | if (!branch.worktreePath) { |
| 42 | return null; | 40 | return null; |
| 43 | } | 41 | } |
| ... | @@ -47,7 +45,7 @@ function normalizeSelectedWorktreePath( | ... | @@ -47,7 +45,7 @@ function normalizeSelectedWorktreePath( |
| 47 | 45 | ||
| 48 | export function branchBadgeLabel(input: { | 46 | export function branchBadgeLabel(input: { |
| 49 | readonly branch: VcsRef; | 47 | readonly branch: VcsRef; |
| 50 | readonly project: EnvironmentScopedProjectShell | null; | 48 | readonly project: EnvironmentProject | null; |
| 51 | }): string | null { | 49 | }): string | null { |
| 52 | if (input.branch.current) { | 50 | if (input.branch.current) { |
| 53 | return "current"; | 51 | return "current"; |
| ... | @@ -67,7 +65,7 @@ export function branchBadgeLabel(input: { | ... | @@ -67,7 +65,7 @@ export function branchBadgeLabel(input: { |
| 67 | type NewTaskFlowContextValue = { | 65 | type NewTaskFlowContextValue = { |
| 68 | readonly logicalProjects: ReadonlyArray<{ | 66 | readonly logicalProjects: ReadonlyArray<{ |
| 69 | readonly key: string; | 67 | readonly key: string; |
| 70 | readonly project: EnvironmentScopedProjectShell; | 68 | readonly project: EnvironmentProject; |
| 71 | }>; | 69 | }>; |
| 72 | readonly selectedEnvironmentId: EnvironmentId | null; | 70 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 73 | readonly selectedProjectKey: string | null; | 71 | readonly selectedProjectKey: string | null; |
| ... | @@ -83,15 +81,12 @@ type NewTaskFlowContextValue = { | ... | @@ -83,15 +81,12 @@ type NewTaskFlowContextValue = { |
| 83 | readonly availableBranches: ReadonlyArray<VcsRef>; | 81 | readonly availableBranches: ReadonlyArray<VcsRef>; |
| 84 | readonly runtimeMode: RuntimeMode; | 82 | readonly runtimeMode: RuntimeMode; |
| 85 | readonly interactionMode: ProviderInteractionMode; | 83 | readonly interactionMode: ProviderInteractionMode; |
| 86 | readonly effort: ClaudeAgentEffort; | ||
| 87 | readonly fastMode: boolean; | ||
| 88 | readonly contextWindow: string; | ||
| 89 | readonly expandedProvider: string | null; | 84 | readonly expandedProvider: string | null; |
| 90 | readonly environments: ReadonlyArray<{ | 85 | readonly environments: ReadonlyArray<{ |
| 91 | readonly environmentId: EnvironmentId; | 86 | readonly environmentId: EnvironmentId; |
| 92 | readonly environmentLabel: string; | 87 | readonly environmentLabel: string; |
| 93 | }>; | 88 | }>; |
| 94 | readonly selectedProject: EnvironmentScopedProjectShell | null; | 89 | readonly selectedProject: EnvironmentProject | null; |
| 95 | readonly modelOptions: ReadonlyArray<ModelOption>; | 90 | readonly modelOptions: ReadonlyArray<ModelOption>; |
| 96 | readonly selectedModel: ModelSelection | null; | 91 | readonly selectedModel: ModelSelection | null; |
| 97 | readonly selectedModelOption: ModelOption | null; | 92 | readonly selectedModelOption: ModelOption | null; |
| ... | @@ -99,7 +94,7 @@ type NewTaskFlowContextValue = { | ... | @@ -99,7 +94,7 @@ type NewTaskFlowContextValue = { |
| 99 | readonly providerGroups: ReadonlyArray<ProviderGroup>; | 94 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 100 | readonly filteredBranches: ReadonlyArray<VcsRef>; | 95 | readonly filteredBranches: ReadonlyArray<VcsRef>; |
| 101 | readonly reset: () => void; | 96 | readonly reset: () => void; |
| 102 | readonly setProject: (project: EnvironmentScopedProjectShell) => void; | 97 | readonly setProject: (project: EnvironmentProject) => void; |
| 103 | readonly selectEnvironment: (environmentId: EnvironmentId) => void; | 98 | readonly selectEnvironment: (environmentId: EnvironmentId) => void; |
| 104 | readonly setSelectedModelKey: (key: string | null) => void; | 99 | readonly setSelectedModelKey: (key: string | null) => void; |
| 105 | readonly setWorkspaceMode: (mode: WorkspaceMode) => void; | 100 | readonly setWorkspaceMode: (mode: WorkspaceMode) => void; |
| ... | @@ -114,17 +109,18 @@ type NewTaskFlowContextValue = { | ... | @@ -114,17 +109,18 @@ type NewTaskFlowContextValue = { |
| 114 | readonly loadBranches: () => Promise<void>; | 109 | readonly loadBranches: () => Promise<void>; |
| 115 | readonly setRuntimeMode: (value: RuntimeMode) => void; | 110 | readonly setRuntimeMode: (value: RuntimeMode) => void; |
| 116 | readonly setInteractionMode: (value: ProviderInteractionMode) => void; | 111 | readonly setInteractionMode: (value: ProviderInteractionMode) => void; |
| 117 | readonly setEffort: (value: ClaudeAgentEffort) => void; | 112 | readonly setSelectedModelOptions: ( |
| 118 | readonly setFastMode: (value: boolean) => void; | 113 | value: ReadonlyArray<ProviderOptionSelection> | undefined, |
| 119 | readonly setContextWindow: (value: string) => void; | 114 | ) => void; |
| 120 | readonly setExpandedProvider: (value: string | null) => void; | 115 | readonly setExpandedProvider: (value: string | null) => void; |
| 121 | }; | 116 | }; |
| 122 | 117 | ||
| 123 | const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null); | 118 | const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null); |
| 124 | 119 | ||
| 125 | export function NewTaskFlowProvider(props: React.PropsWithChildren) { | 120 | export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 126 | const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog(); | 121 | const projects = useProjects(); |
| 127 | const { savedConnectionsById } = useRemoteEnvironmentState(); | 122 | const threads = useThreadShells(); |
| 123 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 128 | 124 | ||
| 129 | const repositoryGroups = useMemo( | 125 | const repositoryGroups = useMemo( |
| 130 | () => groupProjectsByRepository({ projects, threads }), | 126 | () => groupProjectsByRepository({ projects, threads }), |
| ... | @@ -146,16 +142,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -146,16 +142,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 146 | entry, | 142 | entry, |
| 147 | ): entry is { | 143 | ): entry is { |
| 148 | readonly key: string; | 144 | readonly key: string; |
| 149 | readonly project: EnvironmentScopedProjectShell; | 145 | readonly project: EnvironmentProject; |
| 150 | } => entry !== null, | 146 | } => entry !== null, |
| 151 | ), | 147 | ), |
| 152 | ), | 148 | ), |
| 153 | [repositoryGroups], | 149 | [repositoryGroups], |
| 154 | ); | 150 | ); |
| 155 | 151 | ||
| 156 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>( | 152 | const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState<EnvironmentId | null>( |
| 157 | projects[0]?.environmentId ?? null, | 153 | null, |
| 158 | ); | 154 | ); |
| 155 | const selectedEnvironmentId = | ||
| 156 | selectedEnvironmentIdOverride !== null && | ||
| 157 | projects.some((project) => project.environmentId === selectedEnvironmentIdOverride) | ||
| 158 | ? selectedEnvironmentIdOverride | ||
| 159 | : (projects[0]?.environmentId ?? null); | ||
| 159 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | 160 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); |
| 160 | const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null); | 161 | const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null); |
| 161 | const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("local"); | 162 | const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("local"); |
| ... | @@ -168,17 +169,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -168,17 +169,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 168 | const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>( | 169 | const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>( |
| 169 | DEFAULT_PROVIDER_INTERACTION_MODE, | 170 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 170 | ); | 171 | ); |
| 171 | const [effort, setEffort] = useState<ClaudeAgentEffort>("high"); | 172 | const [modelSelectionOverrides, setModelSelectionOverrides] = useState< |
| 172 | const [fastMode, setFastMode] = useState(false); | 173 | Record<string, ModelSelection> |
| 173 | const [contextWindow, setContextWindow] = useState("1M"); | 174 | >({}); |
| 174 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); | 175 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); |
| 175 | 176 | ||
| 176 | const reset = useCallback(() => { | 177 | const reset = useCallback(() => { |
| 177 | console.log("[new task flow] reset", { | 178 | setSelectedEnvironmentId(null); |
| 178 | defaultEnvironmentId: projects[0]?.environmentId ?? null, | ||
| 179 | projectCount: projects.length, | ||
| 180 | }); | ||
| 181 | setSelectedEnvironmentId(projects[0]?.environmentId ?? null); | ||
| 182 | setSelectedProjectKey(null); | 179 | setSelectedProjectKey(null); |
| 183 | setSelectedModelKey(null); | 180 | setSelectedModelKey(null); |
| 184 | setWorkspaceMode("local"); | 181 | setWorkspaceMode("local"); |
| ... | @@ -188,22 +185,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -188,22 +185,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 188 | setBranchQuery(""); | 185 | setBranchQuery(""); |
| 189 | setRuntimeMode(DEFAULT_RUNTIME_MODE); | 186 | setRuntimeMode(DEFAULT_RUNTIME_MODE); |
| 190 | setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); | 187 | setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); |
| 191 | setEffort("high"); | 188 | setModelSelectionOverrides({}); |
| 192 | setFastMode(false); | ||
| 193 | setContextWindow("1M"); | ||
| 194 | setExpandedProvider(null); | 189 | setExpandedProvider(null); |
| 195 | }, [projects]); | 190 | }, []); |
| 196 | |||
| 197 | useEffect(() => { | ||
| 198 | if (selectedEnvironmentId !== null || projects.length === 0) { | ||
| 199 | return; | ||
| 200 | } | ||
| 201 | |||
| 202 | console.log("[new task flow] initializing environment", { | ||
| 203 | environmentId: projects[0]!.environmentId, | ||
| 204 | }); | ||
| 205 | setSelectedEnvironmentId(projects[0]!.environmentId); | ||
| 206 | }, [projects, selectedEnvironmentId]); | ||
| 207 | 191 | ||
| 208 | const environments = useMemo( | 192 | const environments = useMemo( |
| 209 | () => | 193 | () => |
| ... | @@ -254,6 +238,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -254,6 +238,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 254 | ) ?? | 238 | ) ?? |
| 255 | projectsForEnvironment[0] ?? | 239 | projectsForEnvironment[0] ?? |
| 256 | null; | 240 | null; |
| 241 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( | ||
| 242 | selectedProject?.environmentId ?? null, | ||
| 243 | ); | ||
| 257 | const selectedProjectDraftKey = selectedProject | 244 | const selectedProjectDraftKey = selectedProject |
| 258 | ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` | 245 | ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` |
| 259 | : null; | 246 | : null; |
| ... | @@ -264,19 +251,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -264,19 +251,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 264 | const modelOptions = useMemo( | 251 | const modelOptions = useMemo( |
| 265 | () => | 252 | () => |
| 266 | buildModelOptions( | 253 | buildModelOptions( |
| 267 | selectedProject | 254 | selectedEnvironmentServerConfig, |
| 268 | ? (serverConfigByEnvironmentId[selectedProject.environmentId] ?? null) | ||
| 269 | : null, | ||
| 270 | selectedProject?.defaultModelSelection ?? null, | 255 | selectedProject?.defaultModelSelection ?? null, |
| 271 | ), | 256 | ), |
| 272 | [selectedProject, serverConfigByEnvironmentId], | 257 | [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection], |
| 273 | ); | 258 | ); |
| 274 | 259 | ||
| 275 | const selectedModel = | 260 | const defaultModelKey = selectedProject?.defaultModelSelection |
| 261 | ? `${selectedProject.defaultModelSelection.instanceId}:${selectedProject.defaultModelSelection.model}` | ||
| 262 | : null; | ||
| 263 | const baseSelectedModel = | ||
| 276 | modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? | 264 | modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? |
| 265 | (defaultModelKey | ||
| 266 | ? modelOptions.find((option) => option.key === defaultModelKey)?.selection | ||
| 267 | : null) ?? | ||
| 277 | selectedProject?.defaultModelSelection ?? | 268 | selectedProject?.defaultModelSelection ?? |
| 278 | modelOptions[0]?.selection ?? | 269 | modelOptions[0]?.selection ?? |
| 279 | null; | 270 | null; |
| 271 | const selectedModelIdentity = baseSelectedModel | ||
| 272 | ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}` | ||
| 273 | : null; | ||
| 274 | const selectedModel = | ||
| 275 | (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ?? | ||
| 276 | baseSelectedModel; | ||
| 280 | 277 | ||
| 281 | const selectedModelOption = | 278 | const selectedModelOption = |
| 282 | modelOptions.find( | 279 | modelOptions.find( |
| ... | @@ -286,11 +283,27 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -286,11 +283,27 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 286 | option.selection.model === selectedModel.model, | 283 | option.selection.model === selectedModel.model, |
| 287 | ) ?? null; | 284 | ) ?? null; |
| 288 | const selectedProviderSkills = | 285 | const selectedProviderSkills = |
| 289 | (selectedProject | 286 | selectedEnvironmentServerConfig?.providers.find( |
| 290 | ? serverConfigByEnvironmentId[selectedProject.environmentId] | 287 | (provider) => provider.instanceId === selectedModel?.instanceId, |
| 291 | : null | 288 | )?.skills ?? []; |
| 292 | )?.providers.find((provider) => provider.instanceId === selectedModel?.instanceId)?.skills ?? | 289 | const setSelectedModelOptions = useCallback( |
| 293 | []; | 290 | (options: ReadonlyArray<ProviderOptionSelection> | undefined) => { |
| 291 | if (!selectedModel || !selectedModelIdentity) { | ||
| 292 | return; | ||
| 293 | } | ||
| 294 | const nextSelection: ModelSelection = options | ||
| 295 | ? { ...selectedModel, options } | ||
| 296 | : { | ||
| 297 | instanceId: selectedModel.instanceId, | ||
| 298 | model: selectedModel.model, | ||
| 299 | }; | ||
| 300 | setModelSelectionOverrides((current) => ({ | ||
| 301 | ...current, | ||
| 302 | [selectedModelIdentity]: nextSelection, | ||
| 303 | })); | ||
| 304 | }, | ||
| 305 | [selectedModel, selectedModelIdentity], | ||
| 306 | ); | ||
| 294 | 307 | ||
| 295 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); | 308 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); |
| 296 | const setPrompt = useCallback( | 309 | const setPrompt = useCallback( |
| ... | @@ -343,7 +356,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -343,7 +356,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 343 | }), | 356 | }), |
| 344 | [selectedProject?.environmentId, selectedProject?.workspaceRoot], | 357 | [selectedProject?.environmentId, selectedProject?.workspaceRoot], |
| 345 | ); | 358 | ); |
| 346 | const branchState = useVcsRefs(branchTarget); | 359 | const branchState = useBranches(branchTarget); |
| 347 | const branchesLoading = branchState.isPending; | 360 | const branchesLoading = branchState.isPending; |
| 348 | const availableBranches = useMemo( | 361 | const availableBranches = useMemo( |
| 349 | () => | 362 | () => |
| ... | @@ -366,13 +379,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -366,13 +379,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 366 | ); | 379 | ); |
| 367 | }, [availableBranches, branchQuery]); | 380 | }, [availableBranches, branchQuery]); |
| 368 | 381 | ||
| 369 | const setProject = useCallback((project: EnvironmentScopedProjectShell) => { | 382 | const setProject = useCallback((project: EnvironmentProject) => { |
| 370 | const nextProjectKey = scopedProjectKey(project.environmentId, project.id); | 383 | const nextProjectKey = scopedProjectKey(project.environmentId, project.id); |
| 371 | branchLoadVersionRef.current += 1; | 384 | branchLoadVersionRef.current += 1; |
| 372 | setSelectedEnvironmentId(project.environmentId); | 385 | setSelectedEnvironmentId(project.environmentId); |
| 373 | setSelectedProjectKey(nextProjectKey); | 386 | setSelectedProjectKey(nextProjectKey); |
| 374 | setSelectedBranchName(null); | 387 | setSelectedBranchName(null); |
| 375 | setSelectedWorktreePath(null); | 388 | setSelectedWorktreePath(null); |
| 389 | setModelSelectionOverrides({}); | ||
| 376 | }, []); | 390 | }, []); |
| 377 | 391 | ||
| 378 | const selectEnvironment = useCallback((environmentId: EnvironmentId) => { | 392 | const selectEnvironment = useCallback((environmentId: EnvironmentId) => { |
| ... | @@ -381,6 +395,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -381,6 +395,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 381 | setSelectedProjectKey(null); | 395 | setSelectedProjectKey(null); |
| 382 | setSelectedBranchName(null); | 396 | setSelectedBranchName(null); |
| 383 | setSelectedWorktreePath(null); | 397 | setSelectedWorktreePath(null); |
| 398 | setModelSelectionOverrides({}); | ||
| 384 | }, []); | 399 | }, []); |
| 385 | 400 | ||
| 386 | const selectBranch = useCallback( | 401 | const selectBranch = useCallback( |
| ... | @@ -400,37 +415,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -400,37 +415,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 400 | 415 | ||
| 401 | const loadVersion = ++branchLoadVersionRef.current; | 416 | const loadVersion = ++branchLoadVersionRef.current; |
| 402 | const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); | 417 | const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); |
| 403 | try { | 418 | branchState.refresh(); |
| 404 | const result = await vcsRefManager.load({ | 419 | if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { |
| 405 | environmentId: selectedProject.environmentId, | 420 | return; |
| 406 | cwd: selectedProject.workspaceRoot, | 421 | } |
| 407 | query: null, | 422 | setPendingConnectionError(null); |
| 408 | }); | 423 | if (workspaceMode === "worktree" && !selectedBranchName) { |
| 409 | if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { | 424 | const preferredBranch = |
| 410 | return; | 425 | availableBranches.find((branch) => branch.current)?.name ?? |
| 411 | } | 426 | availableBranches.find((branch) => branch.isDefault)?.name ?? |
| 412 | setPendingConnectionError(null); | 427 | null; |
| 413 | const branches = pipe( | 428 | if (preferredBranch) { |
| 414 | result?.refs ?? [], | 429 | setSelectedBranchName(preferredBranch); |
| 415 | Arr.filter((branch) => !branch.isRemote), | ||
| 416 | ); | ||
| 417 | |||
| 418 | if (workspaceMode === "worktree" && !selectedBranchName) { | ||
| 419 | const preferredBranch = | ||
| 420 | branches.find((branch) => branch.current)?.name ?? | ||
| 421 | branches.find((branch) => branch.isDefault)?.name ?? | ||
| 422 | null; | ||
| 423 | if (preferredBranch) { | ||
| 424 | setSelectedBranchName(preferredBranch); | ||
| 425 | } | ||
| 426 | } | ||
| 427 | } catch { | ||
| 428 | if (loadVersion !== branchLoadVersionRef.current) { | ||
| 429 | return; | ||
| 430 | } | 430 | } |
| 431 | setPendingConnectionError("Failed to load branches."); | ||
| 432 | } | 431 | } |
| 433 | }, [selectedBranchName, selectedProject, selectedProjectKey, workspaceMode]); | 432 | }, [ |
| 433 | availableBranches, | ||
| 434 | branchState, | ||
| 435 | selectedBranchName, | ||
| 436 | selectedProject, | ||
| 437 | selectedProjectKey, | ||
| 438 | workspaceMode, | ||
| 439 | ]); | ||
| 434 | 440 | ||
| 435 | const value = useMemo<NewTaskFlowContextValue>( | 441 | const value = useMemo<NewTaskFlowContextValue>( |
| 436 | () => ({ | 442 | () => ({ |
| ... | @@ -449,9 +455,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -449,9 +455,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 449 | availableBranches, | 455 | availableBranches, |
| 450 | runtimeMode, | 456 | runtimeMode, |
| 451 | interactionMode, | 457 | interactionMode, |
| 452 | effort, | ||
| 453 | fastMode, | ||
| 454 | contextWindow, | ||
| 455 | expandedProvider, | 458 | expandedProvider, |
| 456 | environments, | 459 | environments, |
| 457 | selectedProject, | 460 | selectedProject, |
| ... | @@ -477,9 +480,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -477,9 +480,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 477 | loadBranches, | 480 | loadBranches, |
| 478 | setRuntimeMode, | 481 | setRuntimeMode, |
| 479 | setInteractionMode, | 482 | setInteractionMode, |
| 480 | setEffort, | 483 | setSelectedModelOptions, |
| 481 | setFastMode, | ||
| 482 | setContextWindow, | ||
| 483 | setExpandedProvider, | 484 | setExpandedProvider, |
| 484 | }), | 485 | }), |
| 485 | [ | 486 | [ |
| ... | @@ -487,11 +488,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -487,11 +488,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 487 | availableBranches, | 488 | availableBranches, |
| 488 | branchQuery, | 489 | branchQuery, |
| 489 | branchesLoading, | 490 | branchesLoading, |
| 490 | contextWindow, | ||
| 491 | effort, | ||
| 492 | environments, | 491 | environments, |
| 493 | expandedProvider, | 492 | expandedProvider, |
| 494 | fastMode, | ||
| 495 | filteredBranches, | 493 | filteredBranches, |
| 496 | interactionMode, | 494 | interactionMode, |
| 497 | loadBranches, | 495 | loadBranches, |
| ... | @@ -508,6 +506,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -508,6 +506,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 508 | selectedModelKey, | 506 | selectedModelKey, |
| 509 | selectedModelOption, | 507 | selectedModelOption, |
| 510 | selectedProviderSkills, | 508 | selectedProviderSkills, |
| 509 | setSelectedModelOptions, | ||
| 511 | selectedProject, | 510 | selectedProject, |
| 512 | selectedProjectKey, | 511 | selectedProjectKey, |
| 513 | selectedWorktreePath, | 512 | selectedWorktreePath, |
| ... | @@ -522,24 +521,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -522,24 +521,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 522 | ], | 521 | ], |
| 523 | ); | 522 | ); |
| 524 | 523 | ||
| 525 | useEffect(() => { | ||
| 526 | console.log("[new task flow] state", { | ||
| 527 | availableBranchCount: availableBranches.length, | ||
| 528 | environmentCount: environments.length, | ||
| 529 | logicalProjectCount: logicalProjects.length, | ||
| 530 | selectedEnvironmentId, | ||
| 531 | selectedProjectKey, | ||
| 532 | selectedProjectTitle: selectedProject?.title ?? null, | ||
| 533 | }); | ||
| 534 | }, [ | ||
| 535 | availableBranches.length, | ||
| 536 | environments.length, | ||
| 537 | logicalProjects.length, | ||
| 538 | selectedEnvironmentId, | ||
| 539 | selectedProject?.title, | ||
| 540 | selectedProjectKey, | ||
| 541 | ]); | ||
| 542 | |||
| 543 | return <NewTaskFlowContext.Provider value={value}>{props.children}</NewTaskFlowContext.Provider>; | 524 | return <NewTaskFlowContext.Provider value={value}>{props.children}</NewTaskFlowContext.Provider>; |
| 544 | } | 525 | } |
| 545 | 526 |
apps/mobile/src/features/threads/threadContentPresentation.test.ts created+57| ... | @@ -0,0 +1,57 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { projectThreadContentPresentation } from "./threadContentPresentation"; | ||
| 4 | |||
| 5 | describe("thread content presentation", () => { | ||
| 6 | it("renders cached detail while its environment reconnects", () => { | ||
| 7 | expect( | ||
| 8 | projectThreadContentPresentation({ | ||
| 9 | hasDetail: true, | ||
| 10 | detailError: null, | ||
| 11 | detailDeleted: false, | ||
| 12 | connectionState: "reconnecting", | ||
| 13 | }), | ||
| 14 | ).toEqual({ kind: "ready" }); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("loads missing detail inside the thread screen when connected", () => { | ||
| 18 | expect( | ||
| 19 | projectThreadContentPresentation({ | ||
| 20 | hasDetail: false, | ||
| 21 | detailError: null, | ||
| 22 | detailDeleted: false, | ||
| 23 | connectionState: "connected", | ||
| 24 | }), | ||
| 25 | ).toEqual({ kind: "loading" }); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("explains uncached detail while disconnected instead of loading forever", () => { | ||
| 29 | expect( | ||
| 30 | projectThreadContentPresentation({ | ||
| 31 | hasDetail: false, | ||
| 32 | detailError: null, | ||
| 33 | detailDeleted: false, | ||
| 34 | connectionState: "error", | ||
| 35 | }), | ||
| 36 | ).toEqual({ | ||
| 37 | kind: "unavailable", | ||
| 38 | title: "Messages not cached", | ||
| 39 | detail: "Reconnect this environment to load the conversation.", | ||
| 40 | }); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("surfaces detail errors before presenting a loading state", () => { | ||
| 44 | expect( | ||
| 45 | projectThreadContentPresentation({ | ||
| 46 | hasDetail: false, | ||
| 47 | detailError: "The thread stream failed.", | ||
| 48 | detailDeleted: false, | ||
| 49 | connectionState: "connected", | ||
| 50 | }), | ||
| 51 | ).toEqual({ | ||
| 52 | kind: "unavailable", | ||
| 53 | title: "Could not load conversation", | ||
| 54 | detail: "The thread stream failed.", | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | }); | ||
apps/mobile/src/features/threads/threadContentPresentation.ts created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 2 | |||
| 3 | export type ThreadContentPresentation = | ||
| 4 | | { readonly kind: "ready" } | ||
| 5 | | { readonly kind: "loading" } | ||
| 6 | | { | ||
| 7 | readonly kind: "unavailable"; | ||
| 8 | readonly title: string; | ||
| 9 | readonly detail: string; | ||
| 10 | }; | ||
| 11 | |||
| 12 | export function projectThreadContentPresentation(input: { | ||
| 13 | readonly hasDetail: boolean; | ||
| 14 | readonly detailError: string | null; | ||
| 15 | readonly detailDeleted: boolean; | ||
| 16 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 17 | }): ThreadContentPresentation { | ||
| 18 | if (input.hasDetail) { | ||
| 19 | return { kind: "ready" }; | ||
| 20 | } | ||
| 21 | if (input.detailDeleted) { | ||
| 22 | return { | ||
| 23 | kind: "unavailable", | ||
| 24 | title: "Thread unavailable", | ||
| 25 | detail: "This thread was deleted or is no longer available.", | ||
| 26 | }; | ||
| 27 | } | ||
| 28 | if (input.detailError !== null) { | ||
| 29 | return { | ||
| 30 | kind: "unavailable", | ||
| 31 | title: "Could not load conversation", | ||
| 32 | detail: input.detailError, | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | if (input.connectionState === "connected") { | ||
| 36 | return { kind: "loading" }; | ||
| 37 | } | ||
| 38 | return { | ||
| 39 | kind: "unavailable", | ||
| 40 | title: "Messages not cached", | ||
| 41 | detail: "Reconnect this environment to load the conversation.", | ||
| 42 | }; | ||
| 43 | } | ||
apps/mobile/src/features/threads/threadPresentation.ts+3-12| ... | @@ -1,12 +1,12 @@ | ... | @@ -1,12 +1,12 @@ |
| 1 | import type { StatusTone } from "../../components/StatusPill"; | 1 | import type { StatusTone } from "../../components/StatusPill"; |
| 2 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | 2 | import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | 3 | ||
| 4 | export function threadSortValue(thread: EnvironmentScopedThreadShell): number { | 4 | export function threadSortValue(thread: EnvironmentThreadShell): number { |
| 5 | const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); | 5 | const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); |
| 6 | return Number.isNaN(candidate) ? 0 : candidate; | 6 | return Number.isNaN(candidate) ? 0 : candidate; |
| 7 | } | 7 | } |
| 8 | 8 | ||
| 9 | export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTone { | 9 | export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone { |
| 10 | const status = thread.session?.status; | 10 | const status = thread.session?.status; |
| 11 | if (status === "running") { | 11 | if (status === "running") { |
| 12 | return { | 12 | return { |
| ... | @@ -42,12 +42,3 @@ export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTo | ... | @@ -42,12 +42,3 @@ export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTo |
| 42 | textClassName: "text-neutral-600 dark:text-neutral-300", | 42 | textClassName: "text-neutral-600 dark:text-neutral-300", |
| 43 | }; | 43 | }; |
| 44 | } | 44 | } |
| 45 | |||
| 46 | export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string): string | null { | ||
| 47 | if (!httpBaseUrl) { | ||
| 48 | return null; | ||
| 49 | } | ||
| 50 | |||
| 51 | const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl); | ||
| 52 | return url.toString(); | ||
| 53 | } |
apps/mobile/src/features/threads/use-project-actions.ts+66-206| ... | @@ -1,67 +1,26 @@ | ... | @@ -1,67 +1,26 @@ |
| 1 | import { useCallback } from "react"; | 1 | import { useCallback } from "react"; |
| 2 | 2 | ||
| 3 | import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; | 3 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 4 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | ||
| 5 | import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | ||
| 4 | import { | 6 | import { |
| 5 | CommandId, | 7 | CommandId, |
| 6 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 7 | DEFAULT_RUNTIME_MODE, | ||
| 8 | type EnvironmentId, | ||
| 9 | MessageId, | 8 | MessageId, |
| 10 | ThreadId, | 9 | ThreadId, |
| 11 | type ModelSelection, | 10 | type ModelSelection, |
| 12 | type ProviderInteractionMode, | 11 | type ProviderInteractionMode, |
| 13 | type RuntimeMode, | 12 | type RuntimeMode, |
| 14 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 15 | import { buildTemporaryWorktreeBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 14 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 16 | import { uuidv4 } from "../../lib/uuid"; | 15 | import * as Cause from "effect/Cause"; |
| 16 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 17 | 17 | ||
| 18 | import { threadEnvironment } from "../../state/threads"; | ||
| 18 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 19 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 19 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | 20 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 20 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | 21 | import { uuidv4 } from "../../lib/uuid"; |
| 21 | import { environmentRuntimeManager } from "../../state/use-environment-runtime"; | 22 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 22 | import { vcsRefManager } from "../../state/use-vcs-refs"; | 23 | import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; |
| 23 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 24 | import { | ||
| 25 | setPendingConnectionError, | ||
| 26 | useRemoteEnvironmentState, | ||
| 27 | } from "../../state/use-remote-environment-registry"; | ||
| 28 | |||
| 29 | function useRefreshRemoteData() { | ||
| 30 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 31 | |||
| 32 | return useCallback( | ||
| 33 | async (environmentIds?: ReadonlyArray<EnvironmentId>) => { | ||
| 34 | const targets = | ||
| 35 | environmentIds ?? | ||
| 36 | Object.values(savedConnectionsById).map((connection) => connection.environmentId); | ||
| 37 | |||
| 38 | await Promise.all( | ||
| 39 | targets.map(async (environmentId) => { | ||
| 40 | const client = getEnvironmentClient(environmentId); | ||
| 41 | if (!client) { | ||
| 42 | return; | ||
| 43 | } | ||
| 44 | |||
| 45 | try { | ||
| 46 | const serverConfig = await client.server.getConfig(); | ||
| 47 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 48 | ...current, | ||
| 49 | serverConfig, | ||
| 50 | connectionError: null, | ||
| 51 | })); | ||
| 52 | } catch (error) { | ||
| 53 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 54 | ...current, | ||
| 55 | connectionError: | ||
| 56 | error instanceof Error ? error.message : "Failed to refresh remote data.", | ||
| 57 | })); | ||
| 58 | } | ||
| 59 | }), | ||
| 60 | ); | ||
| 61 | }, | ||
| 62 | [savedConnectionsById], | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | 24 | ||
| 66 | function deriveThreadTitleFromPrompt(value: string): string { | 25 | function deriveThreadTitleFromPrompt(value: string): string { |
| 67 | const trimmed = value.trim(); | 26 | const trimmed = value.trim(); |
| ... | @@ -73,13 +32,12 @@ function deriveThreadTitleFromPrompt(value: string): string { | ... | @@ -73,13 +32,12 @@ function deriveThreadTitleFromPrompt(value: string): string { |
| 73 | return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; | 32 | return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; |
| 74 | } | 33 | } |
| 75 | 34 | ||
| 76 | export function useProjectActions() { | 35 | export function useCreateProjectThread() { |
| 77 | const { threads } = useRemoteCatalog(); | 36 | const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); |
| 78 | const refreshRemoteData = useRefreshRemoteData(); | ||
| 79 | 37 | ||
| 80 | const onCreateThreadWithOptions = useCallback( | 38 | return useCallback( |
| 81 | async (input: { | 39 | async (input: { |
| 82 | readonly project: EnvironmentScopedProjectShell; | 40 | readonly project: EnvironmentProject; |
| 83 | readonly modelSelection: ModelSelection; | 41 | readonly modelSelection: ModelSelection; |
| 84 | readonly envMode: "local" | "worktree"; | 42 | readonly envMode: "local" | "worktree"; |
| 85 | readonly branch: string | null; | 43 | readonly branch: string | null; |
| ... | @@ -89,174 +47,76 @@ export function useProjectActions() { | ... | @@ -89,174 +47,76 @@ export function useProjectActions() { |
| 89 | readonly initialMessageText: string; | 47 | readonly initialMessageText: string; |
| 90 | readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 48 | readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 91 | }) => { | 49 | }) => { |
| 92 | const client = getEnvironmentClient(input.project.environmentId); | ||
| 93 | if (!client) { | ||
| 94 | return null; | ||
| 95 | } | ||
| 96 | |||
| 97 | const metadata = makeTurnCommandMetadata(); | 50 | const metadata = makeTurnCommandMetadata(); |
| 98 | const threadId = ThreadId.make(metadata.threadId); | 51 | const threadId = ThreadId.make(metadata.threadId); |
| 99 | const createdAt = metadata.createdAt; | ||
| 100 | const initialMessageText = input.initialMessageText.trim(); | 52 | const initialMessageText = input.initialMessageText.trim(); |
| 101 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); | 53 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); |
| 102 | 54 | ||
| 103 | if (initialMessageText.length === 0) { | 55 | if (initialMessageText.length === 0) { |
| 104 | return null; | 56 | const error = new Error("Enter a task before starting the thread."); |
| 57 | setPendingConnectionError(error.message); | ||
| 58 | return AsyncResult.failure(Cause.fail(error)); | ||
| 105 | } | 59 | } |
| 106 | if (input.envMode === "worktree" && !input.branch) { | 60 | if (input.envMode === "worktree" && !input.branch) { |
| 107 | return null; | 61 | const error = new Error("Select a base branch before creating a worktree."); |
| 62 | setPendingConnectionError(error.message); | ||
| 63 | return AsyncResult.failure(Cause.fail(error)); | ||
| 108 | } | 64 | } |
| 109 | 65 | ||
| 110 | const isWorktree = input.envMode === "worktree"; | 66 | const isWorktree = input.envMode === "worktree"; |
| 111 | 67 | const result = await startTurn({ | |
| 112 | await client.orchestration.dispatchCommand({ | 68 | environmentId: input.project.environmentId, |
| 113 | type: "thread.turn.start", | 69 | input: { |
| 114 | commandId: CommandId.make(metadata.commandId), | 70 | commandId: CommandId.make(metadata.commandId), |
| 115 | threadId, | 71 | threadId, |
| 116 | message: { | 72 | message: { |
| 117 | messageId: MessageId.make(metadata.messageId), | 73 | messageId: MessageId.make(metadata.messageId), |
| 118 | role: "user", | 74 | role: "user", |
| 119 | text: initialMessageText, | 75 | text: initialMessageText, |
| 120 | attachments: input.initialAttachments, | 76 | attachments: input.initialAttachments, |
| 121 | }, | 77 | }, |
| 122 | modelSelection: input.modelSelection, | 78 | modelSelection: input.modelSelection, |
| 123 | titleSeed: nextTitle, | 79 | titleSeed: nextTitle, |
| 124 | runtimeMode: input.runtimeMode, | 80 | runtimeMode: input.runtimeMode, |
| 125 | interactionMode: input.interactionMode, | 81 | interactionMode: input.interactionMode, |
| 126 | bootstrap: { | 82 | bootstrap: { |
| 127 | createThread: { | 83 | createThread: { |
| 128 | projectId: input.project.id, | 84 | projectId: input.project.id, |
| 129 | title: nextTitle, | 85 | title: nextTitle, |
| 130 | modelSelection: input.modelSelection, | 86 | modelSelection: input.modelSelection, |
| 131 | runtimeMode: input.runtimeMode, | 87 | runtimeMode: input.runtimeMode, |
| 132 | interactionMode: input.interactionMode, | 88 | interactionMode: input.interactionMode, |
| 133 | branch: input.branch, | 89 | branch: input.branch, |
| 134 | worktreePath: isWorktree ? null : input.worktreePath, | 90 | worktreePath: isWorktree ? null : input.worktreePath, |
| 135 | createdAt, | 91 | createdAt: metadata.createdAt, |
| 92 | }, | ||
| 93 | ...(isWorktree | ||
| 94 | ? { | ||
| 95 | prepareWorktree: { | ||
| 96 | projectCwd: input.project.workspaceRoot, | ||
| 97 | baseBranch: input.branch!, | ||
| 98 | branch: buildTemporaryWorktreeBranchName(uuidv4), | ||
| 99 | }, | ||
| 100 | runSetupScript: true, | ||
| 101 | } | ||
| 102 | : {}), | ||
| 136 | }, | 103 | }, |
| 137 | ...(isWorktree | 104 | createdAt: metadata.createdAt, |
| 138 | ? { | ||
| 139 | prepareWorktree: { | ||
| 140 | projectCwd: input.project.workspaceRoot, | ||
| 141 | baseBranch: input.branch!, | ||
| 142 | branch: buildTemporaryWorktreeBranchName(uuidv4), | ||
| 143 | }, | ||
| 144 | runSetupScript: true, | ||
| 145 | } | ||
| 146 | : {}), | ||
| 147 | }, | 105 | }, |
| 148 | createdAt, | ||
| 149 | }); | ||
| 150 | |||
| 151 | await refreshRemoteData([input.project.environmentId]); | ||
| 152 | return { | ||
| 153 | environmentId: input.project.environmentId, | ||
| 154 | threadId, | ||
| 155 | }; | ||
| 156 | }, | ||
| 157 | [refreshRemoteData], | ||
| 158 | ); | ||
| 159 | |||
| 160 | const onCreateThread = useCallback( | ||
| 161 | async (project: EnvironmentScopedProjectShell) => { | ||
| 162 | const latestProjectThread = | ||
| 163 | threads.find( | ||
| 164 | (thread) => | ||
| 165 | thread.environmentId === project.environmentId && thread.projectId === project.id, | ||
| 166 | ) ?? null; | ||
| 167 | const modelSelection = | ||
| 168 | project.defaultModelSelection ?? latestProjectThread?.modelSelection ?? null; | ||
| 169 | if (!modelSelection) { | ||
| 170 | setPendingConnectionError("This project does not have a default model configured yet."); | ||
| 171 | return null; | ||
| 172 | } | ||
| 173 | |||
| 174 | return await onCreateThreadWithOptions({ | ||
| 175 | project, | ||
| 176 | modelSelection, | ||
| 177 | envMode: "local", | ||
| 178 | branch: null, | ||
| 179 | worktreePath: null, | ||
| 180 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 181 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 182 | initialMessageText: "", | ||
| 183 | initialAttachments: [], | ||
| 184 | }); | 106 | }); |
| 185 | }, | 107 | if (AsyncResult.isFailure(result)) { |
| 186 | [onCreateThreadWithOptions, threads], | 108 | const error = Cause.squash(result.cause); |
| 187 | ); | ||
| 188 | |||
| 189 | const onListProjectBranches = useCallback( | ||
| 190 | async (project: EnvironmentScopedProjectShell): Promise<ReadonlyArray<VcsRef>> => { | ||
| 191 | const client = getEnvironmentClient(project.environmentId); | ||
| 192 | if (!client) { | ||
| 193 | return []; | ||
| 194 | } | ||
| 195 | |||
| 196 | try { | ||
| 197 | const result = await vcsRefManager.load( | ||
| 198 | { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null }, | ||
| 199 | client.vcs, | ||
| 200 | { limit: 100 }, | ||
| 201 | ); | ||
| 202 | return (result?.refs ?? []).filter((branch) => !branch.isRemote); | ||
| 203 | } catch (error) { | ||
| 204 | setPendingConnectionError( | 109 | setPendingConnectionError( |
| 205 | error instanceof Error ? error.message : "Failed to load branches.", | 110 | error instanceof Error ? error.message : "The task could not be started.", |
| 206 | ); | 111 | ); |
| 207 | return []; | 112 | return AsyncResult.failure(result.cause); |
| 208 | } | ||
| 209 | }, | ||
| 210 | [], | ||
| 211 | ); | ||
| 212 | |||
| 213 | const onCreateProjectWorktree = useCallback( | ||
| 214 | async ( | ||
| 215 | project: EnvironmentScopedProjectShell, | ||
| 216 | nextWorktree: { | ||
| 217 | readonly baseBranch: string; | ||
| 218 | readonly newBranch: string; | ||
| 219 | }, | ||
| 220 | ): Promise<{ | ||
| 221 | readonly branch: string; | ||
| 222 | readonly worktreePath: string; | ||
| 223 | } | null> => { | ||
| 224 | const client = getEnvironmentClient(project.environmentId); | ||
| 225 | if (!client) { | ||
| 226 | return null; | ||
| 227 | } | 113 | } |
| 114 | setPendingConnectionError(null); | ||
| 228 | 115 | ||
| 229 | try { | 116 | return mapAtomCommandResult(result, () => |
| 230 | const result = await client.vcs.createWorktree({ | 117 | scopeThreadRef(input.project.environmentId, threadId), |
| 231 | cwd: project.workspaceRoot, | 118 | ); |
| 232 | refName: nextWorktree.baseBranch, | ||
| 233 | newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), | ||
| 234 | path: null, | ||
| 235 | }); | ||
| 236 | vcsRefManager.invalidate({ | ||
| 237 | environmentId: project.environmentId, | ||
| 238 | cwd: project.workspaceRoot, | ||
| 239 | query: null, | ||
| 240 | }); | ||
| 241 | return { | ||
| 242 | branch: result.worktree.refName, | ||
| 243 | worktreePath: result.worktree.path, | ||
| 244 | }; | ||
| 245 | } catch (error) { | ||
| 246 | setPendingConnectionError( | ||
| 247 | error instanceof Error ? error.message : "Failed to create worktree.", | ||
| 248 | ); | ||
| 249 | return null; | ||
| 250 | } | ||
| 251 | }, | 119 | }, |
| 252 | [], | 120 | [startTurn], |
| 253 | ); | 121 | ); |
| 254 | |||
| 255 | return { | ||
| 256 | onCreateThread, | ||
| 257 | onCreateThreadWithOptions, | ||
| 258 | onListProjectBranches, | ||
| 259 | onCreateProjectWorktree, | ||
| 260 | onRefreshProjects: refreshRemoteData, | ||
| 261 | }; | ||
| 262 | } | 122 | } |
apps/mobile/src/lib/authClientMetadata.ts+1-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; | 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; |
| 2 | import { Platform } from "react-native"; | 2 | import { Platform } from "react-native"; |
| 3 | 3 | ||
| 4 | export function mobileAuthClientMetadata(): AuthClientPresentationMetadata { | 4 | export function authClientMetadata(): AuthClientPresentationMetadata { |
| 5 | return { | 5 | return { |
| 6 | label: "T3 Code Mobile", | 6 | label: "T3 Code Mobile", |
| 7 | deviceType: "mobile", | 7 | deviceType: "mobile", |
apps/mobile/src/lib/connection.test.ts+3-3| ... | @@ -3,13 +3,13 @@ import { EnvironmentId } from "@t3tools/contracts"; | ... | @@ -3,13 +3,13 @@ import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | isRelayManagedConnection, | 5 | isRelayManagedConnection, |
| 6 | mobileAuthClientMetadata, | 6 | authClientMetadata, |
| 7 | redactPairingCredential, | 7 | redactPairingCredential, |
| 8 | toStableSavedRemoteConnection, | 8 | toStableSavedRemoteConnection, |
| 9 | } from "./connection"; | 9 | } from "./connection"; |
| 10 | 10 | ||
| 11 | vi.mock("./runtime", () => ({ | 11 | vi.mock("./runtime", () => ({ |
| 12 | mobileRuntime: { | 12 | runtime: { |
| 13 | runPromise: vi.fn(), | 13 | runPromise: vi.fn(), |
| 14 | }, | 14 | }, |
| 15 | })); | 15 | })); |
| ... | @@ -22,7 +22,7 @@ vi.mock("react-native", () => ({ | ... | @@ -22,7 +22,7 @@ vi.mock("react-native", () => ({ |
| 22 | 22 | ||
| 23 | describe("mobile remote connection records", () => { | 23 | describe("mobile remote connection records", () => { |
| 24 | it("identifies mobile token exchanges for authorized-client presentation", () => { | 24 | it("identifies mobile token exchanges for authorized-client presentation", () => { |
| 25 | expect(mobileAuthClientMetadata()).toEqual({ | 25 | expect(authClientMetadata()).toEqual({ |
| 26 | label: "T3 Code Mobile", | 26 | label: "T3 Code Mobile", |
| 27 | deviceType: "mobile", | 27 | deviceType: "mobile", |
| 28 | os: "iOS", | 28 | os: "iOS", |
apps/mobile/src/lib/connection.ts+4-54| ... | @@ -1,18 +1,8 @@ | ... | @@ -1,18 +1,8 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; |
| 3 | bootstrapRemoteBearerSession, | 3 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 4 | fetchRemoteEnvironmentDescriptor, | ||
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import { mobileAuthClientMetadata } from "./authClientMetadata"; | ||
| 9 | import { mobileRuntime } from "./runtime"; | ||
| 10 | 4 | ||
| 11 | export { mobileAuthClientMetadata } from "./authClientMetadata"; | 5 | export { authClientMetadata } from "./authClientMetadata"; |
| 12 | |||
| 13 | export interface RemoteConnectionInput { | ||
| 14 | readonly pairingUrl: string; | ||
| 15 | } | ||
| 16 | 6 | ||
| 17 | export interface SavedRemoteConnection { | 7 | export interface SavedRemoteConnection { |
| 18 | readonly environmentId: EnvironmentId; | 8 | readonly environmentId: EnvironmentId; |
| ... | @@ -27,12 +17,7 @@ export interface SavedRemoteConnection { | ... | @@ -27,12 +17,7 @@ export interface SavedRemoteConnection { |
| 27 | readonly relayManaged?: true; | 17 | readonly relayManaged?: true; |
| 28 | } | 18 | } |
| 29 | 19 | ||
| 30 | export type RemoteClientConnectionState = | 20 | export type RemoteClientConnectionState = EnvironmentConnectionPhase; |
| 31 | | "idle" | ||
| 32 | | "connecting" | ||
| 33 | | "ready" | ||
| 34 | | "reconnecting" | ||
| 35 | | "disconnected"; | ||
| 36 | 21 | ||
| 37 | export function redactPairingCredential(pairingUrl: string): string { | 22 | export function redactPairingCredential(pairingUrl: string): string { |
| 38 | const trimmed = pairingUrl.trim(); | 23 | const trimmed = pairingUrl.trim(); |
| ... | @@ -59,38 +44,3 @@ export function toStableSavedRemoteConnection( | ... | @@ -59,38 +44,3 @@ export function toStableSavedRemoteConnection( |
| 59 | const { dpopAccessToken: _, ...stableConnection } = connection; | 44 | const { dpopAccessToken: _, ...stableConnection } = connection; |
| 60 | return stableConnection; | 45 | return stableConnection; |
| 61 | } | 46 | } |
| 62 | |||
| 63 | export async function bootstrapRemoteConnection( | ||
| 64 | input: RemoteConnectionInput, | ||
| 65 | ): Promise<SavedRemoteConnection> { | ||
| 66 | const target = resolveRemotePairingTarget({ | ||
| 67 | pairingUrl: input.pairingUrl, | ||
| 68 | }); | ||
| 69 | |||
| 70 | const { descriptor, bootstrap } = await mobileRuntime.runPromise( | ||
| 71 | Effect.all( | ||
| 72 | { | ||
| 73 | descriptor: fetchRemoteEnvironmentDescriptor({ | ||
| 74 | httpBaseUrl: target.httpBaseUrl, | ||
| 75 | }), | ||
| 76 | bootstrap: bootstrapRemoteBearerSession({ | ||
| 77 | httpBaseUrl: target.httpBaseUrl, | ||
| 78 | credential: target.credential, | ||
| 79 | clientMetadata: mobileAuthClientMetadata(), | ||
| 80 | }), | ||
| 81 | }, | ||
| 82 | { concurrency: "unbounded" }, | ||
| 83 | ), | ||
| 84 | ); | ||
| 85 | |||
| 86 | return { | ||
| 87 | environmentId: descriptor.environmentId, | ||
| 88 | environmentLabel: descriptor.label, | ||
| 89 | pairingUrl: redactPairingCredential(input.pairingUrl), | ||
| 90 | displayUrl: target.httpBaseUrl, | ||
| 91 | httpBaseUrl: target.httpBaseUrl, | ||
| 92 | wsBaseUrl: target.wsBaseUrl, | ||
| 93 | bearerToken: bootstrap.access_token, | ||
| 94 | authenticationMethod: "bearer", | ||
| 95 | }; | ||
| 96 | } |
apps/mobile/src/lib/layout.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | function clamp(value: number, min: number, max: number): number { | ||
| 2 | return Math.min(Math.max(value, min), max); | ||
| 3 | } | ||
| 4 | |||
| 5 | export type LayoutVariant = "compact" | "split"; | ||
| 6 | |||
| 7 | export interface Layout { | ||
| 8 | readonly variant: LayoutVariant; | ||
| 9 | readonly usesSplitView: boolean; | ||
| 10 | readonly listPaneWidth: number | null; | ||
| 11 | readonly shellPadding: number; | ||
| 12 | } | ||
| 13 | |||
| 14 | export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { | ||
| 15 | const { width, height } = input; | ||
| 16 | const shortestEdge = Math.min(width, height); | ||
| 17 | const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); | ||
| 18 | |||
| 19 | if (!wideEnoughForSplit) { | ||
| 20 | return { | ||
| 21 | variant: "compact", | ||
| 22 | usesSplitView: false, | ||
| 23 | listPaneWidth: null, | ||
| 24 | shellPadding: 0, | ||
| 25 | }; | ||
| 26 | } | ||
| 27 | |||
| 28 | return { | ||
| 29 | variant: "split", | ||
| 30 | usesSplitView: true, | ||
| 31 | listPaneWidth: clamp(Math.round(width * 0.34), 320, 420), | ||
| 32 | shellPadding: width >= 1180 ? 20 : 14, | ||
| 33 | }; | ||
| 34 | } | ||
apps/mobile/src/lib/mobileLayout.ts deleted-37| ... | @@ -1,37 +0,0 @@ | ||
| 1 | function clamp(value: number, min: number, max: number): number { | ||
| 2 | return Math.min(Math.max(value, min), max); | ||
| 3 | } | ||
| 4 | |||
| 5 | export type MobileLayoutVariant = "compact" | "split"; | ||
| 6 | |||
| 7 | export interface MobileLayout { | ||
| 8 | readonly variant: MobileLayoutVariant; | ||
| 9 | readonly usesSplitView: boolean; | ||
| 10 | readonly listPaneWidth: number | null; | ||
| 11 | readonly shellPadding: number; | ||
| 12 | } | ||
| 13 | |||
| 14 | export function deriveMobileLayout(input: { | ||
| 15 | readonly width: number; | ||
| 16 | readonly height: number; | ||
| 17 | }): MobileLayout { | ||
| 18 | const { width, height } = input; | ||
| 19 | const shortestEdge = Math.min(width, height); | ||
| 20 | const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); | ||
| 21 | |||
| 22 | if (!wideEnoughForSplit) { | ||
| 23 | return { | ||
| 24 | variant: "compact", | ||
| 25 | usesSplitView: false, | ||
| 26 | listPaneWidth: null, | ||
| 27 | shellPadding: 0, | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | |||
| 31 | return { | ||
| 32 | variant: "split", | ||
| 33 | usesSplitView: true, | ||
| 34 | listPaneWidth: clamp(Math.round(width * 0.34), 320, 420), | ||
| 35 | shellPadding: width >= 1180 ? 20 : 14, | ||
| 36 | }; | ||
| 37 | } | ||
apps/mobile/src/lib/modelOptions.test.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { buildModelOptions } from "./modelOptions"; | ||
| 6 | |||
| 7 | describe("mobile model options", () => { | ||
| 8 | it("normalizes a legacy fallback selection against current capabilities", () => { | ||
| 9 | const config = { | ||
| 10 | providers: [ | ||
| 11 | { | ||
| 12 | instanceId: "codex", | ||
| 13 | driver: "codex", | ||
| 14 | displayName: "Codex", | ||
| 15 | enabled: true, | ||
| 16 | installed: true, | ||
| 17 | auth: { status: "authenticated" }, | ||
| 18 | models: [ | ||
| 19 | { | ||
| 20 | slug: "gpt-test", | ||
| 21 | name: "GPT Test", | ||
| 22 | isCustom: false, | ||
| 23 | capabilities: { | ||
| 24 | optionDescriptors: [ | ||
| 25 | { | ||
| 26 | id: "serviceTier", | ||
| 27 | label: "Service Tier", | ||
| 28 | type: "select", | ||
| 29 | options: [ | ||
| 30 | { id: "default", label: "Standard", isDefault: true }, | ||
| 31 | { id: "priority", label: "Fast" }, | ||
| 32 | ], | ||
| 33 | currentValue: "default", | ||
| 34 | }, | ||
| 35 | ], | ||
| 36 | }, | ||
| 37 | }, | ||
| 38 | ], | ||
| 39 | }, | ||
| 40 | ], | ||
| 41 | } as unknown as ServerConfig; | ||
| 42 | |||
| 43 | const [option] = buildModelOptions(config, { | ||
| 44 | instanceId: ProviderInstanceId.make("codex"), | ||
| 45 | model: "gpt-test", | ||
| 46 | options: [{ id: "fastMode", value: true }], | ||
| 47 | }); | ||
| 48 | |||
| 49 | expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); | ||
| 50 | expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); | ||
| 51 | }); | ||
| 52 | }); | ||
apps/mobile/src/lib/modelOptions.ts+47-6| ... | @@ -1,4 +1,12 @@ | ... | @@ -1,4 +1,12 @@ |
| 1 | import type { ModelSelection, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; | 1 | import type { |
| 2 | ModelCapabilities, | ||
| 3 | ModelSelection, | ||
| 4 | ServerConfig as T3ServerConfig, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | buildProviderOptionSelectionsFromDescriptors, | ||
| 8 | getProviderOptionDescriptors, | ||
| 9 | } from "@t3tools/shared/model"; | ||
| 2 | 10 | ||
| 3 | export type ModelOption = { | 11 | export type ModelOption = { |
| 4 | readonly key: string; | 12 | readonly key: string; |
| ... | @@ -7,6 +15,7 @@ export type ModelOption = { | ... | @@ -7,6 +15,7 @@ export type ModelOption = { |
| 7 | readonly providerKey: string; | 15 | readonly providerKey: string; |
| 8 | readonly providerLabel: string; | 16 | readonly providerLabel: string; |
| 9 | readonly providerDriver: string; | 17 | readonly providerDriver: string; |
| 18 | readonly capabilities: ModelCapabilities | null; | ||
| 10 | readonly selection: ModelSelection; | 19 | readonly selection: ModelSelection; |
| 11 | }; | 20 | }; |
| 12 | 21 | ||
| ... | @@ -27,6 +36,27 @@ function providerDisplayLabel(provider: { | ... | @@ -27,6 +36,27 @@ function providerDisplayLabel(provider: { |
| 27 | return provider.instanceId; | 36 | return provider.instanceId; |
| 28 | } | 37 | } |
| 29 | 38 | ||
| 39 | function normalizeSelectionOptions( | ||
| 40 | selection: ModelSelection, | ||
| 41 | capabilities: ModelCapabilities | null, | ||
| 42 | ): ModelSelection { | ||
| 43 | if (!capabilities) { | ||
| 44 | return selection; | ||
| 45 | } | ||
| 46 | const options = buildProviderOptionSelectionsFromDescriptors( | ||
| 47 | getProviderOptionDescriptors({ | ||
| 48 | caps: capabilities, | ||
| 49 | selections: selection.options, | ||
| 50 | }), | ||
| 51 | ); | ||
| 52 | return options | ||
| 53 | ? { ...selection, options } | ||
| 54 | : { | ||
| 55 | instanceId: selection.instanceId, | ||
| 56 | model: selection.model, | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 30 | export function buildModelOptions( | 60 | export function buildModelOptions( |
| 31 | config: T3ServerConfig | null | undefined, | 61 | config: T3ServerConfig | null | undefined, |
| 32 | fallbackModelSelection: ModelSelection | null, | 62 | fallbackModelSelection: ModelSelection | null, |
| ... | @@ -48,17 +78,27 @@ export function buildModelOptions( | ... | @@ -48,17 +78,27 @@ export function buildModelOptions( |
| 48 | providerKey: provider.instanceId, | 78 | providerKey: provider.instanceId, |
| 49 | providerLabel, | 79 | providerLabel, |
| 50 | providerDriver: provider.driver, | 80 | providerDriver: provider.driver, |
| 51 | selection: { | 81 | capabilities: model.capabilities, |
| 52 | instanceId: provider.instanceId, | 82 | selection: normalizeSelectionOptions( |
| 53 | model: model.slug, | 83 | { |
| 54 | }, | 84 | instanceId: provider.instanceId, |
| 85 | model: model.slug, | ||
| 86 | }, | ||
| 87 | model.capabilities, | ||
| 88 | ), | ||
| 55 | }); | 89 | }); |
| 56 | } | 90 | } |
| 57 | } | 91 | } |
| 58 | 92 | ||
| 59 | if (fallbackModelSelection) { | 93 | if (fallbackModelSelection) { |
| 60 | const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; | 94 | const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; |
| 61 | if (!options.has(key)) { | 95 | const existing = options.get(key); |
| 96 | if (existing) { | ||
| 97 | options.set(key, { | ||
| 98 | ...existing, | ||
| 99 | selection: normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), | ||
| 100 | }); | ||
| 101 | } else { | ||
| 62 | const providerLabel = fallbackModelSelection.instanceId; | 102 | const providerLabel = fallbackModelSelection.instanceId; |
| 63 | options.set(key, { | 103 | options.set(key, { |
| 64 | key, | 104 | key, |
| ... | @@ -67,6 +107,7 @@ export function buildModelOptions( | ... | @@ -67,6 +107,7 @@ export function buildModelOptions( |
| 67 | providerKey: fallbackModelSelection.instanceId, | 107 | providerKey: fallbackModelSelection.instanceId, |
| 68 | providerLabel, | 108 | providerLabel, |
| 69 | providerDriver: fallbackModelSelection.instanceId, | 109 | providerDriver: fallbackModelSelection.instanceId, |
| 110 | capabilities: null, | ||
| 70 | selection: fallbackModelSelection, | 111 | selection: fallbackModelSelection, |
| 71 | }); | 112 | }); |
| 72 | } | 113 | } |
apps/mobile/src/lib/providerOptions.test.ts created+100| ... | @@ -0,0 +1,100 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import type { ModelCapabilities } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | applyProviderOptionMenuEvent, | ||
| 7 | buildProviderOptionMenuActions, | ||
| 8 | providerOptionsConfigurationLabel, | ||
| 9 | resolveProviderOptionDescriptors, | ||
| 10 | } from "./providerOptions"; | ||
| 11 | |||
| 12 | const CODEX_CAPABILITIES: ModelCapabilities = { | ||
| 13 | optionDescriptors: [ | ||
| 14 | { | ||
| 15 | id: "reasoningEffort", | ||
| 16 | label: "Reasoning", | ||
| 17 | type: "select", | ||
| 18 | options: [ | ||
| 19 | { id: "medium", label: "Medium", isDefault: true }, | ||
| 20 | { id: "high", label: "High" }, | ||
| 21 | ], | ||
| 22 | currentValue: "medium", | ||
| 23 | }, | ||
| 24 | { | ||
| 25 | id: "serviceTier", | ||
| 26 | label: "Service Tier", | ||
| 27 | type: "select", | ||
| 28 | options: [ | ||
| 29 | { id: "default", label: "Standard", isDefault: true }, | ||
| 30 | { id: "priority", label: "Fast" }, | ||
| 31 | ], | ||
| 32 | currentValue: "default", | ||
| 33 | }, | ||
| 34 | ], | ||
| 35 | }; | ||
| 36 | |||
| 37 | describe("mobile provider options", () => { | ||
| 38 | it("renders the option descriptors advertised by the selected model", () => { | ||
| 39 | const descriptors = resolveProviderOptionDescriptors({ | ||
| 40 | capabilities: CODEX_CAPABILITIES, | ||
| 41 | selections: undefined, | ||
| 42 | }); | ||
| 43 | |||
| 44 | expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ | ||
| 45 | { | ||
| 46 | title: "Reasoning", | ||
| 47 | subtitle: "Medium", | ||
| 48 | subactions: [ | ||
| 49 | { title: "Medium (default)", state: "on" }, | ||
| 50 | { title: "High", state: undefined }, | ||
| 51 | ], | ||
| 52 | }, | ||
| 53 | { | ||
| 54 | title: "Service Tier", | ||
| 55 | subtitle: "Standard", | ||
| 56 | subactions: [ | ||
| 57 | { title: "Standard (default)", state: "on" }, | ||
| 58 | { title: "Fast", state: undefined }, | ||
| 59 | ], | ||
| 60 | }, | ||
| 61 | ]); | ||
| 62 | expect(providerOptionsConfigurationLabel(descriptors)).toBe("Medium · Standard"); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("updates generic select options without knowing provider-specific ids", () => { | ||
| 66 | const descriptors = resolveProviderOptionDescriptors({ | ||
| 67 | capabilities: CODEX_CAPABILITIES, | ||
| 68 | selections: undefined, | ||
| 69 | }); | ||
| 70 | const actions = buildProviderOptionMenuActions(descriptors); | ||
| 71 | const fastEvent = actions[1]?.subactions?.[1]?.id; | ||
| 72 | |||
| 73 | expect(fastEvent).toBeDefined(); | ||
| 74 | expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([ | ||
| 75 | { id: "reasoningEffort", value: "medium" }, | ||
| 76 | { id: "serviceTier", value: "priority" }, | ||
| 77 | ]); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("treats an unspecified boolean capability as off", () => { | ||
| 81 | const descriptors = resolveProviderOptionDescriptors({ | ||
| 82 | capabilities: { | ||
| 83 | optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], | ||
| 84 | }, | ||
| 85 | selections: undefined, | ||
| 86 | }); | ||
| 87 | |||
| 88 | expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ | ||
| 89 | { | ||
| 90 | title: "Fast Mode", | ||
| 91 | subtitle: "Off", | ||
| 92 | subactions: [ | ||
| 93 | { title: "Off", state: "on" }, | ||
| 94 | { title: "On", state: undefined }, | ||
| 95 | ], | ||
| 96 | }, | ||
| 97 | ]); | ||
| 98 | expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); | ||
| 99 | }); | ||
| 100 | }); | ||
apps/mobile/src/lib/providerOptions.ts created+141| ... | @@ -0,0 +1,141 @@ | ||
| 1 | import type { | ||
| 2 | ModelCapabilities, | ||
| 3 | ProviderOptionDescriptor, | ||
| 4 | ProviderOptionSelection, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import type { MenuAction } from "@react-native-menu/menu"; | ||
| 7 | import { | ||
| 8 | buildProviderOptionSelectionsFromDescriptors, | ||
| 9 | getProviderOptionCurrentLabel, | ||
| 10 | getProviderOptionCurrentValue, | ||
| 11 | getProviderOptionDescriptors, | ||
| 12 | } from "@t3tools/shared/model"; | ||
| 13 | |||
| 14 | const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:"; | ||
| 15 | |||
| 16 | function providerOptionEvent(id: string, value: string | boolean): string { | ||
| 17 | return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`; | ||
| 18 | } | ||
| 19 | |||
| 20 | function parseProviderOptionEvent( | ||
| 21 | event: string, | ||
| 22 | ): { readonly id: string; readonly value: string | boolean } | null { | ||
| 23 | if (!event.startsWith(PROVIDER_OPTION_EVENT_PREFIX)) { | ||
| 24 | return null; | ||
| 25 | } | ||
| 26 | |||
| 27 | try { | ||
| 28 | const parsed: unknown = JSON.parse( | ||
| 29 | decodeURIComponent(event.slice(PROVIDER_OPTION_EVENT_PREFIX.length)), | ||
| 30 | ); | ||
| 31 | if ( | ||
| 32 | typeof parsed === "object" && | ||
| 33 | parsed !== null && | ||
| 34 | "id" in parsed && | ||
| 35 | typeof parsed.id === "string" && | ||
| 36 | "value" in parsed && | ||
| 37 | (typeof parsed.value === "string" || typeof parsed.value === "boolean") | ||
| 38 | ) { | ||
| 39 | return { id: parsed.id, value: parsed.value }; | ||
| 40 | } | ||
| 41 | } catch { | ||
| 42 | return null; | ||
| 43 | } | ||
| 44 | |||
| 45 | return null; | ||
| 46 | } | ||
| 47 | |||
| 48 | export function resolveProviderOptionDescriptors(input: { | ||
| 49 | readonly capabilities: ModelCapabilities | null | undefined; | ||
| 50 | readonly selections: ReadonlyArray<ProviderOptionSelection> | null | undefined; | ||
| 51 | }): ReadonlyArray<ProviderOptionDescriptor> { | ||
| 52 | if (!input.capabilities) { | ||
| 53 | return []; | ||
| 54 | } | ||
| 55 | return getProviderOptionDescriptors({ | ||
| 56 | caps: input.capabilities, | ||
| 57 | selections: input.selections, | ||
| 58 | }); | ||
| 59 | } | ||
| 60 | |||
| 61 | export function buildProviderOptionMenuActions( | ||
| 62 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, | ||
| 63 | ): ReadonlyArray<MenuAction> { | ||
| 64 | return descriptors.map((descriptor) => { | ||
| 65 | const currentValue = | ||
| 66 | descriptor.type === "boolean" | ||
| 67 | ? (descriptor.currentValue ?? false) | ||
| 68 | : getProviderOptionCurrentValue(descriptor); | ||
| 69 | const choices = | ||
| 70 | descriptor.type === "select" | ||
| 71 | ? descriptor.options.map((option) => ({ | ||
| 72 | id: providerOptionEvent(descriptor.id, option.id), | ||
| 73 | title: `${option.label}${option.isDefault ? " (default)" : ""}`, | ||
| 74 | state: currentValue === option.id ? ("on" as const) : undefined, | ||
| 75 | })) | ||
| 76 | : ([false, true] as const).map((value) => ({ | ||
| 77 | id: providerOptionEvent(descriptor.id, value), | ||
| 78 | title: value ? "On" : "Off", | ||
| 79 | state: currentValue === value ? ("on" as const) : undefined, | ||
| 80 | })); | ||
| 81 | |||
| 82 | return { | ||
| 83 | id: `provider-option-menu:${descriptor.id}`, | ||
| 84 | title: descriptor.label, | ||
| 85 | subtitle: | ||
| 86 | descriptor.type === "boolean" | ||
| 87 | ? currentValue | ||
| 88 | ? "On" | ||
| 89 | : "Off" | ||
| 90 | : getProviderOptionCurrentLabel(descriptor), | ||
| 91 | subactions: choices, | ||
| 92 | }; | ||
| 93 | }); | ||
| 94 | } | ||
| 95 | |||
| 96 | export function providerOptionsConfigurationLabel( | ||
| 97 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, | ||
| 98 | ): string { | ||
| 99 | const labels = descriptors.flatMap((descriptor) => { | ||
| 100 | if (descriptor.type === "boolean") { | ||
| 101 | return descriptor.currentValue ? [descriptor.label] : []; | ||
| 102 | } | ||
| 103 | const label = getProviderOptionCurrentLabel(descriptor); | ||
| 104 | return label ? [label] : []; | ||
| 105 | }); | ||
| 106 | return labels.length > 0 ? labels.join(" · ") : "Configuration"; | ||
| 107 | } | ||
| 108 | |||
| 109 | export function applyProviderOptionMenuEvent( | ||
| 110 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, | ||
| 111 | event: string, | ||
| 112 | ): ReadonlyArray<ProviderOptionSelection> | null { | ||
| 113 | const selection = parseProviderOptionEvent(event); | ||
| 114 | if (!selection) { | ||
| 115 | return null; | ||
| 116 | } | ||
| 117 | |||
| 118 | const descriptor = descriptors.find((candidate) => candidate.id === selection.id); | ||
| 119 | if (!descriptor) { | ||
| 120 | return null; | ||
| 121 | } | ||
| 122 | if ( | ||
| 123 | (descriptor.type === "boolean" && typeof selection.value !== "boolean") || | ||
| 124 | (descriptor.type === "select" && | ||
| 125 | (typeof selection.value !== "string" || | ||
| 126 | !descriptor.options.some((option) => option.id === selection.value))) | ||
| 127 | ) { | ||
| 128 | return null; | ||
| 129 | } | ||
| 130 | |||
| 131 | const nextDescriptors = descriptors.map((candidate) => | ||
| 132 | candidate.id === descriptor.id | ||
| 133 | ? { | ||
| 134 | ...candidate, | ||
| 135 | currentValue: selection.value, | ||
| 136 | } | ||
| 137 | : candidate, | ||
| 138 | ) as ReadonlyArray<ProviderOptionDescriptor>; | ||
| 139 | |||
| 140 | return buildProviderOptionSelectionsFromDescriptors(nextDescriptors) ?? []; | ||
| 141 | } | ||
apps/mobile/src/lib/repositoryGroups.test.ts+6-13| ... | @@ -3,15 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -3,15 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 3 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import { groupProjectsByRepository } from "./repositoryGroups"; | 5 | import { groupProjectsByRepository } from "./repositoryGroups"; |
| 6 | import { | 6 | import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 7 | EnvironmentScopedProjectShell, | ||
| 8 | EnvironmentScopedThreadShell, | ||
| 9 | } from "@t3tools/client-runtime"; | ||
| 10 | 7 | ||
| 11 | function makeProject( | 8 | function makeProject( |
| 12 | input: Partial<EnvironmentScopedProjectShell> & | 9 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, |
| 13 | Pick<EnvironmentScopedProjectShell, "environmentId" | "id" | "title">, | 10 | ): EnvironmentProject { |
| 14 | ): EnvironmentScopedProjectShell { | ||
| 15 | return { | 11 | return { |
| 16 | workspaceRoot: `/workspaces/${input.id}`, | 12 | workspaceRoot: `/workspaces/${input.id}`, |
| 17 | repositoryIdentity: null, | 13 | repositoryIdentity: null, |
| ... | @@ -24,12 +20,9 @@ function makeProject( | ... | @@ -24,12 +20,9 @@ function makeProject( |
| 24 | } | 20 | } |
| 25 | 21 | ||
| 26 | function makeThread( | 22 | function makeThread( |
| 27 | input: Partial<EnvironmentScopedThreadShell> & | 23 | input: Partial<EnvironmentThreadShell> & |
| 28 | Pick< | 24 | Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title" | "modelSelection">, |
| 29 | EnvironmentScopedThreadShell, | 25 | ): EnvironmentThreadShell { |
| 30 | "environmentId" | "id" | "projectId" | "title" | "modelSelection" | ||
| 31 | >, | ||
| 32 | ): EnvironmentScopedThreadShell { | ||
| 33 | return { | 26 | return { |
| 34 | runtimeMode: "full-access", | 27 | runtimeMode: "full-access", |
| 35 | interactionMode: "default", | 28 | interactionMode: "default", |
apps/mobile/src/lib/repositoryGroups.ts+16-19| ... | @@ -3,21 +3,18 @@ import * as Arr from "effect/Array"; | ... | @@ -3,21 +3,18 @@ import * as Arr from "effect/Array"; |
| 3 | import type { RepositoryIdentity } from "@t3tools/contracts"; | 3 | import type { RepositoryIdentity } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import { scopedProjectKey } from "./scopedEntities"; | 5 | import { scopedProjectKey } from "./scopedEntities"; |
| 6 | import { | 6 | import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 7 | EnvironmentScopedProjectShell, | ||
| 8 | EnvironmentScopedThreadShell, | ||
| 9 | } from "@t3tools/client-runtime"; | ||
| 10 | 7 | ||
| 11 | const DateDescending = Order.flip(Order.Date); | 8 | const DateDescending = Order.flip(Order.Date); |
| 12 | 9 | ||
| 13 | export interface MobileRepositoryProjectGroup { | 10 | export interface RepositoryProjectGroup { |
| 14 | readonly key: string; | 11 | readonly key: string; |
| 15 | readonly project: EnvironmentScopedProjectShell; | 12 | readonly project: EnvironmentProject; |
| 16 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | 13 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 17 | readonly latestActivityAt: string; | 14 | readonly latestActivityAt: string; |
| 18 | } | 15 | } |
| 19 | 16 | ||
| 20 | export interface MobileRepositoryGroup { | 17 | export interface RepositoryGroup { |
| 21 | readonly key: string; | 18 | readonly key: string; |
| 22 | readonly title: string; | 19 | readonly title: string; |
| 23 | readonly subtitle: string | null; | 20 | readonly subtitle: string | null; |
| ... | @@ -25,20 +22,20 @@ export interface MobileRepositoryGroup { | ... | @@ -25,20 +22,20 @@ export interface MobileRepositoryGroup { |
| 25 | readonly projectCount: number; | 22 | readonly projectCount: number; |
| 26 | readonly threadCount: number; | 23 | readonly threadCount: number; |
| 27 | readonly latestActivityAt: string; | 24 | readonly latestActivityAt: string; |
| 28 | readonly projects: ReadonlyArray<MobileRepositoryProjectGroup>; | 25 | readonly projects: ReadonlyArray<RepositoryProjectGroup>; |
| 29 | } | 26 | } |
| 30 | 27 | ||
| 31 | function compareIsoDateDescending(left: string, right: string): number { | 28 | function compareIsoDateDescending(left: string, right: string): number { |
| 32 | return new Date(right).getTime() - new Date(left).getTime(); | 29 | return new Date(right).getTime() - new Date(left).getTime(); |
| 33 | } | 30 | } |
| 34 | 31 | ||
| 35 | function deriveRepositoryGroupKey(project: EnvironmentScopedProjectShell): string { | 32 | function deriveRepositoryGroupKey(project: EnvironmentProject): string { |
| 36 | return ( | 33 | return ( |
| 37 | project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) | 34 | project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) |
| 38 | ); | 35 | ); |
| 39 | } | 36 | } |
| 40 | 37 | ||
| 41 | function deriveRepositoryTitle(project: EnvironmentScopedProjectShell): string { | 38 | function deriveRepositoryTitle(project: EnvironmentProject): string { |
| 42 | const identity = project.repositoryIdentity; | 39 | const identity = project.repositoryIdentity; |
| 43 | return identity?.displayName ?? identity?.name ?? project.title; | 40 | return identity?.displayName ?? identity?.name ?? project.title; |
| 44 | } | 41 | } |
| ... | @@ -54,18 +51,18 @@ function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefine | ... | @@ -54,18 +51,18 @@ function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefine |
| 54 | } | 51 | } |
| 55 | 52 | ||
| 56 | function deriveProjectLatestActivity( | 53 | function deriveProjectLatestActivity( |
| 57 | project: EnvironmentScopedProjectShell, | 54 | project: EnvironmentProject, |
| 58 | threads: ReadonlyArray<EnvironmentScopedThreadShell>, | 55 | threads: ReadonlyArray<EnvironmentThreadShell>, |
| 59 | ): string { | 56 | ): string { |
| 60 | const latestThread = threads[0]; | 57 | const latestThread = threads[0]; |
| 61 | return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; | 58 | return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; |
| 62 | } | 59 | } |
| 63 | 60 | ||
| 64 | export function groupProjectsByRepository(input: { | 61 | export function groupProjectsByRepository(input: { |
| 65 | readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>; | 62 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 66 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | 63 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 67 | }): ReadonlyArray<MobileRepositoryGroup> { | 64 | }): ReadonlyArray<RepositoryGroup> { |
| 68 | const threadsByProjectKey = new Map<string, EnvironmentScopedThreadShell[]>(); | 65 | const threadsByProjectKey = new Map<string, EnvironmentThreadShell[]>(); |
| 69 | 66 | ||
| 70 | for (const thread of input.threads) { | 67 | for (const thread of input.threads) { |
| 71 | const key = scopedProjectKey(thread.environmentId, thread.projectId); | 68 | const key = scopedProjectKey(thread.environmentId, thread.projectId); |
| ... | @@ -77,7 +74,7 @@ export function groupProjectsByRepository(input: { | ... | @@ -77,7 +74,7 @@ export function groupProjectsByRepository(input: { |
| 77 | } | 74 | } |
| 78 | } | 75 | } |
| 79 | 76 | ||
| 80 | const grouped = new Map<string, MobileRepositoryGroup>(); | 77 | const grouped = new Map<string, RepositoryGroup>(); |
| 81 | 78 | ||
| 82 | for (const project of input.projects) { | 79 | for (const project of input.projects) { |
| 83 | const key = deriveRepositoryGroupKey(project); | 80 | const key = deriveRepositoryGroupKey(project); |
| ... | @@ -89,7 +86,7 @@ export function groupProjectsByRepository(input: { | ... | @@ -89,7 +86,7 @@ export function groupProjectsByRepository(input: { |
| 89 | ); | 86 | ); |
| 90 | 87 | ||
| 91 | const latestActivityAt = deriveProjectLatestActivity(project, threads); | 88 | const latestActivityAt = deriveProjectLatestActivity(project, threads); |
| 92 | const projectGroup: MobileRepositoryProjectGroup = { | 89 | const projectGroup: RepositoryProjectGroup = { |
| 93 | key: projectKey, | 90 | key: projectKey, |
| 94 | project, | 91 | project, |
| 95 | threads, | 92 | threads, |
apps/mobile/src/lib/routes.ts+2-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { Href, useRouter } from "expo-router"; | 1 | import type { Href, useRouter } from "expo-router"; |
| 2 | import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | 2 | import { type EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import type { SelectedThreadRef } from "../state/remote-runtime-types"; | 5 | import type { SelectedThreadRef } from "../state/remote-runtime-types"; |
| ... | @@ -8,7 +8,7 @@ type Router = ReturnType<typeof useRouter>; | ... | @@ -8,7 +8,7 @@ type Router = ReturnType<typeof useRouter>; |
| 8 | 8 | ||
| 9 | type ThreadRouteInput = | 9 | type ThreadRouteInput = |
| 10 | | Pick<SelectedThreadRef, "environmentId" | "threadId"> | 10 | | Pick<SelectedThreadRef, "environmentId" | "threadId"> |
| 11 | | Pick<EnvironmentScopedThreadShell, "environmentId" | "id">; | 11 | | Pick<EnvironmentThreadShell, "environmentId" | "id">; |
| 12 | type PlainThreadRouteInput = | 12 | type PlainThreadRouteInput = |
| 13 | | { | 13 | | { |
| 14 | environmentId: EnvironmentId; | 14 | environmentId: EnvironmentId; |
apps/mobile/src/lib/runtime.ts+16-12| ... | @@ -1,25 +1,29 @@ | ... | @@ -1,25 +1,29 @@ |
| 1 | import * as Layer from "effect/Layer"; | 1 | import * as Layer from "effect/Layer"; |
| 2 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 2 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 3 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 3 | 4 | ||
| 4 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | 5 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; |
| 5 | 6 | ||
| 6 | import { mobileCryptoLayer } from "../features/cloud/dpop"; | 7 | import { cryptoLayer } from "../features/cloud/dpop"; |
| 7 | import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; | 8 | import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; |
| 8 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; | 9 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; |
| 9 | import { mobileTracingLayer } from "../features/observability/mobileTracing"; | 10 | import { tracingLayer } from "../features/observability/tracing"; |
| 10 | 11 | ||
| 11 | function configuredRelayUrl(): string { | 12 | function configuredRelayUrl(): string { |
| 12 | return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; | 13 | return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; |
| 13 | } | 14 | } |
| 14 | 15 | ||
| 15 | const mobileHttpClientLayer = remoteHttpClientLayer(fetch); | 16 | const httpClientLayer = remoteHttpClientLayer(fetch); |
| 16 | 17 | ||
| 17 | export const mobileRuntime = ManagedRuntime.make( | 18 | export const runtimeLayer = Layer.merge( |
| 18 | mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( | 19 | managedRelayClientLayer(configuredRelayUrl()), |
| 19 | Layer.provideMerge(mobileCryptoLayer), | 20 | Socket.layerWebSocketConstructorGlobal, |
| 20 | Layer.provideMerge(mobileHttpClientLayer), | 21 | ).pipe( |
| 21 | Layer.provideMerge(mobileTracingLayer.pipe(Layer.provide(mobileHttpClientLayer))), | 22 | Layer.provideMerge(cryptoLayer), |
| 22 | ), | 23 | Layer.provideMerge(httpClientLayer), |
| 24 | Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), | ||
| 23 | ); | 25 | ); |
| 24 | 26 | ||
| 25 | export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect); | 27 | export const runtime = ManagedRuntime.make(runtimeLayer); |
| 28 | |||
| 29 | export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); |
apps/mobile/src/lib/storage.test.ts+1-1| ... | @@ -25,7 +25,7 @@ vi.mock("react-native", () => ({ | ... | @@ -25,7 +25,7 @@ vi.mock("react-native", () => ({ |
| 25 | })); | 25 | })); |
| 26 | 26 | ||
| 27 | vi.mock("./runtime", () => ({ | 27 | vi.mock("./runtime", () => ({ |
| 28 | mobileRuntime: { | 28 | runtime: { |
| 29 | runPromise: vi.fn(), | 29 | runPromise: vi.fn(), |
| 30 | }, | 30 | }, |
| 31 | })); | 31 | })); |
apps/mobile/src/lib/storage.ts+6-98| ... | @@ -1,9 +1,7 @@ | ... | @@ -1,9 +1,7 @@ |
| 1 | import * as Arr from "effect/Array"; | 1 | import * as Arr from "effect/Array"; |
| 2 | import { pipe } from "effect/Function"; | 2 | import { pipe } from "effect/Function"; |
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import * as SecureStore from "expo-secure-store"; | 3 | import * as SecureStore from "expo-secure-store"; |
| 6 | import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; | 4 | import { EnvironmentId } from "@t3tools/contracts"; |
| 7 | 5 | ||
| 8 | import { | 6 | import { |
| 9 | isRelayManagedConnection, | 7 | isRelayManagedConnection, |
| ... | @@ -14,29 +12,12 @@ import { | ... | @@ -14,29 +12,12 @@ import { |
| 14 | const CONNECTIONS_KEY = "t3code.connections"; | 12 | const CONNECTIONS_KEY = "t3code.connections"; |
| 15 | const PREFERENCES_KEY = "t3code.preferences"; | 13 | const PREFERENCES_KEY = "t3code.preferences"; |
| 16 | const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; | 14 | const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; |
| 17 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | ||
| 18 | const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; | ||
| 19 | |||
| 20 | export interface CachedShellSnapshot { | ||
| 21 | readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION; | ||
| 22 | readonly environmentId: EnvironmentId; | ||
| 23 | readonly snapshotReceivedAt: string; | ||
| 24 | readonly snapshot: OrchestrationShellSnapshot; | ||
| 25 | } | ||
| 26 | 15 | ||
| 27 | export interface MobilePreferences { | 16 | export interface Preferences { |
| 28 | readonly liveActivitiesEnabled?: boolean; | 17 | readonly liveActivitiesEnabled?: boolean; |
| 29 | readonly terminalFontSize?: number; | 18 | readonly terminalFontSize?: number; |
| 30 | } | 19 | } |
| 31 | 20 | ||
| 32 | const CachedShellSnapshotSchema = Schema.Struct({ | ||
| 33 | schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), | ||
| 34 | environmentId: EnvironmentId, | ||
| 35 | snapshotReceivedAt: Schema.String, | ||
| 36 | snapshot: OrchestrationShellSnapshot, | ||
| 37 | }); | ||
| 38 | const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema); | ||
| 39 | |||
| 40 | async function readStorageItem(key: string): Promise<string | null> { | 21 | async function readStorageItem(key: string): Promise<string | null> { |
| 41 | return await SecureStore.getItemAsync(key); | 22 | return await SecureStore.getItemAsync(key); |
| 42 | } | 23 | } |
| ... | @@ -58,77 +39,6 @@ async function readJsonStorageItem<T>(key: string): Promise<T | null> { | ... | @@ -58,77 +39,6 @@ async function readJsonStorageItem<T>(key: string): Promise<T | null> { |
| 58 | } | 39 | } |
| 59 | } | 40 | } |
| 60 | 41 | ||
| 61 | function cachedShellSnapshotFileName(environmentId: EnvironmentId): string { | ||
| 62 | return `${encodeURIComponent(environmentId)}.json`; | ||
| 63 | } | ||
| 64 | |||
| 65 | async function getShellSnapshotCacheDirectory() { | ||
| 66 | const { Directory, Paths } = await import("expo-file-system"); | ||
| 67 | const directory = new Directory(Paths.document, SHELL_SNAPSHOT_CACHE_DIRECTORY); | ||
| 68 | directory.create({ idempotent: true, intermediates: true }); | ||
| 69 | return directory; | ||
| 70 | } | ||
| 71 | |||
| 72 | export async function loadCachedShellSnapshot( | ||
| 73 | environmentId: EnvironmentId, | ||
| 74 | ): Promise<CachedShellSnapshot | null> { | ||
| 75 | try { | ||
| 76 | const { File } = await import("expo-file-system"); | ||
| 77 | const directory = await getShellSnapshotCacheDirectory(); | ||
| 78 | const file = new File(directory, cachedShellSnapshotFileName(environmentId)); | ||
| 79 | if (!file.exists) { | ||
| 80 | return null; | ||
| 81 | } | ||
| 82 | |||
| 83 | const parsed = JSON.parse(await file.text()) as unknown; | ||
| 84 | const decoded = decodeCachedShellSnapshot(parsed); | ||
| 85 | if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) { | ||
| 86 | return null; | ||
| 87 | } | ||
| 88 | |||
| 89 | return decoded.value; | ||
| 90 | } catch { | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | export async function saveCachedShellSnapshot( | ||
| 96 | environmentId: EnvironmentId, | ||
| 97 | snapshot: OrchestrationShellSnapshot, | ||
| 98 | ): Promise<void> { | ||
| 99 | try { | ||
| 100 | const { File } = await import("expo-file-system"); | ||
| 101 | const directory = await getShellSnapshotCacheDirectory(); | ||
| 102 | const file = new File(directory, cachedShellSnapshotFileName(environmentId)); | ||
| 103 | const document: CachedShellSnapshot = { | ||
| 104 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | ||
| 105 | environmentId, | ||
| 106 | snapshotReceivedAt: new Date().toISOString(), | ||
| 107 | snapshot, | ||
| 108 | }; | ||
| 109 | |||
| 110 | if (!file.exists) { | ||
| 111 | file.create({ intermediates: true, overwrite: true }); | ||
| 112 | } | ||
| 113 | file.write(JSON.stringify(document)); | ||
| 114 | } catch { | ||
| 115 | // Cache persistence is best-effort and should never block live data. | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | export async function clearCachedShellSnapshot(environmentId: EnvironmentId): Promise<void> { | ||
| 120 | try { | ||
| 121 | const { File } = await import("expo-file-system"); | ||
| 122 | const directory = await getShellSnapshotCacheDirectory(); | ||
| 123 | const file = new File(directory, cachedShellSnapshotFileName(environmentId)); | ||
| 124 | if (file.exists) { | ||
| 125 | file.delete(); | ||
| 126 | } | ||
| 127 | } catch { | ||
| 128 | // Ignore cache cleanup failures. | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> { | 42 | export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> { |
| 133 | const parsed = await readJsonStorageItem<{ | 43 | const parsed = await readJsonStorageItem<{ |
| 134 | readonly connections?: ReadonlyArray<SavedRemoteConnection>; | 44 | readonly connections?: ReadonlyArray<SavedRemoteConnection>; |
| ... | @@ -169,8 +79,8 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis | ... | @@ -169,8 +79,8 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis |
| 169 | await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); | 79 | await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); |
| 170 | } | 80 | } |
| 171 | 81 | ||
| 172 | export async function loadPreferences(): Promise<MobilePreferences> { | 82 | export async function loadPreferences(): Promise<Preferences> { |
| 173 | const parsed = await readJsonStorageItem<MobilePreferences>(PREFERENCES_KEY); | 83 | const parsed = await readJsonStorageItem<Preferences>(PREFERENCES_KEY); |
| 174 | if (!parsed || typeof parsed !== "object") { | 84 | if (!parsed || typeof parsed !== "object") { |
| 175 | return {}; | 85 | return {}; |
| 176 | } | 86 | } |
| ... | @@ -190,11 +100,9 @@ export async function loadPreferences(): Promise<MobilePreferences> { | ... | @@ -190,11 +100,9 @@ export async function loadPreferences(): Promise<MobilePreferences> { |
| 190 | return preferences; | 100 | return preferences; |
| 191 | } | 101 | } |
| 192 | 102 | ||
| 193 | export async function savePreferencesPatch( | 103 | export async function savePreferencesPatch(patch: Partial<Preferences>): Promise<Preferences> { |
| 194 | patch: Partial<MobilePreferences>, | ||
| 195 | ): Promise<MobilePreferences> { | ||
| 196 | const current = await loadPreferences(); | 104 | const current = await loadPreferences(); |
| 197 | const next: MobilePreferences = { | 105 | const next: Preferences = { |
| 198 | ...current, | 106 | ...current, |
| 199 | ...patch, | 107 | ...patch, |
| 200 | }; | 108 | }; |
apps/mobile/src/lib/threadActivity.ts+1-14| ... | @@ -1,19 +1,16 @@ | ... | @@ -1,19 +1,16 @@ |
| 1 | import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; | 1 | import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; |
| 2 | import type { | 2 | import type { |
| 3 | CommandId, | ||
| 4 | EnvironmentId, | ||
| 5 | MessageId, | 3 | MessageId, |
| 6 | OrchestrationLatestTurn, | 4 | OrchestrationLatestTurn, |
| 7 | OrchestrationThread, | 5 | OrchestrationThread, |
| 8 | OrchestrationThreadActivity, | 6 | OrchestrationThreadActivity, |
| 9 | ToolLifecycleItemType, | 7 | ToolLifecycleItemType, |
| 10 | ThreadId, | ||
| 11 | TurnId, | 8 | TurnId, |
| 12 | UserInputQuestion, | 9 | UserInputQuestion, |
| 13 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 14 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | 11 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 15 | 12 | ||
| 16 | import type { DraftComposerImageAttachment } from "./composerImages"; | 13 | import type { QueuedThreadMessage } from "../state/thread-outbox-model"; |
| 17 | import * as Arr from "effect/Array"; | 14 | import * as Arr from "effect/Array"; |
| 18 | import * as Order from "effect/Order"; | 15 | import * as Order from "effect/Order"; |
| 19 | 16 | ||
| ... | @@ -35,16 +32,6 @@ export interface PendingUserInputDraftAnswer { | ... | @@ -35,16 +32,6 @@ export interface PendingUserInputDraftAnswer { |
| 35 | readonly customAnswer?: string; | 32 | readonly customAnswer?: string; |
| 36 | } | 33 | } |
| 37 | 34 | ||
| 38 | export interface QueuedThreadMessage { | ||
| 39 | readonly environmentId: EnvironmentId; | ||
| 40 | readonly threadId: ThreadId; | ||
| 41 | readonly messageId: MessageId; | ||
| 42 | readonly commandId: CommandId; | ||
| 43 | readonly text: string; | ||
| 44 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 45 | readonly createdAt: string; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface ThreadFeedActivity { | 35 | export interface ThreadFeedActivity { |
| 49 | readonly id: string; | 36 | readonly id: string; |
| 50 | readonly createdAt: string; | 37 | readonly createdAt: string; |
apps/mobile/src/state/assets.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | ||
| 3 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 5 | |||
| 6 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 7 | import { usePreparedConnection } from "./session"; | ||
| 8 | |||
| 9 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); | ||
| 10 | |||
| 11 | const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | ||
| 12 | Atom.withLabel("mobile-asset-url:empty"), | ||
| 13 | ); | ||
| 14 | |||
| 15 | export function useAssetUrl( | ||
| 16 | environmentId: EnvironmentId | null, | ||
| 17 | resource: AssetResource | null, | ||
| 18 | ): string | null { | ||
| 19 | const preparedConnection = usePreparedConnection(environmentId); | ||
| 20 | const result = useAtomValue( | ||
| 21 | environmentId === null || resource === null | ||
| 22 | ? EMPTY_ASSET_URL_ATOM | ||
| 23 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), | ||
| 24 | ); | ||
| 25 | if (preparedConnection._tag === "None" || result._tag !== "Success") { | ||
| 26 | return null; | ||
| 27 | } | ||
| 28 | return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); | ||
| 29 | } | ||
apps/mobile/src/state/auth.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); | ||
apps/mobile/src/state/entities.ts created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { | ||
| 3 | EnvironmentProject, | ||
| 4 | EnvironmentThreadShell, | ||
| 5 | } from "@t3tools/client-runtime/state/shell"; | ||
| 6 | import type { | ||
| 7 | EnvironmentId, | ||
| 8 | ScopedProjectRef, | ||
| 9 | ScopedThreadRef, | ||
| 10 | ServerConfig, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import { Atom } from "effect/unstable/reactivity"; | ||
| 13 | |||
| 14 | import { environmentProjects } from "./projects"; | ||
| 15 | import { environmentServerConfigsAtom } from "./server"; | ||
| 16 | import { environmentSession } from "./session"; | ||
| 17 | import { environmentThreadShells } from "./threads"; | ||
| 18 | |||
| 19 | const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe( | ||
| 20 | Atom.withLabel("mobile-project:empty"), | ||
| 21 | ); | ||
| 22 | const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).pipe( | ||
| 23 | Atom.withLabel("mobile-thread-shell:empty"), | ||
| 24 | ); | ||
| 25 | const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe( | ||
| 26 | Atom.withLabel("mobile-server-config:empty"), | ||
| 27 | ); | ||
| 28 | |||
| 29 | export function useProjects(): ReadonlyArray<EnvironmentProject> { | ||
| 30 | return useAtomValue(environmentProjects.projectsAtom); | ||
| 31 | } | ||
| 32 | |||
| 33 | export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> { | ||
| 34 | return useAtomValue(environmentThreadShells.threadShellsAtom); | ||
| 35 | } | ||
| 36 | |||
| 37 | export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { | ||
| 38 | return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); | ||
| 39 | } | ||
| 40 | |||
| 41 | export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null { | ||
| 42 | return useAtomValue( | ||
| 43 | ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref), | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | export function useEnvironmentServerConfig( | ||
| 48 | environmentId: EnvironmentId | null, | ||
| 49 | ): ServerConfig | null { | ||
| 50 | return useAtomValue( | ||
| 51 | environmentId === null | ||
| 52 | ? EMPTY_SERVER_CONFIG_ATOM | ||
| 53 | : environmentSession.configValueAtom(environmentId), | ||
| 54 | ); | ||
| 55 | } | ||
| 56 | |||
| 57 | export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> { | ||
| 58 | return useAtomValue(environmentServerConfigsAtom); | ||
| 59 | } | ||
apps/mobile/src/state/environment-session-registry.ts deleted-48| ... | @@ -1,48 +0,0 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import type { EnvironmentSession } from "./remote-runtime-types"; | ||
| 4 | |||
| 5 | const environmentSessions = new Map<EnvironmentId, EnvironmentSession>(); | ||
| 6 | const environmentConnectionListeners = new Set<() => void>(); | ||
| 7 | |||
| 8 | export function getEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { | ||
| 9 | return environmentSessions.get(environmentId) ?? null; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function getEnvironmentClient(environmentId: EnvironmentId) { | ||
| 13 | return getEnvironmentSession(environmentId)?.client ?? null; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function setEnvironmentSession( | ||
| 17 | environmentId: EnvironmentId, | ||
| 18 | session: EnvironmentSession, | ||
| 19 | ): void { | ||
| 20 | environmentSessions.set(environmentId, session); | ||
| 21 | } | ||
| 22 | |||
| 23 | export function removeEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { | ||
| 24 | const session = getEnvironmentSession(environmentId); | ||
| 25 | environmentSessions.delete(environmentId); | ||
| 26 | return session; | ||
| 27 | } | ||
| 28 | |||
| 29 | export function drainEnvironmentSessions(): ReadonlyArray<EnvironmentSession> { | ||
| 30 | const sessions = [...environmentSessions.values()]; | ||
| 31 | environmentSessions.clear(); | ||
| 32 | return sessions; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function notifyEnvironmentConnectionListeners() { | ||
| 36 | for (const listener of environmentConnectionListeners) listener(); | ||
| 37 | } | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Subscribe to environment-connection changes (connect / disconnect / reconnect). | ||
| 41 | * Returns an unsubscribe function. | ||
| 42 | */ | ||
| 43 | export function subscribeEnvironmentConnections(listener: () => void): () => void { | ||
| 44 | environmentConnectionListeners.add(listener); | ||
| 45 | return () => { | ||
| 46 | environmentConnectionListeners.delete(listener); | ||
| 47 | }; | ||
| 48 | } | ||
apps/mobile/src/state/environments.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | connectionCatalogDisplayUrl, | ||
| 4 | type EnvironmentPresentation as BaseEnvironmentPresentation, | ||
| 5 | } from "@t3tools/client-runtime/connection"; | ||
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 7 | import { useMemo } from "react"; | ||
| 8 | |||
| 9 | import { environmentCatalog } from "../connection/catalog"; | ||
| 10 | import { environmentPresentations } from "./presentation"; | ||
| 11 | import { useEnvironmentQuery } from "./query"; | ||
| 12 | |||
| 13 | export interface EnvironmentPresentation extends BaseEnvironmentPresentation { | ||
| 14 | readonly environmentId: EnvironmentId; | ||
| 15 | readonly label: string; | ||
| 16 | readonly displayUrl: string | null; | ||
| 17 | readonly relayManaged: boolean; | ||
| 18 | } | ||
| 19 | |||
| 20 | export function projectEnvironmentPresentation( | ||
| 21 | environmentId: EnvironmentId, | ||
| 22 | presentation: BaseEnvironmentPresentation, | ||
| 23 | ): EnvironmentPresentation { | ||
| 24 | return { | ||
| 25 | ...presentation, | ||
| 26 | environmentId, | ||
| 27 | label: presentation.entry.target.label, | ||
| 28 | displayUrl: connectionCatalogDisplayUrl(presentation.entry), | ||
| 29 | relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function useEnvironments() { | ||
| 34 | const catalog = useAtomValue(environmentCatalog.catalogValueAtom); | ||
| 35 | const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); | ||
| 36 | const presentationById = useAtomValue(environmentPresentations.presentationsAtom); | ||
| 37 | |||
| 38 | const environments = useMemo( | ||
| 39 | () => | ||
| 40 | [...presentationById.entries()].map(([environmentId, presentation]) => | ||
| 41 | projectEnvironmentPresentation(environmentId, presentation), | ||
| 42 | ), | ||
| 43 | [presentationById], | ||
| 44 | ); | ||
| 45 | |||
| 46 | return { | ||
| 47 | isReady: catalog.isReady, | ||
| 48 | networkStatus, | ||
| 49 | environments, | ||
| 50 | presentationById, | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | export function useEnvironmentConnectionState(environmentId: EnvironmentId) { | ||
| 55 | return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); | ||
| 56 | } | ||
apps/mobile/src/state/filesystem.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); | ||
apps/mobile/src/state/git.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); | ||
apps/mobile/src/state/orchestration.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); | ||
apps/mobile/src/state/presentation.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; | ||
| 3 | import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; | ||
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 5 | import { Atom } from "effect/unstable/reactivity"; | ||
| 6 | |||
| 7 | import { environmentCatalog } from "../connection/catalog"; | ||
| 8 | import { environmentSession } from "./session"; | ||
| 9 | |||
| 10 | export const environmentPresentations = createEnvironmentPresentationAtoms({ | ||
| 11 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 12 | stateAtom: environmentCatalog.stateAtom, | ||
| 13 | configValueAtom: environmentSession.configValueAtom, | ||
| 14 | }); | ||
| 15 | |||
| 16 | const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make<EnvironmentPresentation | null>(null).pipe( | ||
| 17 | Atom.withLabel("mobile-environment-presentation:empty"), | ||
| 18 | ); | ||
| 19 | |||
| 20 | export function useEnvironmentPresentation(environmentId: EnvironmentId | null) { | ||
| 21 | const catalog = useAtomValue(environmentCatalog.catalogValueAtom); | ||
| 22 | const presentation = useAtomValue( | ||
| 23 | environmentId === null | ||
| 24 | ? EMPTY_ENVIRONMENT_PRESENTATION_ATOM | ||
| 25 | : environmentPresentations.presentationAtom(environmentId), | ||
| 26 | ); | ||
| 27 | return { | ||
| 28 | isReady: catalog.isReady, | ||
| 29 | presentation, | ||
| 30 | }; | ||
| 31 | } | ||
apps/mobile/src/state/projects.ts created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; | ||
| 2 | import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; | ||
| 3 | |||
| 4 | import { environmentCatalog } from "../connection/catalog"; | ||
| 5 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 6 | import { environmentSnapshotAtom } from "./shell"; | ||
| 7 | |||
| 8 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); | ||
| 9 | export const environmentProjects = createEnvironmentProjectAtoms({ | ||
| 10 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 11 | snapshotAtom: environmentSnapshotAtom, | ||
| 12 | }); | ||
apps/mobile/src/state/queries.test.ts created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { buildCheckpointDiffTargets, normalizeComposerPathSearchQuery } from "./queryTargets"; | ||
| 5 | |||
| 6 | describe("appQueries", () => { | ||
| 7 | it("normalizes composer path search input", () => { | ||
| 8 | expect(normalizeComposerPathSearchQuery(" src/app ")).toBe("src/app"); | ||
| 9 | expect(normalizeComposerPathSearchQuery(null)).toBe(""); | ||
| 10 | }); | ||
| 11 | |||
| 12 | it("routes the first turn range through the full-thread diff query", () => { | ||
| 13 | const environmentId = EnvironmentId.make("environment-a"); | ||
| 14 | const threadId = ThreadId.make("thread-a"); | ||
| 15 | |||
| 16 | expect( | ||
| 17 | buildCheckpointDiffTargets({ | ||
| 18 | environmentId, | ||
| 19 | threadId, | ||
| 20 | fromTurnCount: 0, | ||
| 21 | toTurnCount: 4, | ||
| 22 | ignoreWhitespace: true, | ||
| 23 | }), | ||
| 24 | ).toEqual({ | ||
| 25 | fullThread: { | ||
| 26 | environmentId, | ||
| 27 | input: { | ||
| 28 | threadId, | ||
| 29 | toTurnCount: 4, | ||
| 30 | ignoreWhitespace: true, | ||
| 31 | }, | ||
| 32 | }, | ||
| 33 | turn: null, | ||
| 34 | }); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("routes later ranges through the incremental turn diff query", () => { | ||
| 38 | const environmentId = EnvironmentId.make("environment-a"); | ||
| 39 | const threadId = ThreadId.make("thread-a"); | ||
| 40 | |||
| 41 | expect( | ||
| 42 | buildCheckpointDiffTargets({ | ||
| 43 | environmentId, | ||
| 44 | threadId, | ||
| 45 | fromTurnCount: 3, | ||
| 46 | toTurnCount: 4, | ||
| 47 | ignoreWhitespace: false, | ||
| 48 | }), | ||
| 49 | ).toEqual({ | ||
| 50 | fullThread: null, | ||
| 51 | turn: { | ||
| 52 | environmentId, | ||
| 53 | input: { | ||
| 54 | threadId, | ||
| 55 | fromTurnCount: 3, | ||
| 56 | toTurnCount: 4, | ||
| 57 | ignoreWhitespace: false, | ||
| 58 | }, | ||
| 59 | }, | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | }); | ||
apps/mobile/src/state/queries.ts created+134| ... | @@ -0,0 +1,134 @@ | ||
| 1 | import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import { useEffect, useMemo, useState } from "react"; | ||
| 4 | |||
| 5 | import { orchestrationEnvironment } from "./orchestration"; | ||
| 6 | import { projectEnvironment } from "./projects"; | ||
| 7 | import { useEnvironmentQuery } from "./query"; | ||
| 8 | import { useEnvironmentThread } from "./threads"; | ||
| 9 | import { vcsEnvironment } from "./vcs"; | ||
| 10 | import { | ||
| 11 | buildCheckpointDiffTargets, | ||
| 12 | normalizeComposerPathSearchQuery, | ||
| 13 | type CheckpointDiffTarget, | ||
| 14 | } from "./queryTargets"; | ||
| 15 | |||
| 16 | const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; | ||
| 17 | const COMPOSER_PATH_SEARCH_LIMIT = 20; | ||
| 18 | const VCS_REF_LIST_LIMIT = 100; | ||
| 19 | |||
| 20 | export interface ThreadDetailView { | ||
| 21 | readonly data: OrchestrationThread | null; | ||
| 22 | readonly error: string | null; | ||
| 23 | readonly isPending: boolean; | ||
| 24 | readonly isDeleted: boolean; | ||
| 25 | } | ||
| 26 | |||
| 27 | export interface ComposerPathSearchTarget { | ||
| 28 | readonly environmentId: EnvironmentId | null; | ||
| 29 | readonly cwd: string | null; | ||
| 30 | readonly query: string | null; | ||
| 31 | } | ||
| 32 | |||
| 33 | function useDebouncedValue<A>(value: A, delayMs: number): A { | ||
| 34 | const [debounced, setDebounced] = useState(value); | ||
| 35 | |||
| 36 | useEffect(() => { | ||
| 37 | const timer = setTimeout(() => { | ||
| 38 | setDebounced(value); | ||
| 39 | }, delayMs); | ||
| 40 | return () => { | ||
| 41 | clearTimeout(timer); | ||
| 42 | }; | ||
| 43 | }, [delayMs, value]); | ||
| 44 | |||
| 45 | return debounced; | ||
| 46 | } | ||
| 47 | |||
| 48 | export function useThreadDetail( | ||
| 49 | environmentId: EnvironmentId | null, | ||
| 50 | threadId: ThreadId | null, | ||
| 51 | ): ThreadDetailView { | ||
| 52 | const state = useEnvironmentThread(environmentId, threadId); | ||
| 53 | return { | ||
| 54 | data: Option.getOrNull(state.data), | ||
| 55 | error: Option.getOrNull(state.error), | ||
| 56 | isPending: state.status === "synchronizing", | ||
| 57 | isDeleted: state.status === "deleted", | ||
| 58 | }; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function useBranches(input: { | ||
| 62 | readonly environmentId: EnvironmentId | null; | ||
| 63 | readonly cwd: string | null; | ||
| 64 | readonly query?: string | null; | ||
| 65 | }) { | ||
| 66 | const query = input.query?.trim() ?? ""; | ||
| 67 | return useEnvironmentQuery( | ||
| 68 | input.environmentId !== null && input.cwd !== null | ||
| 69 | ? vcsEnvironment.listRefs({ | ||
| 70 | environmentId: input.environmentId, | ||
| 71 | input: { | ||
| 72 | cwd: input.cwd, | ||
| 73 | ...(query.length > 0 ? { query } : {}), | ||
| 74 | limit: VCS_REF_LIST_LIMIT, | ||
| 75 | }, | ||
| 76 | }) | ||
| 77 | : null, | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | |||
| 81 | export function useComposerPathSearch(target: ComposerPathSearchTarget) { | ||
| 82 | const normalizedTarget = useMemo( | ||
| 83 | () => ({ | ||
| 84 | environmentId: target.environmentId, | ||
| 85 | cwd: target.cwd, | ||
| 86 | query: normalizeComposerPathSearchQuery(target.query), | ||
| 87 | }), | ||
| 88 | [target.cwd, target.environmentId, target.query], | ||
| 89 | ); | ||
| 90 | const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); | ||
| 91 | const result = useEnvironmentQuery( | ||
| 92 | debouncedTarget.environmentId !== null && | ||
| 93 | debouncedTarget.cwd !== null && | ||
| 94 | debouncedTarget.query.length > 0 | ||
| 95 | ? projectEnvironment.searchEntries({ | ||
| 96 | environmentId: debouncedTarget.environmentId, | ||
| 97 | input: { | ||
| 98 | cwd: debouncedTarget.cwd, | ||
| 99 | query: debouncedTarget.query, | ||
| 100 | limit: COMPOSER_PATH_SEARCH_LIMIT, | ||
| 101 | }, | ||
| 102 | }) | ||
| 103 | : null, | ||
| 104 | ); | ||
| 105 | |||
| 106 | return { | ||
| 107 | entries: result.data?.entries ?? [], | ||
| 108 | error: result.error, | ||
| 109 | isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, | ||
| 110 | refresh: result.refresh, | ||
| 111 | }; | ||
| 112 | } | ||
| 113 | |||
| 114 | export function useCheckpointDiff(target: CheckpointDiffTarget) { | ||
| 115 | const targets = useMemo( | ||
| 116 | () => buildCheckpointDiffTargets(target), | ||
| 117 | [ | ||
| 118 | target.environmentId, | ||
| 119 | target.fromTurnCount, | ||
| 120 | target.ignoreWhitespace, | ||
| 121 | target.threadId, | ||
| 122 | target.toTurnCount, | ||
| 123 | ], | ||
| 124 | ); | ||
| 125 | const fullThread = useEnvironmentQuery( | ||
| 126 | targets.fullThread === null | ||
| 127 | ? null | ||
| 128 | : orchestrationEnvironment.fullThreadDiff(targets.fullThread), | ||
| 129 | ); | ||
| 130 | const turn = useEnvironmentQuery( | ||
| 131 | targets.turn === null ? null : orchestrationEnvironment.turnDiff(targets.turn), | ||
| 132 | ); | ||
| 133 | return targets.fullThread === null ? turn : fullThread; | ||
| 134 | } | ||
apps/mobile/src/state/query.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 5 | |||
| 6 | const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | ||
| 7 | Atom.withLabel("mobile-environment-query:empty"), | ||
| 8 | ); | ||
| 9 | |||
| 10 | export interface EnvironmentQueryView<A> { | ||
| 11 | readonly data: A | null; | ||
| 12 | readonly error: string | null; | ||
| 13 | readonly isPending: boolean; | ||
| 14 | readonly refresh: () => void; | ||
| 15 | } | ||
| 16 | |||
| 17 | function formatError(cause: Cause.Cause<unknown>): string { | ||
| 18 | const error = Cause.squash(cause); | ||
| 19 | return error instanceof Error && error.message.trim().length > 0 | ||
| 20 | ? error.message | ||
| 21 | : "The environment request failed."; | ||
| 22 | } | ||
| 23 | |||
| 24 | export function useEnvironmentQuery<A, E>( | ||
| 25 | atom: Atom.Atom<AsyncResult.AsyncResult<A, E>> | null, | ||
| 26 | ): EnvironmentQueryView<A> { | ||
| 27 | const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; | ||
| 28 | const result = useAtomValue(selectedAtom); | ||
| 29 | const refresh = useAtomRefresh(selectedAtom); | ||
| 30 | return { | ||
| 31 | data: Option.getOrNull(AsyncResult.value(result)), | ||
| 32 | error: result._tag === "Failure" ? formatError(result.cause) : null, | ||
| 33 | isPending: atom !== null && result.waiting, | ||
| 34 | refresh, | ||
| 35 | }; | ||
| 36 | } | ||
apps/mobile/src/state/queryTargets.ts created+51| ... | @@ -0,0 +1,51 @@ | ||
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface CheckpointDiffTarget { | ||
| 4 | readonly environmentId: EnvironmentId | null; | ||
| 5 | readonly threadId: ThreadId | null; | ||
| 6 | readonly fromTurnCount: number | null; | ||
| 7 | readonly toTurnCount: number | null; | ||
| 8 | readonly ignoreWhitespace: boolean; | ||
| 9 | } | ||
| 10 | |||
| 11 | export function normalizeComposerPathSearchQuery(query: string | null): string { | ||
| 12 | return query?.trim() ?? ""; | ||
| 13 | } | ||
| 14 | |||
| 15 | export function buildCheckpointDiffTargets(target: CheckpointDiffTarget) { | ||
| 16 | if ( | ||
| 17 | target.environmentId === null || | ||
| 18 | target.threadId === null || | ||
| 19 | target.fromTurnCount === null || | ||
| 20 | target.toTurnCount === null | ||
| 21 | ) { | ||
| 22 | return { fullThread: null, turn: null } as const; | ||
| 23 | } | ||
| 24 | |||
| 25 | if (target.fromTurnCount === 0) { | ||
| 26 | return { | ||
| 27 | fullThread: { | ||
| 28 | environmentId: target.environmentId, | ||
| 29 | input: { | ||
| 30 | threadId: target.threadId, | ||
| 31 | toTurnCount: target.toTurnCount, | ||
| 32 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | turn: null, | ||
| 36 | } as const; | ||
| 37 | } | ||
| 38 | |||
| 39 | return { | ||
| 40 | fullThread: null, | ||
| 41 | turn: { | ||
| 42 | environmentId: target.environmentId, | ||
| 43 | input: { | ||
| 44 | threadId: target.threadId, | ||
| 45 | fromTurnCount: target.fromTurnCount, | ||
| 46 | toTurnCount: target.toTurnCount, | ||
| 47 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 48 | }, | ||
| 49 | }, | ||
| 50 | } as const; | ||
| 51 | } | ||
apps/mobile/src/state/relay.ts created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const relayEnvironmentDiscovery = | ||
| 6 | createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); | ||
apps/mobile/src/state/remote-runtime-types.ts+10-13| ... | @@ -1,27 +1,24 @@ | ... | @@ -1,27 +1,24 @@ |
| 1 | import type { | 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | EnvironmentConnection, | 2 | import { EnvironmentId, ThreadId, type ServerConfig } from "@t3tools/contracts"; |
| 3 | EnvironmentConnectionState, | ||
| 4 | WsRpcClient, | ||
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 7 | 3 | ||
| 8 | export type { EnvironmentRuntimeState } from "@t3tools/client-runtime"; | 4 | export interface EnvironmentRuntimeState { |
| 5 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 6 | readonly connectionError: string | null; | ||
| 7 | readonly connectionErrorTraceId: string | null; | ||
| 8 | readonly serverConfig: ServerConfig | null; | ||
| 9 | } | ||
| 9 | 10 | ||
| 10 | export interface ConnectedEnvironmentSummary { | 11 | export interface ConnectedEnvironmentSummary { |
| 11 | readonly environmentId: EnvironmentId; | 12 | readonly environmentId: EnvironmentId; |
| 12 | readonly environmentLabel: string; | 13 | readonly environmentLabel: string; |
| 13 | readonly displayUrl: string; | 14 | readonly displayUrl: string; |
| 14 | readonly isRelayManaged: boolean; | 15 | readonly isRelayManaged: boolean; |
| 15 | readonly connectionState: EnvironmentConnectionState; | 16 | readonly connectionState: EnvironmentConnectionPhase; |
| 16 | readonly connectionError: string | null; | 17 | readonly connectionError: string | null; |
| 18 | readonly connectionErrorTraceId: string | null; | ||
| 17 | } | 19 | } |
| 18 | 20 | ||
| 19 | export interface SelectedThreadRef { | 21 | export interface SelectedThreadRef { |
| 20 | readonly environmentId: EnvironmentId; | 22 | readonly environmentId: EnvironmentId; |
| 21 | readonly threadId: ThreadId; | 23 | readonly threadId: ThreadId; |
| 22 | } | 24 | } |
| 23 | |||
| 24 | export interface EnvironmentSession { | ||
| 25 | readonly client: WsRpcClient; | ||
| 26 | readonly connection: EnvironmentConnection; | ||
| 27 | } |
apps/mobile/src/state/review.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); | ||
apps/mobile/src/state/server.ts created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; | ||
| 2 | import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; | ||
| 3 | |||
| 4 | import { environmentCatalog } from "../connection/catalog"; | ||
| 5 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 6 | import { environmentSession } from "./session"; | ||
| 7 | |||
| 8 | export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { | ||
| 9 | initialConfigValueAtom: environmentSession.configValueAtom, | ||
| 10 | }); | ||
| 11 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ | ||
| 12 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 13 | configValueAtom: serverEnvironment.configValueAtom, | ||
| 14 | }); | ||
apps/mobile/src/state/session.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { Atom } from "effect/unstable/reactivity"; | ||
| 6 | |||
| 7 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 8 | |||
| 9 | export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); | ||
| 10 | |||
| 11 | const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( | ||
| 12 | Atom.withLabel("mobile-prepared-connection:empty"), | ||
| 13 | ); | ||
| 14 | |||
| 15 | export function usePreparedConnection(environmentId: EnvironmentId | null) { | ||
| 16 | return useAtomValue( | ||
| 17 | environmentId === null | ||
| 18 | ? EMPTY_PREPARED_CONNECTION_ATOM | ||
| 19 | : environmentSession.preparedConnectionValueAtom(environmentId), | ||
| 20 | ); | ||
| 21 | } | ||
apps/mobile/src/state/shell.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | import { | ||
| 2 | createEnvironmentShellAtoms, | ||
| 3 | createEnvironmentShellSummaryAtom, | ||
| 4 | createEnvironmentSnapshotAtom, | ||
| 5 | createShellEnvironmentAtoms, | ||
| 6 | } from "@t3tools/client-runtime/state/shell"; | ||
| 7 | |||
| 8 | import { environmentCatalog } from "../connection/catalog"; | ||
| 9 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 10 | |||
| 11 | export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); | ||
| 12 | export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); | ||
| 13 | export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); | ||
| 14 | export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ | ||
| 15 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 16 | shellStateValueAtom: environmentShell.stateValueAtom, | ||
| 17 | }); | ||
apps/mobile/src/state/sourceControl.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime); | ||
apps/mobile/src/state/terminal.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime); | ||
apps/mobile/src/state/thread-outbox-manager.ts created+108| ... | @@ -0,0 +1,108 @@ | ||
| 1 | import type { EnvironmentId, MessageId } from "@t3tools/contracts"; | ||
| 2 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | flattenQueuedThreadMessages, | ||
| 6 | groupQueuedThreadMessages, | ||
| 7 | type QueuedThreadMessage, | ||
| 8 | } from "./thread-outbox-model"; | ||
| 9 | import type { ThreadOutboxStorage } from "./thread-outbox-storage"; | ||
| 10 | |||
| 11 | export interface ThreadOutboxManagerOptions { | ||
| 12 | readonly registry: AtomRegistry.AtomRegistry; | ||
| 13 | readonly storage: ThreadOutboxStorage; | ||
| 14 | readonly warn?: (message: string, error: unknown) => void; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ||
| 18 | const queuedMessagesByThreadKeyAtom = Atom.make< | ||
| 19 | Record<string, ReadonlyArray<QueuedThreadMessage>> | ||
| 20 | >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-outbox:queued-messages")); | ||
| 21 | const warn = | ||
| 22 | options.warn ?? | ||
| 23 | ((message: string, error: unknown) => { | ||
| 24 | console.warn(message, error); | ||
| 25 | }); | ||
| 26 | let loadPromise: Promise<void> | null = null; | ||
| 27 | let mutationQueue: Promise<void> = Promise.resolve(); | ||
| 28 | |||
| 29 | const serialize = <A>(mutation: () => Promise<A>): Promise<A> => { | ||
| 30 | const result = mutationQueue.then(mutation, mutation); | ||
| 31 | mutationQueue = result.then( | ||
| 32 | () => undefined, | ||
| 33 | () => undefined, | ||
| 34 | ); | ||
| 35 | return result; | ||
| 36 | }; | ||
| 37 | |||
| 38 | const currentMessages = (): ReadonlyArray<QueuedThreadMessage> => | ||
| 39 | flattenQueuedThreadMessages(options.registry.get(queuedMessagesByThreadKeyAtom)); | ||
| 40 | |||
| 41 | const setMessages = (messages: ReadonlyArray<QueuedThreadMessage>): void => { | ||
| 42 | options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages)); | ||
| 43 | }; | ||
| 44 | |||
| 45 | const load = (): Promise<void> => { | ||
| 46 | if (loadPromise !== null) { | ||
| 47 | return loadPromise; | ||
| 48 | } | ||
| 49 | loadPromise = serialize(async () => { | ||
| 50 | const persistedMessages = await options.storage.load(); | ||
| 51 | setMessages([...persistedMessages, ...currentMessages()]); | ||
| 52 | }).catch((error) => { | ||
| 53 | loadPromise = null; | ||
| 54 | warn("[thread-outbox] failed to load persisted messages", error); | ||
| 55 | }); | ||
| 56 | return loadPromise; | ||
| 57 | }; | ||
| 58 | |||
| 59 | const enqueue = (message: QueuedThreadMessage): Promise<void> => | ||
| 60 | serialize(async () => { | ||
| 61 | await options.storage.write(message); | ||
| 62 | setMessages([...currentMessages(), message]); | ||
| 63 | }); | ||
| 64 | |||
| 65 | const remove = (message: QueuedThreadMessage): Promise<void> => | ||
| 66 | serialize(async () => { | ||
| 67 | await options.storage.remove(message); | ||
| 68 | setMessages( | ||
| 69 | currentMessages().filter((candidate) => candidate.messageId !== message.messageId), | ||
| 70 | ); | ||
| 71 | }); | ||
| 72 | |||
| 73 | const clearEnvironment = (environmentId: EnvironmentId): Promise<void> => | ||
| 74 | serialize(async () => { | ||
| 75 | const persisted = await options.storage.load().catch((error) => { | ||
| 76 | warn("[thread-outbox] failed to load messages while clearing environment", error); | ||
| 77 | return []; | ||
| 78 | }); | ||
| 79 | const allMessages = flattenQueuedThreadMessages( | ||
| 80 | groupQueuedThreadMessages([...persisted, ...currentMessages()]), | ||
| 81 | ); | ||
| 82 | const removedMessageIds = new Set<MessageId>(); | ||
| 83 | |||
| 84 | await Promise.all( | ||
| 85 | allMessages | ||
| 86 | .filter((message) => message.environmentId === environmentId) | ||
| 87 | .map(async (message) => { | ||
| 88 | try { | ||
| 89 | await options.storage.remove(message); | ||
| 90 | removedMessageIds.add(message.messageId); | ||
| 91 | } catch (error) { | ||
| 92 | warn("[thread-outbox] failed to clear persisted message", error); | ||
| 93 | } | ||
| 94 | }), | ||
| 95 | ); | ||
| 96 | |||
| 97 | setMessages(allMessages.filter((message) => !removedMessageIds.has(message.messageId))); | ||
| 98 | }); | ||
| 99 | |||
| 100 | return { | ||
| 101 | queuedMessagesByThreadKeyAtom, | ||
| 102 | serialize, | ||
| 103 | load, | ||
| 104 | enqueue, | ||
| 105 | remove, | ||
| 106 | clearEnvironment, | ||
| 107 | }; | ||
| 108 | } | ||
apps/mobile/src/state/thread-outbox-model.ts created+121| ... | @@ -0,0 +1,121 @@ | ||
| 1 | import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; | ||
| 2 | import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; | ||
| 3 | import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | |||
| 6 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | ||
| 7 | import { scopedThreadKey } from "../lib/scopedEntities"; | ||
| 8 | |||
| 9 | const THREAD_OUTBOX_SCHEMA_VERSION = 1; | ||
| 10 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; | ||
| 11 | |||
| 12 | const DraftComposerImageAttachmentSchema = Schema.Struct({ | ||
| 13 | id: Schema.String, | ||
| 14 | previewUri: Schema.String, | ||
| 15 | type: Schema.Literal("image"), | ||
| 16 | name: Schema.String, | ||
| 17 | mimeType: Schema.String, | ||
| 18 | sizeBytes: Schema.Number, | ||
| 19 | dataUrl: Schema.String, | ||
| 20 | }); | ||
| 21 | |||
| 22 | export const QueuedThreadMessageSchema = Schema.Struct({ | ||
| 23 | schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION), | ||
| 24 | environmentId: EnvironmentId, | ||
| 25 | threadId: ThreadId, | ||
| 26 | messageId: MessageId, | ||
| 27 | commandId: CommandId, | ||
| 28 | text: Schema.String, | ||
| 29 | attachments: Schema.Array(DraftComposerImageAttachmentSchema), | ||
| 30 | createdAt: IsoDateTime, | ||
| 31 | }); | ||
| 32 | |||
| 33 | const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema); | ||
| 34 | const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema); | ||
| 35 | |||
| 36 | export interface QueuedThreadMessage { | ||
| 37 | readonly environmentId: EnvironmentId; | ||
| 38 | readonly threadId: ThreadId; | ||
| 39 | readonly messageId: MessageId; | ||
| 40 | readonly commandId: CommandId; | ||
| 41 | readonly text: string; | ||
| 42 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 43 | readonly createdAt: string; | ||
| 44 | } | ||
| 45 | |||
| 46 | export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown { | ||
| 47 | return encodeStoredQueuedThreadMessage({ | ||
| 48 | schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, | ||
| 49 | ...message, | ||
| 50 | }); | ||
| 51 | } | ||
| 52 | |||
| 53 | export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage { | ||
| 54 | const { schemaVersion: _, ...message } = decodeStoredQueuedThreadMessage(value); | ||
| 55 | return message; | ||
| 56 | } | ||
| 57 | |||
| 58 | export function groupQueuedThreadMessages( | ||
| 59 | messages: ReadonlyArray<QueuedThreadMessage>, | ||
| 60 | ): Record<string, ReadonlyArray<QueuedThreadMessage>> { | ||
| 61 | const deduplicated = new Map<MessageId, QueuedThreadMessage>(); | ||
| 62 | for (const message of messages) { | ||
| 63 | deduplicated.set(message.messageId, message); | ||
| 64 | } | ||
| 65 | |||
| 66 | const grouped: Record<string, Array<QueuedThreadMessage>> = {}; | ||
| 67 | for (const message of deduplicated.values()) { | ||
| 68 | const threadKey = scopedThreadKey(message.environmentId, message.threadId); | ||
| 69 | (grouped[threadKey] ??= []).push(message); | ||
| 70 | } | ||
| 71 | for (const queue of Object.values(grouped)) { | ||
| 72 | queue.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); | ||
| 73 | } | ||
| 74 | return grouped; | ||
| 75 | } | ||
| 76 | |||
| 77 | export function flattenQueuedThreadMessages( | ||
| 78 | queues: Record<string, ReadonlyArray<QueuedThreadMessage>>, | ||
| 79 | ): ReadonlyArray<QueuedThreadMessage> { | ||
| 80 | return Object.values(queues).flat(); | ||
| 81 | } | ||
| 82 | |||
| 83 | export function threadOutboxRetryDelayMs(attempt: number): number { | ||
| 84 | return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS); | ||
| 85 | } | ||
| 86 | |||
| 87 | export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send"; | ||
| 88 | |||
| 89 | export function resolveThreadOutboxDeliveryAction(input: { | ||
| 90 | readonly threadExists: boolean; | ||
| 91 | readonly shellStatus: EnvironmentShellStatus; | ||
| 92 | readonly environmentConnected: boolean; | ||
| 93 | readonly threadBusy: boolean; | ||
| 94 | }): ThreadOutboxDeliveryAction { | ||
| 95 | if (!input.threadExists) { | ||
| 96 | return input.shellStatus === "live" ? "remove" : "wait"; | ||
| 97 | } | ||
| 98 | return input.environmentConnected && !input.threadBusy ? "send" : "wait"; | ||
| 99 | } | ||
| 100 | |||
| 101 | function errorMessage(error: unknown): string | null { | ||
| 102 | if (error instanceof Error) { | ||
| 103 | return error.message; | ||
| 104 | } | ||
| 105 | if (typeof error === "object" && error !== null && "message" in error) { | ||
| 106 | return typeof error.message === "string" ? error.message : null; | ||
| 107 | } | ||
| 108 | return typeof error === "string" ? error : null; | ||
| 109 | } | ||
| 110 | |||
| 111 | export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { | ||
| 112 | if ( | ||
| 113 | typeof error === "object" && | ||
| 114 | error !== null && | ||
| 115 | "_tag" in error && | ||
| 116 | error._tag === "ConnectionTransientError" | ||
| 117 | ) { | ||
| 118 | return true; | ||
| 119 | } | ||
| 120 | return isTransportConnectionErrorMessage(errorMessage(error)); | ||
| 121 | } | ||
apps/mobile/src/state/thread-outbox-storage.ts created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import type { MessageId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | decodeQueuedThreadMessage, | ||
| 5 | encodeQueuedThreadMessage, | ||
| 6 | type QueuedThreadMessage, | ||
| 7 | } from "./thread-outbox-model"; | ||
| 8 | |||
| 9 | const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; | ||
| 10 | |||
| 11 | export interface ThreadOutboxStorage { | ||
| 12 | readonly load: () => Promise<ReadonlyArray<QueuedThreadMessage>>; | ||
| 13 | readonly write: (message: QueuedThreadMessage) => Promise<void>; | ||
| 14 | readonly remove: (message: QueuedThreadMessage) => Promise<void>; | ||
| 15 | } | ||
| 16 | |||
| 17 | function messageFileName(messageId: MessageId): string { | ||
| 18 | return `${encodeURIComponent(messageId)}.json`; | ||
| 19 | } | ||
| 20 | |||
| 21 | async function getOutboxDirectory() { | ||
| 22 | const { Directory, Paths } = await import("expo-file-system"); | ||
| 23 | const directory = new Directory(Paths.document, THREAD_OUTBOX_DIRECTORY); | ||
| 24 | directory.create({ idempotent: true, intermediates: true }); | ||
| 25 | return directory; | ||
| 26 | } | ||
| 27 | |||
| 28 | async function getMessageFile(messageId: MessageId) { | ||
| 29 | const { File } = await import("expo-file-system"); | ||
| 30 | return new File(await getOutboxDirectory(), messageFileName(messageId)); | ||
| 31 | } | ||
| 32 | |||
| 33 | export const expoThreadOutboxStorage: ThreadOutboxStorage = { | ||
| 34 | load: async () => { | ||
| 35 | const { File } = await import("expo-file-system"); | ||
| 36 | const directory = await getOutboxDirectory(); | ||
| 37 | const messages: QueuedThreadMessage[] = []; | ||
| 38 | |||
| 39 | for (const entry of directory.list()) { | ||
| 40 | if (!(entry instanceof File) || !entry.name.endsWith(".json")) { | ||
| 41 | continue; | ||
| 42 | } | ||
| 43 | try { | ||
| 44 | messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); | ||
| 45 | } catch (error) { | ||
| 46 | console.warn("[thread-outbox] ignored invalid persisted message", entry.name, error); | ||
| 47 | } | ||
| 48 | } | ||
| 49 | return messages; | ||
| 50 | }, | ||
| 51 | write: async (message) => { | ||
| 52 | const file = await getMessageFile(message.messageId); | ||
| 53 | if (!file.exists) { | ||
| 54 | file.create({ intermediates: true, overwrite: true }); | ||
| 55 | } | ||
| 56 | file.write(JSON.stringify(encodeQueuedThreadMessage(message))); | ||
| 57 | }, | ||
| 58 | remove: async (message) => { | ||
| 59 | const file = await getMessageFile(message.messageId); | ||
| 60 | if (file.exists) { | ||
| 61 | file.delete(); | ||
| 62 | } | ||
| 63 | }, | ||
| 64 | }; | ||
apps/mobile/src/state/thread-outbox.test.ts created+227| ... | @@ -0,0 +1,227 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | decodeQueuedThreadMessage, | ||
| 7 | groupQueuedThreadMessages, | ||
| 8 | resolveThreadOutboxDeliveryAction, | ||
| 9 | shouldRetryThreadOutboxDelivery, | ||
| 10 | threadOutboxRetryDelayMs, | ||
| 11 | type QueuedThreadMessage, | ||
| 12 | } from "./thread-outbox-model"; | ||
| 13 | import { createThreadOutboxManager } from "./thread-outbox-manager"; | ||
| 14 | import type { ThreadOutboxStorage } from "./thread-outbox-storage"; | ||
| 15 | |||
| 16 | function queuedMessage(input: { | ||
| 17 | readonly environmentId?: string; | ||
| 18 | readonly threadId?: string; | ||
| 19 | readonly messageId: string; | ||
| 20 | readonly createdAt: string; | ||
| 21 | }): QueuedThreadMessage { | ||
| 22 | return { | ||
| 23 | environmentId: EnvironmentId.make(input.environmentId ?? "environment-1"), | ||
| 24 | threadId: ThreadId.make(input.threadId ?? "thread-1"), | ||
| 25 | messageId: MessageId.make(input.messageId), | ||
| 26 | commandId: CommandId.make(`command-${input.messageId}`), | ||
| 27 | text: input.messageId, | ||
| 28 | attachments: [], | ||
| 29 | createdAt: input.createdAt, | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | describe("thread outbox", () => { | ||
| 34 | it("groups messages by scoped thread and preserves creation order", () => { | ||
| 35 | const later = queuedMessage({ | ||
| 36 | messageId: "message-2", | ||
| 37 | createdAt: "2026-06-08T10:00:02.000Z", | ||
| 38 | }); | ||
| 39 | const earlier = queuedMessage({ | ||
| 40 | messageId: "message-1", | ||
| 41 | createdAt: "2026-06-08T10:00:01.000Z", | ||
| 42 | }); | ||
| 43 | |||
| 44 | expect(groupQueuedThreadMessages([later, earlier])).toEqual({ | ||
| 45 | "environment-1:thread-1": [earlier, later], | ||
| 46 | }); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("decodes the persisted schema and rejects incomplete messages", () => { | ||
| 50 | const message = queuedMessage({ | ||
| 51 | messageId: "message-1", | ||
| 52 | createdAt: "2026-06-08T10:00:01.000Z", | ||
| 53 | }); | ||
| 54 | |||
| 55 | expect( | ||
| 56 | decodeQueuedThreadMessage({ | ||
| 57 | schemaVersion: 1, | ||
| 58 | ...message, | ||
| 59 | }), | ||
| 60 | ).toEqual(message); | ||
| 61 | expect(() => | ||
| 62 | decodeQueuedThreadMessage({ | ||
| 63 | schemaVersion: 1, | ||
| 64 | environmentId: "environment-1", | ||
| 65 | }), | ||
| 66 | ).toThrow(); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("backs off queued delivery retries and caps them at sixteen seconds", () => { | ||
| 70 | expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ | ||
| 71 | 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, | ||
| 72 | ]); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("serializes mutations even when an earlier mutation is slower", async () => { | ||
| 76 | const registry = AtomRegistry.make(); | ||
| 77 | const manager = createThreadOutboxManager({ | ||
| 78 | registry, | ||
| 79 | storage: { | ||
| 80 | load: async () => [], | ||
| 81 | write: async () => undefined, | ||
| 82 | remove: async () => undefined, | ||
| 83 | }, | ||
| 84 | }); | ||
| 85 | const order: string[] = []; | ||
| 86 | let releaseFirst!: () => void; | ||
| 87 | const firstBlocked = new Promise<void>((resolve) => { | ||
| 88 | releaseFirst = resolve; | ||
| 89 | }); | ||
| 90 | |||
| 91 | const first = manager.serialize(async () => { | ||
| 92 | order.push("first:start"); | ||
| 93 | await firstBlocked; | ||
| 94 | order.push("first:end"); | ||
| 95 | }); | ||
| 96 | const second = manager.serialize(async () => { | ||
| 97 | order.push("second"); | ||
| 98 | }); | ||
| 99 | |||
| 100 | await Promise.resolve(); | ||
| 101 | expect(order).toEqual(["first:start"]); | ||
| 102 | releaseFirst(); | ||
| 103 | await Promise.all([first, second]); | ||
| 104 | expect(order).toEqual(["first:start", "first:end", "second"]); | ||
| 105 | registry.dispose(); | ||
| 106 | }); | ||
| 107 | |||
| 108 | it("holds the mutation queue while persisted messages are loading", async () => { | ||
| 109 | const registry = AtomRegistry.make(); | ||
| 110 | const message = queuedMessage({ | ||
| 111 | messageId: "message-1", | ||
| 112 | createdAt: "2026-06-08T10:00:01.000Z", | ||
| 113 | }); | ||
| 114 | const stored = new Map([[message.messageId, message]]); | ||
| 115 | let loadCalls = 0; | ||
| 116 | let removeCalls = 0; | ||
| 117 | let releaseInitialLoad!: () => void; | ||
| 118 | const initialLoadBlocked = new Promise<void>((resolve) => { | ||
| 119 | releaseInitialLoad = resolve; | ||
| 120 | }); | ||
| 121 | const storage: ThreadOutboxStorage = { | ||
| 122 | load: async () => { | ||
| 123 | loadCalls += 1; | ||
| 124 | if (loadCalls === 1) { | ||
| 125 | await initialLoadBlocked; | ||
| 126 | } | ||
| 127 | return [...stored.values()]; | ||
| 128 | }, | ||
| 129 | write: async () => undefined, | ||
| 130 | remove: async (candidate) => { | ||
| 131 | removeCalls += 1; | ||
| 132 | stored.delete(candidate.messageId); | ||
| 133 | }, | ||
| 134 | }; | ||
| 135 | const manager = createThreadOutboxManager({ registry, storage }); | ||
| 136 | |||
| 137 | const loading = manager.load(); | ||
| 138 | await Promise.resolve(); | ||
| 139 | const clearing = manager.clearEnvironment(message.environmentId); | ||
| 140 | await Promise.resolve(); | ||
| 141 | await Promise.resolve(); | ||
| 142 | |||
| 143 | expect(loadCalls).toBe(1); | ||
| 144 | expect(removeCalls).toBe(0); | ||
| 145 | |||
| 146 | releaseInitialLoad(); | ||
| 147 | await Promise.all([loading, clearing]); | ||
| 148 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); | ||
| 149 | registry.dispose(); | ||
| 150 | }); | ||
| 151 | |||
| 152 | it("keeps atom state aligned with durable writes and removals", async () => { | ||
| 153 | const registry = AtomRegistry.make(); | ||
| 154 | const stored = new Map<MessageId, QueuedThreadMessage>(); | ||
| 155 | let failRemoval = true; | ||
| 156 | const storage: ThreadOutboxStorage = { | ||
| 157 | load: async () => [...stored.values()], | ||
| 158 | write: async (message) => { | ||
| 159 | stored.set(message.messageId, message); | ||
| 160 | }, | ||
| 161 | remove: async (message) => { | ||
| 162 | if (failRemoval) { | ||
| 163 | throw new Error("remove failed"); | ||
| 164 | } | ||
| 165 | stored.delete(message.messageId); | ||
| 166 | }, | ||
| 167 | }; | ||
| 168 | const manager = createThreadOutboxManager({ registry, storage }); | ||
| 169 | const message = queuedMessage({ | ||
| 170 | messageId: "message-1", | ||
| 171 | createdAt: "2026-06-08T10:00:01.000Z", | ||
| 172 | }); | ||
| 173 | |||
| 174 | await manager.enqueue(message); | ||
| 175 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ | ||
| 176 | "environment-1:thread-1": [message], | ||
| 177 | }); | ||
| 178 | |||
| 179 | await expect(manager.remove(message)).rejects.toThrow("remove failed"); | ||
| 180 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ | ||
| 181 | "environment-1:thread-1": [message], | ||
| 182 | }); | ||
| 183 | |||
| 184 | failRemoval = false; | ||
| 185 | await manager.remove(message); | ||
| 186 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); | ||
| 187 | registry.dispose(); | ||
| 188 | }); | ||
| 189 | |||
| 190 | it("only removes a missing-thread message after shell synchronization is live", () => { | ||
| 191 | expect( | ||
| 192 | resolveThreadOutboxDeliveryAction({ | ||
| 193 | threadExists: false, | ||
| 194 | shellStatus: "synchronizing", | ||
| 195 | environmentConnected: true, | ||
| 196 | threadBusy: false, | ||
| 197 | }), | ||
| 198 | ).toBe("wait"); | ||
| 199 | expect( | ||
| 200 | resolveThreadOutboxDeliveryAction({ | ||
| 201 | threadExists: false, | ||
| 202 | shellStatus: "live", | ||
| 203 | environmentConnected: true, | ||
| 204 | threadBusy: false, | ||
| 205 | }), | ||
| 206 | ).toBe("remove"); | ||
| 207 | expect( | ||
| 208 | resolveThreadOutboxDeliveryAction({ | ||
| 209 | threadExists: true, | ||
| 210 | shellStatus: "live", | ||
| 211 | environmentConnected: true, | ||
| 212 | threadBusy: false, | ||
| 213 | }), | ||
| 214 | ).toBe("send"); | ||
| 215 | }); | ||
| 216 | |||
| 217 | it("retries transport failures but drops deterministic command failures", () => { | ||
| 218 | expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true); | ||
| 219 | expect( | ||
| 220 | shouldRetryThreadOutboxDelivery({ | ||
| 221 | _tag: "ConnectionTransientError", | ||
| 222 | message: "temporarily unavailable", | ||
| 223 | }), | ||
| 224 | ).toBe(true); | ||
| 225 | expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); | ||
| 226 | }); | ||
| 227 | }); | ||
apps/mobile/src/state/thread-outbox.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { appAtomRegistry } from "./atom-registry"; | ||
| 4 | import { createThreadOutboxManager } from "./thread-outbox-manager"; | ||
| 5 | import type { QueuedThreadMessage } from "./thread-outbox-model"; | ||
| 6 | import { expoThreadOutboxStorage } from "./thread-outbox-storage"; | ||
| 7 | |||
| 8 | export * from "./thread-outbox-model"; | ||
| 9 | |||
| 10 | export const threadOutboxManager = createThreadOutboxManager({ | ||
| 11 | registry: appAtomRegistry, | ||
| 12 | storage: expoThreadOutboxStorage, | ||
| 13 | }); | ||
| 14 | |||
| 15 | export function ensureThreadOutboxLoaded(): void { | ||
| 16 | void threadOutboxManager.load(); | ||
| 17 | } | ||
| 18 | |||
| 19 | export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> { | ||
| 20 | return threadOutboxManager.enqueue(message); | ||
| 21 | } | ||
| 22 | |||
| 23 | export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> { | ||
| 24 | return threadOutboxManager.remove(message); | ||
| 25 | } | ||
| 26 | |||
| 27 | export function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise<void> { | ||
| 28 | return threadOutboxManager.clearEnvironment(environmentId); | ||
| 29 | } | ||
apps/mobile/src/state/threads.ts created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | createEnvironmentThreadDetailAtoms, | ||
| 4 | createEnvironmentThreadShellAtoms, | ||
| 5 | createEnvironmentThreadStateAtoms, | ||
| 6 | EMPTY_ENVIRONMENT_THREAD_STATE, | ||
| 7 | type EnvironmentThreadState, | ||
| 8 | createThreadEnvironmentAtoms, | ||
| 9 | } from "@t3tools/client-runtime/state/threads"; | ||
| 10 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 13 | |||
| 14 | import { environmentCatalog } from "../connection/catalog"; | ||
| 15 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 16 | import { environmentSnapshotAtom } from "./shell"; | ||
| 17 | |||
| 18 | export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); | ||
| 19 | export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); | ||
| 20 | export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( | ||
| 21 | environmentThreads.stateAtom, | ||
| 22 | ); | ||
| 23 | export const environmentThreadShells = createEnvironmentThreadShellAtoms({ | ||
| 24 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 25 | snapshotAtom: environmentSnapshotAtom, | ||
| 26 | }); | ||
| 27 | |||
| 28 | const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( | ||
| 29 | Atom.withLabel("mobile-environment-thread:empty"), | ||
| 30 | ); | ||
| 31 | |||
| 32 | export function useEnvironmentThread( | ||
| 33 | environmentId: EnvironmentId | null, | ||
| 34 | threadId: ThreadId | null, | ||
| 35 | ): EnvironmentThreadState { | ||
| 36 | const result = useAtomValue( | ||
| 37 | environmentId !== null && threadId !== null | ||
| 38 | ? environmentThreads.stateAtom(environmentId, threadId) | ||
| 39 | : EMPTY_THREAD_STATE_ATOM, | ||
| 40 | ); | ||
| 41 | return Option.getOrElse( | ||
| 42 | AsyncResult.value(result), | ||
| 43 | () => EMPTY_ENVIRONMENT_THREAD_STATE, | ||
| 44 | ) as EnvironmentThreadState; | ||
| 45 | } | ||
apps/mobile/src/state/use-atom-command.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 1 | import { RegistryContext } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type AtomCommand, | ||
| 4 | type AtomCommandOptions, | ||
| 5 | type AtomCommandResult, | ||
| 6 | runAtomCommand, | ||
| 7 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 8 | import { useCallback, useContext } from "react"; | ||
| 9 | |||
| 10 | export function useAtomCommand<A, E, W>( | ||
| 11 | command: AtomCommand<W, A, E>, | ||
| 12 | options?: string | AtomCommandOptions, | ||
| 13 | ): (value: W) => Promise<AtomCommandResult<A, E>> { | ||
| 14 | const registry = useContext(RegistryContext); | ||
| 15 | const label = typeof options === "string" ? options : (options?.label ?? command.label); | ||
| 16 | const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); | ||
| 17 | const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); | ||
| 18 | |||
| 19 | return useCallback( | ||
| 20 | (value: W) => runAtomCommand(registry, command, value, { label, reportFailure, reportDefect }), | ||
| 21 | [command, label, registry, reportDefect, reportFailure], | ||
| 22 | ); | ||
| 23 | } | ||
apps/mobile/src/state/use-atom-query-runner.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import { RegistryContext } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | executeAtomQuery, | ||
| 4 | type AtomCommandOptions, | ||
| 5 | type AtomCommandResult, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import { AsyncResult, type Atom } from "effect/unstable/reactivity"; | ||
| 8 | import { useCallback, useContext } from "react"; | ||
| 9 | |||
| 10 | export function useAtomQueryRunner<T, A, E>( | ||
| 11 | family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>, | ||
| 12 | options?: string | AtomCommandOptions, | ||
| 13 | ): (target: T) => Promise<AtomCommandResult<A, E>> { | ||
| 14 | const registry = useContext(RegistryContext); | ||
| 15 | const explicitLabel = typeof options === "string" ? options : options?.label; | ||
| 16 | const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); | ||
| 17 | const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); | ||
| 18 | |||
| 19 | return useCallback( | ||
| 20 | (target: T) => { | ||
| 21 | const atom = family(target); | ||
| 22 | return executeAtomQuery(registry, atom, { | ||
| 23 | label: explicitLabel ?? atom.label?.[0] ?? "atom query", | ||
| 24 | reportFailure, | ||
| 25 | reportDefect, | ||
| 26 | }); | ||
| 27 | }, | ||
| 28 | [explicitLabel, family, registry, reportDefect, reportFailure], | ||
| 29 | ); | ||
| 30 | } | ||
apps/mobile/src/state/use-checkpoint-diff.ts deleted-16| ... | @@ -1,16 +0,0 @@ | ||
| 1 | import { createCheckpointDiffManager, type CheckpointDiffTarget } from "@t3tools/client-runtime"; | ||
| 2 | |||
| 3 | import { appAtomRegistry } from "./atom-registry"; | ||
| 4 | import { getEnvironmentClient } from "./environment-session-registry"; | ||
| 5 | |||
| 6 | export const checkpointDiffManager = createCheckpointDiffManager({ | ||
| 7 | getRegistry: () => appAtomRegistry, | ||
| 8 | getClient: (environmentId) => getEnvironmentClient(environmentId)?.orchestration ?? null, | ||
| 9 | }); | ||
| 10 | |||
| 11 | export function loadCheckpointDiff( | ||
| 12 | target: CheckpointDiffTarget, | ||
| 13 | options?: { readonly force?: boolean }, | ||
| 14 | ) { | ||
| 15 | return checkpointDiffManager.load(target, undefined, options); | ||
| 16 | } | ||
apps/mobile/src/state/use-composer-drafts.test.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts"; | ||
| 5 | |||
| 6 | const DRAFT: ComposerDraft = { | ||
| 7 | text: "hello", | ||
| 8 | attachments: [], | ||
| 9 | }; | ||
| 10 | |||
| 11 | describe("mobile composer drafts", () => { | ||
| 12 | it("removes only drafts owned by the selected environment", () => { | ||
| 13 | const environmentId = EnvironmentId.make("environment-cloud"); | ||
| 14 | const retainedEnvironmentId = EnvironmentId.make("environment-local"); | ||
| 15 | |||
| 16 | expect( | ||
| 17 | removeComposerDraftsForEnvironment( | ||
| 18 | { | ||
| 19 | [`${environmentId}:thread-cloud`]: DRAFT, | ||
| 20 | [`${retainedEnvironmentId}:thread-local`]: DRAFT, | ||
| 21 | }, | ||
| 22 | environmentId, | ||
| 23 | ), | ||
| 24 | ).toEqual({ | ||
| 25 | [`${retainedEnvironmentId}:thread-local`]: DRAFT, | ||
| 26 | }); | ||
| 27 | }); | ||
| 28 | }); | ||
apps/mobile/src/state/use-composer-drafts.ts+61-24| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { useEffect } from "react"; | 3 | import { useEffect } from "react"; |
| 3 | import { Atom } from "effect/unstable/reactivity"; | 4 | import { Atom } from "effect/unstable/reactivity"; |
| 4 | 5 | ||
| ... | @@ -30,7 +31,7 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p | ... | @@ -30,7 +31,7 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p |
| 30 | Atom.withLabel("mobile:composer-drafts"), | 31 | Atom.withLabel("mobile:composer-drafts"), |
| 31 | ); | 32 | ); |
| 32 | 33 | ||
| 33 | let loadStarted = false; | 34 | let loadPromise: Promise<void> | null = null; |
| 34 | let persistTimer: ReturnType<typeof setTimeout> | null = null; | 35 | let persistTimer: ReturnType<typeof setTimeout> | null = null; |
| 35 | 36 | ||
| 36 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { | 37 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { |
| ... | @@ -79,20 +80,24 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra | ... | @@ -79,20 +80,24 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra |
| 79 | } | 80 | } |
| 80 | } | 81 | } |
| 81 | 82 | ||
| 83 | async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { | ||
| 84 | const file = await getComposerDraftsFile(); | ||
| 85 | const nonEmptyDrafts = Object.fromEntries( | ||
| 86 | Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), | ||
| 87 | ); | ||
| 88 | const document: PersistedComposerDrafts = { | ||
| 89 | schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, | ||
| 90 | drafts: nonEmptyDrafts, | ||
| 91 | }; | ||
| 92 | if (!file.exists) { | ||
| 93 | file.create({ intermediates: true, overwrite: true }); | ||
| 94 | } | ||
| 95 | file.write(JSON.stringify(document)); | ||
| 96 | } | ||
| 97 | |||
| 82 | async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { | 98 | async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { |
| 83 | try { | 99 | try { |
| 84 | const file = await getComposerDraftsFile(); | 100 | await writePersistedComposerDrafts(drafts); |
| 85 | const nonEmptyDrafts = Object.fromEntries( | ||
| 86 | Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), | ||
| 87 | ); | ||
| 88 | const document: PersistedComposerDrafts = { | ||
| 89 | schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, | ||
| 90 | drafts: nonEmptyDrafts, | ||
| 91 | }; | ||
| 92 | if (!file.exists) { | ||
| 93 | file.create({ intermediates: true, overwrite: true }); | ||
| 94 | } | ||
| 95 | file.write(JSON.stringify(document)); | ||
| 96 | } catch { | 101 | } catch { |
| 97 | // Draft persistence is best-effort; in-memory drafts still keep working. | 102 | // Draft persistence is best-effort; in-memory drafts still keep working. |
| 98 | } | 103 | } |
| ... | @@ -109,20 +114,23 @@ function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): v | ... | @@ -109,20 +114,23 @@ function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): v |
| 109 | } | 114 | } |
| 110 | 115 | ||
| 111 | export function ensureComposerDraftsLoaded(): void { | 116 | export function ensureComposerDraftsLoaded(): void { |
| 112 | if (loadStarted) { | 117 | if (loadPromise !== null) { |
| 113 | return; | 118 | return; |
| 114 | } | 119 | } |
| 115 | loadStarted = true; | 120 | loadPromise = loadPersistedComposerDrafts() |
| 116 | void loadPersistedComposerDrafts().then((persistedDrafts) => { | 121 | .then((persistedDrafts) => { |
| 117 | if (Object.keys(persistedDrafts).length === 0) { | 122 | if (Object.keys(persistedDrafts).length === 0) { |
| 118 | return; | 123 | return; |
| 119 | } | 124 | } |
| 120 | const current = appAtomRegistry.get(composerDraftsAtom); | 125 | const current = appAtomRegistry.get(composerDraftsAtom); |
| 121 | appAtomRegistry.set(composerDraftsAtom, { | 126 | appAtomRegistry.set(composerDraftsAtom, { |
| 122 | ...persistedDrafts, | 127 | ...persistedDrafts, |
| 123 | ...current, | 128 | ...current, |
| 129 | }); | ||
| 130 | }) | ||
| 131 | .catch(() => { | ||
| 132 | // Draft loading is best-effort; in-memory drafts still keep working. | ||
| 124 | }); | 133 | }); |
| 125 | }); | ||
| 126 | } | 134 | } |
| 127 | 135 | ||
| 128 | function updateComposerDrafts( | 136 | function updateComposerDrafts( |
| ... | @@ -234,6 +242,35 @@ export function clearComposerDraft(draftKey: string): void { | ... | @@ -234,6 +242,35 @@ export function clearComposerDraft(draftKey: string): void { |
| 234 | }); | 242 | }); |
| 235 | } | 243 | } |
| 236 | 244 | ||
| 245 | export function removeComposerDraftsForEnvironment( | ||
| 246 | drafts: Record<string, ComposerDraft>, | ||
| 247 | environmentId: EnvironmentId, | ||
| 248 | ): Record<string, ComposerDraft> { | ||
| 249 | const environmentPrefix = `${environmentId}:`; | ||
| 250 | return Object.fromEntries( | ||
| 251 | Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)), | ||
| 252 | ); | ||
| 253 | } | ||
| 254 | |||
| 255 | export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise<void> { | ||
| 256 | ensureComposerDraftsLoaded(); | ||
| 257 | if (loadPromise !== null) { | ||
| 258 | await loadPromise; | ||
| 259 | } | ||
| 260 | |||
| 261 | const next = removeComposerDraftsForEnvironment( | ||
| 262 | appAtomRegistry.get(composerDraftsAtom), | ||
| 263 | environmentId, | ||
| 264 | ); | ||
| 265 | |||
| 266 | if (persistTimer !== null) { | ||
| 267 | clearTimeout(persistTimer); | ||
| 268 | persistTimer = null; | ||
| 269 | } | ||
| 270 | appAtomRegistry.set(composerDraftsAtom, next); | ||
| 271 | await writePersistedComposerDrafts(next); | ||
| 272 | } | ||
| 273 | |||
| 237 | export function useComposerDraft(draftKey: string | null): ComposerDraft { | 274 | export function useComposerDraft(draftKey: string | null): ComposerDraft { |
| 238 | const drafts = useAtomValue(composerDraftsAtom); | 275 | const drafts = useAtomValue(composerDraftsAtom); |
| 239 | useEffect(() => { | 276 | useEffect(() => { |
apps/mobile/src/state/use-composer-path-search.ts+4-43| ... | @@ -1,46 +1,7 @@ | ... | @@ -1,46 +1,7 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { type ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; |
| 2 | import { | ||
| 3 | type ComposerPathSearchState, | ||
| 4 | type ComposerPathSearchTarget, | ||
| 5 | EMPTY_COMPOSER_PATH_SEARCH_ATOM, | ||
| 6 | EMPTY_COMPOSER_PATH_SEARCH_STATE, | ||
| 7 | composerPathSearchStateAtom, | ||
| 8 | createComposerPathSearchManager, | ||
| 9 | getComposerPathSearchTargetKey, | ||
| 10 | normalizeComposerPathSearchQuery, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | import { useEffect, useMemo } from "react"; | ||
| 13 | 2 | ||
| 14 | import { appAtomRegistry } from "./atom-registry"; | 3 | import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; |
| 15 | import { | ||
| 16 | getEnvironmentClient, | ||
| 17 | subscribeEnvironmentConnections, | ||
| 18 | } from "./environment-session-registry"; | ||
| 19 | 4 | ||
| 20 | const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; | 5 | export function useComposerPathSearch(target: ComposerPathSearchTarget) { |
| 21 | 6 | return useComposerPathSearchQuery(target); | |
| 22 | const composerPathSearchManager = createComposerPathSearchManager({ | ||
| 23 | getRegistry: () => appAtomRegistry, | ||
| 24 | getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null, | ||
| 25 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 26 | staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, | ||
| 27 | }); | ||
| 28 | |||
| 29 | export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { | ||
| 30 | const stableTarget = useMemo( | ||
| 31 | () => ({ | ||
| 32 | environmentId: target.environmentId, | ||
| 33 | cwd: target.cwd, | ||
| 34 | query: normalizeComposerPathSearchQuery(target.query), | ||
| 35 | }), | ||
| 36 | [target.cwd, target.environmentId, target.query], | ||
| 37 | ); | ||
| 38 | const targetKey = getComposerPathSearchTargetKey(stableTarget); | ||
| 39 | |||
| 40 | useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); | ||
| 41 | |||
| 42 | const state = useAtomValue( | ||
| 43 | targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, | ||
| 44 | ); | ||
| 45 | return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; | ||
| 46 | } | 7 | } |
apps/mobile/src/state/use-environment-runtime.ts deleted-76| ... | @@ -1,76 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_ENVIRONMENT_RUNTIME_ATOM, | ||
| 4 | EMPTY_ENVIRONMENT_RUNTIME_STATE, | ||
| 5 | createEnvironmentRuntimeManager, | ||
| 6 | environmentRuntimeStateAtom, | ||
| 7 | getEnvironmentRuntimeTargetKey, | ||
| 8 | type EnvironmentRuntimeState, | ||
| 9 | } from "@t3tools/client-runtime"; | ||
| 10 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 11 | import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; | ||
| 12 | |||
| 13 | import { appAtomRegistry } from "./atom-registry"; | ||
| 14 | import * as Arr from "effect/Array"; | ||
| 15 | import * as Order from "effect/Order"; | ||
| 16 | |||
| 17 | export const environmentRuntimeManager = createEnvironmentRuntimeManager({ | ||
| 18 | getRegistry: () => appAtomRegistry, | ||
| 19 | }); | ||
| 20 | |||
| 21 | export function useEnvironmentRuntime( | ||
| 22 | environmentId: EnvironmentId | null, | ||
| 23 | ): EnvironmentRuntimeState { | ||
| 24 | const targetKey = getEnvironmentRuntimeTargetKey({ environmentId }); | ||
| 25 | const state = useAtomValue( | ||
| 26 | targetKey !== null ? environmentRuntimeStateAtom(targetKey) : EMPTY_ENVIRONMENT_RUNTIME_ATOM, | ||
| 27 | ); | ||
| 28 | return targetKey === null ? EMPTY_ENVIRONMENT_RUNTIME_STATE : state; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function useEnvironmentRuntimeStates( | ||
| 32 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 33 | ): Readonly<Record<EnvironmentId, EnvironmentRuntimeState>> { | ||
| 34 | const stableEnvironmentIds = useMemo( | ||
| 35 | () => Arr.sort(new Set(environmentIds), Order.String), | ||
| 36 | [environmentIds], | ||
| 37 | ); | ||
| 38 | const snapshotCacheRef = useRef<Readonly<Record<EnvironmentId, EnvironmentRuntimeState>>>({}); | ||
| 39 | |||
| 40 | const subscribe = useCallback( | ||
| 41 | (onStoreChange: () => void) => { | ||
| 42 | const unsubs = stableEnvironmentIds.map((environmentId) => | ||
| 43 | appAtomRegistry.subscribe(environmentRuntimeStateAtom(environmentId), onStoreChange), | ||
| 44 | ); | ||
| 45 | return () => { | ||
| 46 | for (const unsub of unsubs) { | ||
| 47 | unsub(); | ||
| 48 | } | ||
| 49 | }; | ||
| 50 | }, | ||
| 51 | [stableEnvironmentIds], | ||
| 52 | ); | ||
| 53 | |||
| 54 | const getSnapshot = useCallback(() => { | ||
| 55 | const previous = snapshotCacheRef.current; | ||
| 56 | let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; | ||
| 57 | const next: Record<EnvironmentId, EnvironmentRuntimeState> = {}; | ||
| 58 | |||
| 59 | for (const environmentId of stableEnvironmentIds) { | ||
| 60 | const snapshot = environmentRuntimeManager.getSnapshot({ environmentId }); | ||
| 61 | next[environmentId] = snapshot; | ||
| 62 | if (!hasChanged && previous[environmentId] !== snapshot) { | ||
| 63 | hasChanged = true; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | if (!hasChanged) { | ||
| 68 | return previous; | ||
| 69 | } | ||
| 70 | |||
| 71 | snapshotCacheRef.current = next; | ||
| 72 | return next; | ||
| 73 | }, [stableEnvironmentIds]); | ||
| 74 | |||
| 75 | return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); | ||
| 76 | } | ||
apps/mobile/src/state/use-filesystem-browse.ts deleted-82| ... | @@ -1,82 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_FILESYSTEM_BROWSE_ATOM, | ||
| 4 | EMPTY_FILESYSTEM_BROWSE_STATE, | ||
| 5 | type FilesystemBrowseClient, | ||
| 6 | type FilesystemBrowseState, | ||
| 7 | type FilesystemBrowseTarget, | ||
| 8 | createFilesystemBrowseManager, | ||
| 9 | filesystemBrowseStateAtom, | ||
| 10 | getFilesystemBrowseTargetKey, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | import type { | ||
| 13 | EnvironmentId, | ||
| 14 | FilesystemBrowseInput, | ||
| 15 | FilesystemBrowseResult, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import { useEffect, useMemo } from "react"; | ||
| 18 | |||
| 19 | import { appAtomRegistry } from "./atom-registry"; | ||
| 20 | import { | ||
| 21 | getEnvironmentClient, | ||
| 22 | subscribeEnvironmentConnections, | ||
| 23 | } from "./environment-session-registry"; | ||
| 24 | |||
| 25 | const filesystemBrowseManager = createFilesystemBrowseManager<EnvironmentId>({ | ||
| 26 | getRegistry: () => appAtomRegistry, | ||
| 27 | getClient: (environmentId) => getEnvironmentClient(environmentId)?.filesystem ?? null, | ||
| 28 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 29 | }); | ||
| 30 | |||
| 31 | function filesystemBrowseTargetForEnvironment( | ||
| 32 | environmentId: EnvironmentId | null, | ||
| 33 | input: FilesystemBrowseInput | null, | ||
| 34 | ): FilesystemBrowseTarget<EnvironmentId> { | ||
| 35 | return { key: environmentId, input }; | ||
| 36 | } | ||
| 37 | |||
| 38 | export function refreshFilesystemBrowseForEnvironment( | ||
| 39 | environmentId: EnvironmentId | null, | ||
| 40 | input: FilesystemBrowseInput | null, | ||
| 41 | client?: FilesystemBrowseClient | null, | ||
| 42 | ): Promise<FilesystemBrowseResult | null> { | ||
| 43 | return filesystemBrowseManager.refresh( | ||
| 44 | filesystemBrowseTargetForEnvironment(environmentId, input), | ||
| 45 | client ?? undefined, | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | export function invalidateFilesystemBrowseForEnvironment( | ||
| 50 | environmentId: EnvironmentId | null, | ||
| 51 | input: FilesystemBrowseInput | null, | ||
| 52 | ): void { | ||
| 53 | filesystemBrowseManager.invalidate(filesystemBrowseTargetForEnvironment(environmentId, input)); | ||
| 54 | } | ||
| 55 | |||
| 56 | export function resetFilesystemBrowseState(): void { | ||
| 57 | filesystemBrowseManager.reset(); | ||
| 58 | } | ||
| 59 | |||
| 60 | export function resetFilesystemBrowseStateForTests(): void { | ||
| 61 | resetFilesystemBrowseState(); | ||
| 62 | } | ||
| 63 | |||
| 64 | export function useFilesystemBrowse( | ||
| 65 | environmentId: EnvironmentId | null, | ||
| 66 | input: FilesystemBrowseInput | null, | ||
| 67 | ): FilesystemBrowseState { | ||
| 68 | const target = useMemo( | ||
| 69 | () => filesystemBrowseTargetForEnvironment(environmentId, input), | ||
| 70 | [environmentId, input], | ||
| 71 | ); | ||
| 72 | |||
| 73 | useEffect(() => { | ||
| 74 | return filesystemBrowseManager.watch(target); | ||
| 75 | }, [target]); | ||
| 76 | |||
| 77 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 78 | const state = useAtomValue( | ||
| 79 | targetKey !== null ? filesystemBrowseStateAtom(targetKey) : EMPTY_FILESYSTEM_BROWSE_ATOM, | ||
| 80 | ); | ||
| 81 | return targetKey === null ? EMPTY_FILESYSTEM_BROWSE_STATE : state; | ||
| 82 | } | ||
apps/mobile/src/state/use-remote-catalog.ts deleted-198| ... | @@ -1,198 +0,0 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | import * as Order from "effect/Order"; | ||
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | EnvironmentConnectionState, | ||
| 7 | EnvironmentScopedProjectShell, | ||
| 8 | EnvironmentScopedThreadShell, | ||
| 9 | scopeProjectShell, | ||
| 10 | scopeThreadShell, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | |||
| 13 | import { ConnectedEnvironmentSummary } from "./remote-runtime-types"; | ||
| 14 | import type { SavedRemoteConnection } from "../lib/connection"; | ||
| 15 | import { useCachedShellSnapshotMetadata, useShellSnapshotStates } from "./use-shell-snapshot"; | ||
| 16 | import { | ||
| 17 | useRemoteConnectionStatus, | ||
| 18 | useRemoteEnvironmentState, | ||
| 19 | } from "./use-remote-environment-registry"; | ||
| 20 | |||
| 21 | const projectsSortOrder = Order.mapInput( | ||
| 22 | Order.Struct({ | ||
| 23 | title: Order.String, | ||
| 24 | environmentId: Order.String, | ||
| 25 | }), | ||
| 26 | (project: EnvironmentScopedProjectShell) => ({ | ||
| 27 | title: project.title, | ||
| 28 | environmentId: project.environmentId, | ||
| 29 | }), | ||
| 30 | ); | ||
| 31 | |||
| 32 | const threadsSortOrder = Order.mapInput( | ||
| 33 | Order.Struct({ | ||
| 34 | activityAt: Order.flip(Order.String), | ||
| 35 | environmentId: Order.String, | ||
| 36 | }), | ||
| 37 | (thread: EnvironmentScopedThreadShell) => ({ | ||
| 38 | activityAt: thread.updatedAt ?? thread.createdAt, | ||
| 39 | environmentId: thread.environmentId, | ||
| 40 | }), | ||
| 41 | ); | ||
| 42 | |||
| 43 | function deriveOverallConnectionState( | ||
| 44 | environments: ReadonlyArray<ConnectedEnvironmentSummary>, | ||
| 45 | ): EnvironmentConnectionState { | ||
| 46 | if (environments.length === 0) { | ||
| 47 | return "idle"; | ||
| 48 | } | ||
| 49 | if (environments.some((environment) => environment.connectionState === "ready")) { | ||
| 50 | return "ready"; | ||
| 51 | } | ||
| 52 | if (environments.some((environment) => environment.connectionState === "reconnecting")) { | ||
| 53 | return "reconnecting"; | ||
| 54 | } | ||
| 55 | if (environments.some((environment) => environment.connectionState === "connecting")) { | ||
| 56 | return "connecting"; | ||
| 57 | } | ||
| 58 | return "disconnected"; | ||
| 59 | } | ||
| 60 | |||
| 61 | function listRemoteCatalogEnvironmentIds( | ||
| 62 | savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>, | ||
| 63 | ): ReadonlyArray<SavedRemoteConnection["environmentId"]> { | ||
| 64 | const environmentIds: SavedRemoteConnection["environmentId"][] = []; | ||
| 65 | for (const connection of Object.values(savedConnectionsById)) { | ||
| 66 | environmentIds.push(connection.environmentId); | ||
| 67 | } | ||
| 68 | return environmentIds; | ||
| 69 | } | ||
| 70 | |||
| 71 | export interface RemoteCatalogState { | ||
| 72 | readonly isLoadingSavedConnections: boolean; | ||
| 73 | readonly hasSavedConnections: boolean; | ||
| 74 | readonly hasLoadedShellSnapshot: boolean; | ||
| 75 | readonly hasPendingShellSnapshot: boolean; | ||
| 76 | readonly hasReadyEnvironment: boolean; | ||
| 77 | readonly hasConnectingEnvironment: boolean; | ||
| 78 | readonly connectionState: EnvironmentConnectionState; | ||
| 79 | readonly connectionError: string | null; | ||
| 80 | readonly shellSnapshotError: string | null; | ||
| 81 | readonly isUsingCachedData: boolean; | ||
| 82 | readonly latestCachedSnapshotReceivedAt: string | null; | ||
| 83 | } | ||
| 84 | |||
| 85 | export function useRemoteCatalog() { | ||
| 86 | const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); | ||
| 87 | const { environmentStateById, isLoadingSavedConnection, savedConnectionsById } = | ||
| 88 | useRemoteEnvironmentState(); | ||
| 89 | const catalogEnvironmentIds = useMemo( | ||
| 90 | () => listRemoteCatalogEnvironmentIds(savedConnectionsById), | ||
| 91 | [savedConnectionsById], | ||
| 92 | ); | ||
| 93 | const shellSnapshotStates = useShellSnapshotStates(catalogEnvironmentIds); | ||
| 94 | const cachedShellSnapshotMetadata = useCachedShellSnapshotMetadata(); | ||
| 95 | |||
| 96 | const projects = useMemo(() => { | ||
| 97 | const scopedProjects: EnvironmentScopedProjectShell[] = []; | ||
| 98 | for (const connection of Object.values(savedConnectionsById)) { | ||
| 99 | const projects = shellSnapshotStates[connection.environmentId]?.data?.projects ?? []; | ||
| 100 | for (const project of projects) { | ||
| 101 | scopedProjects.push(scopeProjectShell(connection.environmentId, project)); | ||
| 102 | } | ||
| 103 | } | ||
| 104 | return Arr.sort(scopedProjects, projectsSortOrder); | ||
| 105 | }, [savedConnectionsById, shellSnapshotStates]); | ||
| 106 | |||
| 107 | const threads = useMemo(() => { | ||
| 108 | const scopedThreads: EnvironmentScopedThreadShell[] = []; | ||
| 109 | for (const connection of Object.values(savedConnectionsById)) { | ||
| 110 | const threads = shellSnapshotStates[connection.environmentId]?.data?.threads ?? []; | ||
| 111 | for (const thread of threads) { | ||
| 112 | scopedThreads.push(scopeThreadShell(connection.environmentId, thread)); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | return Arr.sort(scopedThreads, threadsSortOrder); | ||
| 116 | }, [savedConnectionsById, shellSnapshotStates]); | ||
| 117 | |||
| 118 | const serverConfigByEnvironmentId = useMemo( | ||
| 119 | () => | ||
| 120 | Object.fromEntries( | ||
| 121 | Object.entries(environmentStateById).map(([environmentId, runtime]) => [ | ||
| 122 | environmentId, | ||
| 123 | runtime.serverConfig ?? null, | ||
| 124 | ]), | ||
| 125 | ), | ||
| 126 | [environmentStateById], | ||
| 127 | ); | ||
| 128 | |||
| 129 | const overallConnectionState = useMemo( | ||
| 130 | () => deriveOverallConnectionState(connectedEnvironments), | ||
| 131 | [connectedEnvironments], | ||
| 132 | ); | ||
| 133 | |||
| 134 | const hasRemoteActivity = useMemo( | ||
| 135 | () => | ||
| 136 | threads.some( | ||
| 137 | (thread) => thread.session?.status === "running" || thread.session?.status === "starting", | ||
| 138 | ), | ||
| 139 | [threads], | ||
| 140 | ); | ||
| 141 | |||
| 142 | const state = useMemo<RemoteCatalogState>(() => { | ||
| 143 | const shellSnapshots = Object.values(shellSnapshotStates); | ||
| 144 | const cachedSnapshotReceivedAts: string[] = []; | ||
| 145 | for (const environmentId of catalogEnvironmentIds) { | ||
| 146 | const metadata = cachedShellSnapshotMetadata[environmentId]; | ||
| 147 | if (metadata) { | ||
| 148 | cachedSnapshotReceivedAts.push(metadata.snapshotReceivedAt); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | let shellSnapshotError: string | null = null; | ||
| 152 | for (const snapshot of shellSnapshots) { | ||
| 153 | if (snapshot.error !== null) { | ||
| 154 | shellSnapshotError = snapshot.error; | ||
| 155 | break; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | return { | ||
| 159 | isLoadingSavedConnections: isLoadingSavedConnection, | ||
| 160 | hasSavedConnections: catalogEnvironmentIds.length > 0, | ||
| 161 | hasLoadedShellSnapshot: shellSnapshots.some((snapshot) => snapshot.data !== null), | ||
| 162 | hasPendingShellSnapshot: shellSnapshots.some((snapshot) => snapshot.isPending), | ||
| 163 | hasReadyEnvironment: connectedEnvironments.some( | ||
| 164 | (environment) => environment.connectionState === "ready", | ||
| 165 | ), | ||
| 166 | hasConnectingEnvironment: connectedEnvironments.some( | ||
| 167 | (environment) => | ||
| 168 | environment.connectionState === "connecting" || | ||
| 169 | environment.connectionState === "reconnecting", | ||
| 170 | ), | ||
| 171 | connectionState: connectionState ?? overallConnectionState, | ||
| 172 | connectionError, | ||
| 173 | shellSnapshotError, | ||
| 174 | isUsingCachedData: cachedSnapshotReceivedAts.length > 0, | ||
| 175 | latestCachedSnapshotReceivedAt: | ||
| 176 | Arr.sort(cachedSnapshotReceivedAts, Order.flip(Order.String))[0] ?? null, | ||
| 177 | }; | ||
| 178 | }, [ | ||
| 179 | cachedShellSnapshotMetadata, | ||
| 180 | catalogEnvironmentIds, | ||
| 181 | connectedEnvironments, | ||
| 182 | connectionError, | ||
| 183 | connectionState, | ||
| 184 | isLoadingSavedConnection, | ||
| 185 | overallConnectionState, | ||
| 186 | shellSnapshotStates, | ||
| 187 | ]); | ||
| 188 | |||
| 189 | return { | ||
| 190 | projects, | ||
| 191 | threads, | ||
| 192 | serverConfigByEnvironmentId, | ||
| 193 | connectionState: state.connectionState, | ||
| 194 | connectionError: state.connectionError, | ||
| 195 | state, | ||
| 196 | hasRemoteActivity, | ||
| 197 | }; | ||
| 198 | } | ||
apps/mobile/src/state/use-remote-environment-registry.test.ts deleted-430| ... | @@ -1,430 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | createManagedRelaySession, | ||
| 5 | ManagedRelayDpopSigner, | ||
| 6 | setManagedRelaySession, | ||
| 7 | } from "@t3tools/client-runtime"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import { beforeEach, vi } from "vite-plus/test"; | ||
| 10 | |||
| 11 | const mocks = vi.hoisted(() => { | ||
| 12 | const environmentConnection = { | ||
| 13 | ensureBootstrapped: vi.fn(() => Promise.resolve()), | ||
| 14 | dispose: vi.fn(() => Promise.resolve()), | ||
| 15 | }; | ||
| 16 | const sessionConnection = { | ||
| 17 | dispose: vi.fn(() => Promise.resolve()), | ||
| 18 | reconnect: vi.fn(() => Promise.resolve()), | ||
| 19 | }; | ||
| 20 | const sessionClient = { | ||
| 21 | isHeartbeatFresh: vi.fn(() => false), | ||
| 22 | }; | ||
| 23 | return { | ||
| 24 | environmentConnection, | ||
| 25 | sessionConnection, | ||
| 26 | sessionClient, | ||
| 27 | createEnvironmentConnection: vi.fn(() => environmentConnection), | ||
| 28 | createKnownEnvironment: vi.fn((input: unknown) => input), | ||
| 29 | createWsRpcClient: vi.fn(() => ({ rpc: true })), | ||
| 30 | wsTransportConstructor: vi.fn(), | ||
| 31 | resolveRemoteWebSocketConnectionUrl: vi.fn(() => ({ _tag: "remote-ws-url-effect" })), | ||
| 32 | resolveRemoteDpopWebSocketConnectionUrl: vi.fn(), | ||
| 33 | remoteEndpointUrl: vi.fn((baseUrl: string, path: string) => new URL(path, baseUrl).toString()), | ||
| 34 | createDpopProof: vi.fn(), | ||
| 35 | refreshCloudEnvironmentConnection: vi.fn(), | ||
| 36 | bootstrapRemoteConnection: vi.fn(), | ||
| 37 | clearCachedShellSnapshot: vi.fn(() => Promise.resolve()), | ||
| 38 | clearSavedConnection: vi.fn(() => Promise.resolve()), | ||
| 39 | saveConnection: vi.fn((_connection?: unknown) => Promise.resolve()), | ||
| 40 | saveCachedShellSnapshot: vi.fn(() => Promise.resolve()), | ||
| 41 | mobileRunPromise: vi.fn((_effect?: unknown) => | ||
| 42 | Promise.resolve("wss://desktop.example/ws?wsTicket=token"), | ||
| 43 | ), | ||
| 44 | removeEnvironmentSession: vi.fn(() => null), | ||
| 45 | getEnvironmentSession: vi.fn(() => null), | ||
| 46 | setEnvironmentSession: vi.fn(), | ||
| 47 | notifyEnvironmentConnectionListeners: vi.fn(), | ||
| 48 | unregisterAgentAwarenessConnection: vi.fn(), | ||
| 49 | registerAgentAwarenessConnection: vi.fn(), | ||
| 50 | shellSnapshotInvalidate: vi.fn(), | ||
| 51 | shellSnapshotMarkPending: vi.fn(), | ||
| 52 | environmentRuntimeInvalidate: vi.fn(), | ||
| 53 | environmentRuntimePatch: vi.fn(), | ||
| 54 | clearCachedShellSnapshotMetadata: vi.fn(), | ||
| 55 | invalidateSourceControlDiscoveryForEnvironment: vi.fn(), | ||
| 56 | terminalSessionInvalidateEnvironment: vi.fn(), | ||
| 57 | subscribeTerminalMetadata: vi.fn(() => vi.fn()), | ||
| 58 | terminalDebugLog: vi.fn(), | ||
| 59 | WsTransport: function WsTransport(...args: ReadonlyArray<unknown>) { | ||
| 60 | mocks.wsTransportConstructor(...args); | ||
| 61 | }, | ||
| 62 | }; | ||
| 63 | }); | ||
| 64 | |||
| 65 | vi.mock("react-native", () => ({ | ||
| 66 | Alert: { | ||
| 67 | alert: vi.fn(), | ||
| 68 | }, | ||
| 69 | AppState: { | ||
| 70 | currentState: "active", | ||
| 71 | addEventListener: vi.fn(() => ({ remove: vi.fn() })), | ||
| 72 | }, | ||
| 73 | })); | ||
| 74 | |||
| 75 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ||
| 76 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); | ||
| 77 | return { | ||
| 78 | ...actual, | ||
| 79 | WsTransport: mocks.WsTransport, | ||
| 80 | createEnvironmentConnection: mocks.createEnvironmentConnection, | ||
| 81 | createKnownEnvironment: mocks.createKnownEnvironment, | ||
| 82 | createWsRpcClient: mocks.createWsRpcClient, | ||
| 83 | remoteEndpointUrl: mocks.remoteEndpointUrl, | ||
| 84 | resolveRemoteDpopWebSocketConnectionUrl: mocks.resolveRemoteDpopWebSocketConnectionUrl, | ||
| 85 | resolveRemoteWebSocketConnectionUrl: mocks.resolveRemoteWebSocketConnectionUrl, | ||
| 86 | }; | ||
| 87 | }); | ||
| 88 | |||
| 89 | vi.mock("../lib/connection", async (importOriginal) => ({ | ||
| 90 | ...(await importOriginal<typeof import("../lib/connection")>()), | ||
| 91 | bootstrapRemoteConnection: mocks.bootstrapRemoteConnection, | ||
| 92 | })); | ||
| 93 | |||
| 94 | vi.mock("../features/cloud/linkEnvironment", () => ({ | ||
| 95 | refreshCloudEnvironmentConnection: mocks.refreshCloudEnvironmentConnection, | ||
| 96 | })); | ||
| 97 | |||
| 98 | vi.mock("../lib/storage", () => ({ | ||
| 99 | clearCachedShellSnapshot: mocks.clearCachedShellSnapshot, | ||
| 100 | clearSavedConnection: mocks.clearSavedConnection, | ||
| 101 | loadCachedShellSnapshot: vi.fn(() => Promise.resolve(null)), | ||
| 102 | loadSavedConnections: vi.fn(() => Promise.resolve([])), | ||
| 103 | saveCachedShellSnapshot: mocks.saveCachedShellSnapshot, | ||
| 104 | saveConnection: mocks.saveConnection, | ||
| 105 | })); | ||
| 106 | |||
| 107 | vi.mock("../lib/runtime", () => ({ | ||
| 108 | mobileRuntime: { | ||
| 109 | runPromise: mocks.mobileRunPromise, | ||
| 110 | }, | ||
| 111 | })); | ||
| 112 | |||
| 113 | vi.mock("./environment-session-registry", () => ({ | ||
| 114 | drainEnvironmentSessions: vi.fn(() => []), | ||
| 115 | getEnvironmentSession: mocks.getEnvironmentSession, | ||
| 116 | notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners, | ||
| 117 | removeEnvironmentSession: mocks.removeEnvironmentSession, | ||
| 118 | setEnvironmentSession: mocks.setEnvironmentSession, | ||
| 119 | })); | ||
| 120 | |||
| 121 | vi.mock("../features/agent-awareness/remoteRegistration", () => ({ | ||
| 122 | registerAgentAwarenessConnection: mocks.registerAgentAwarenessConnection, | ||
| 123 | unregisterAgentAwarenessConnection: mocks.unregisterAgentAwarenessConnection, | ||
| 124 | unregisterAllAgentAwarenessConnections: vi.fn(), | ||
| 125 | })); | ||
| 126 | |||
| 127 | vi.mock("../features/terminal/terminalDebugLog", () => ({ | ||
| 128 | terminalDebugLog: mocks.terminalDebugLog, | ||
| 129 | })); | ||
| 130 | |||
| 131 | vi.mock("./use-environment-runtime", () => ({ | ||
| 132 | environmentRuntimeManager: { | ||
| 133 | invalidate: mocks.environmentRuntimeInvalidate, | ||
| 134 | patch: mocks.environmentRuntimePatch, | ||
| 135 | }, | ||
| 136 | useEnvironmentRuntimeStates: vi.fn(() => ({})), | ||
| 137 | })); | ||
| 138 | |||
| 139 | vi.mock("./use-shell-snapshot", () => ({ | ||
| 140 | clearCachedShellSnapshotMetadata: mocks.clearCachedShellSnapshotMetadata, | ||
| 141 | hydrateCachedShellSnapshot: vi.fn(), | ||
| 142 | markShellSnapshotLive: vi.fn(), | ||
| 143 | shellSnapshotManager: { | ||
| 144 | applyEvent: vi.fn(), | ||
| 145 | invalidate: mocks.shellSnapshotInvalidate, | ||
| 146 | markPending: mocks.shellSnapshotMarkPending, | ||
| 147 | syncSnapshot: vi.fn(), | ||
| 148 | }, | ||
| 149 | })); | ||
| 150 | |||
| 151 | vi.mock("./use-source-control-discovery", () => ({ | ||
| 152 | invalidateSourceControlDiscoveryForEnvironment: | ||
| 153 | mocks.invalidateSourceControlDiscoveryForEnvironment, | ||
| 154 | resetSourceControlDiscoveryState: vi.fn(), | ||
| 155 | })); | ||
| 156 | |||
| 157 | vi.mock("./use-terminal-session", () => ({ | ||
| 158 | subscribeTerminalMetadata: mocks.subscribeTerminalMetadata, | ||
| 159 | terminalSessionManager: { | ||
| 160 | invalidate: vi.fn(), | ||
| 161 | invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment, | ||
| 162 | }, | ||
| 163 | })); | ||
| 164 | |||
| 165 | import { | ||
| 166 | connectSavedEnvironment, | ||
| 167 | disconnectEnvironment, | ||
| 168 | reconnectEnvironmentConnectionsAfterAppResume, | ||
| 169 | } from "./use-remote-environment-registry"; | ||
| 170 | import { appAtomRegistry } from "./atom-registry"; | ||
| 171 | |||
| 172 | const environmentId = EnvironmentId.make("env-mobile-test"); | ||
| 173 | |||
| 174 | const connection = { | ||
| 175 | environmentId, | ||
| 176 | environmentLabel: "Mobile Test Desktop", | ||
| 177 | pairingUrl: "https://desktop.example/", | ||
| 178 | displayUrl: "https://desktop.example/", | ||
| 179 | httpBaseUrl: "https://desktop.example/", | ||
| 180 | wsBaseUrl: "wss://desktop.example/", | ||
| 181 | bearerToken: "remote-access-token", | ||
| 182 | } as const; | ||
| 183 | |||
| 184 | describe("mobile remote environment registry effects", () => { | ||
| 185 | beforeEach(() => { | ||
| 186 | vi.clearAllMocks(); | ||
| 187 | mocks.createEnvironmentConnection.mockReturnValue(mocks.environmentConnection); | ||
| 188 | mocks.environmentConnection.ensureBootstrapped.mockResolvedValue(undefined); | ||
| 189 | mocks.environmentConnection.dispose.mockResolvedValue(undefined); | ||
| 190 | mocks.sessionConnection.dispose.mockResolvedValue(undefined); | ||
| 191 | mocks.sessionConnection.reconnect.mockResolvedValue(undefined); | ||
| 192 | mocks.sessionClient.isHeartbeatFresh.mockReturnValue(false); | ||
| 193 | mocks.removeEnvironmentSession.mockReturnValue(null); | ||
| 194 | mocks.getEnvironmentSession.mockReturnValue(null); | ||
| 195 | mocks.mobileRunPromise.mockResolvedValue("wss://desktop.example/ws?wsTicket=token"); | ||
| 196 | mocks.createDpopProof.mockReturnValue(Effect.succeed("dpop-proof")); | ||
| 197 | mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.die("unexpected refresh")); | ||
| 198 | mocks.resolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( | ||
| 199 | Effect.succeed("wss://desktop.example/ws?wsTicket=dpop-token"), | ||
| 200 | ); | ||
| 201 | setManagedRelaySession(appAtomRegistry, null); | ||
| 202 | }); | ||
| 203 | |||
| 204 | it.effect("connects a saved managed endpoint environment through Effect-wrapped APIs", () => | ||
| 205 | Effect.gen(function* () { | ||
| 206 | yield* connectSavedEnvironment(connection); | ||
| 207 | |||
| 208 | expect(mocks.saveConnection).toHaveBeenCalledWith(connection); | ||
| 209 | expect(mocks.wsTransportConstructor).toHaveBeenCalledTimes(1); | ||
| 210 | expect(mocks.createEnvironmentConnection).toHaveBeenCalledTimes(1); | ||
| 211 | expect(mocks.setEnvironmentSession).toHaveBeenCalledWith( | ||
| 212 | connection.environmentId, | ||
| 213 | expect.objectContaining({ | ||
| 214 | connection: mocks.environmentConnection, | ||
| 215 | }), | ||
| 216 | ); | ||
| 217 | expect(mocks.subscribeTerminalMetadata).toHaveBeenCalledWith( | ||
| 218 | expect.objectContaining({ environmentId: connection.environmentId }), | ||
| 219 | ); | ||
| 220 | expect(mocks.registerAgentAwarenessConnection).toHaveBeenCalledWith(connection); | ||
| 221 | expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); | ||
| 222 | }), | ||
| 223 | ); | ||
| 224 | |||
| 225 | it.effect("uses DPoP-bound admission for a managed DPoP connection", () => | ||
| 226 | Effect.gen(function* () { | ||
| 227 | const dpopConnection = { | ||
| 228 | ...connection, | ||
| 229 | bearerToken: null, | ||
| 230 | authenticationMethod: "dpop", | ||
| 231 | dpopAccessToken: "environment-dpop-token", | ||
| 232 | } as const; | ||
| 233 | mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => | ||
| 234 | Effect.runPromise( | ||
| 235 | (effect as Effect.Effect<string, unknown, ManagedRelayDpopSigner>).pipe( | ||
| 236 | Effect.provideService( | ||
| 237 | ManagedRelayDpopSigner, | ||
| 238 | ManagedRelayDpopSigner.of({ | ||
| 239 | thumbprint: Effect.succeed("mobile-key-thumbprint"), | ||
| 240 | createProof: mocks.createDpopProof, | ||
| 241 | }), | ||
| 242 | ), | ||
| 243 | ), | ||
| 244 | ), | ||
| 245 | ); | ||
| 246 | |||
| 247 | yield* connectSavedEnvironment(dpopConnection); | ||
| 248 | const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as | ||
| 249 | | (() => Promise<string>) | ||
| 250 | | undefined; | ||
| 251 | expect(openSocket).toBeDefined(); | ||
| 252 | yield* Effect.promise(() => openSocket!()); | ||
| 253 | |||
| 254 | expect(mocks.createDpopProof).toHaveBeenCalledWith({ | ||
| 255 | method: "POST", | ||
| 256 | url: "https://desktop.example/api/auth/websocket-ticket", | ||
| 257 | accessToken: "environment-dpop-token", | ||
| 258 | }); | ||
| 259 | expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ | ||
| 260 | wsBaseUrl: dpopConnection.wsBaseUrl, | ||
| 261 | httpBaseUrl: dpopConnection.httpBaseUrl, | ||
| 262 | accessToken: "environment-dpop-token", | ||
| 263 | dpopProof: "dpop-proof", | ||
| 264 | }); | ||
| 265 | expect(mocks.resolveRemoteWebSocketConnectionUrl).not.toHaveBeenCalled(); | ||
| 266 | }), | ||
| 267 | ); | ||
| 268 | |||
| 269 | it.effect("refreshes a persisted managed connection before reconnecting", () => | ||
| 270 | Effect.gen(function* () { | ||
| 271 | const savedDpopConnection = { | ||
| 272 | ...connection, | ||
| 273 | bearerToken: null, | ||
| 274 | authenticationMethod: "dpop", | ||
| 275 | relayManaged: true, | ||
| 276 | } as const; | ||
| 277 | const refreshedConnection = { | ||
| 278 | ...savedDpopConnection, | ||
| 279 | displayUrl: "https://rotated-desktop.example/", | ||
| 280 | httpBaseUrl: "https://rotated-desktop.example/", | ||
| 281 | wsBaseUrl: "wss://rotated-desktop.example/", | ||
| 282 | dpopAccessToken: "fresh-environment-dpop-token", | ||
| 283 | } as const; | ||
| 284 | setManagedRelaySession( | ||
| 285 | appAtomRegistry, | ||
| 286 | createManagedRelaySession({ | ||
| 287 | accountId: "account-1", | ||
| 288 | readClerkToken: () => Promise.resolve("fresh-clerk-token"), | ||
| 289 | }), | ||
| 290 | ); | ||
| 291 | mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.succeed(refreshedConnection)); | ||
| 292 | mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => | ||
| 293 | Effect.runPromise( | ||
| 294 | (effect as Effect.Effect<string, unknown, ManagedRelayDpopSigner>).pipe( | ||
| 295 | Effect.provideService( | ||
| 296 | ManagedRelayDpopSigner, | ||
| 297 | ManagedRelayDpopSigner.of({ | ||
| 298 | thumbprint: Effect.succeed("mobile-key-thumbprint"), | ||
| 299 | createProof: mocks.createDpopProof, | ||
| 300 | }), | ||
| 301 | ), | ||
| 302 | ), | ||
| 303 | ), | ||
| 304 | ); | ||
| 305 | |||
| 306 | yield* connectSavedEnvironment(savedDpopConnection, { persist: false }); | ||
| 307 | const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as | ||
| 308 | | (() => Promise<string>) | ||
| 309 | | undefined; | ||
| 310 | expect(openSocket).toBeDefined(); | ||
| 311 | yield* Effect.promise(() => openSocket!()); | ||
| 312 | |||
| 313 | expect(mocks.refreshCloudEnvironmentConnection).toHaveBeenCalledWith({ | ||
| 314 | clerkToken: "fresh-clerk-token", | ||
| 315 | connection: savedDpopConnection, | ||
| 316 | }); | ||
| 317 | const persistedConnection = mocks.saveConnection.mock.calls[0]?.[0]; | ||
| 318 | expect(persistedConnection).toMatchObject({ | ||
| 319 | ...savedDpopConnection, | ||
| 320 | displayUrl: refreshedConnection.displayUrl, | ||
| 321 | httpBaseUrl: refreshedConnection.httpBaseUrl, | ||
| 322 | wsBaseUrl: refreshedConnection.wsBaseUrl, | ||
| 323 | }); | ||
| 324 | expect(persistedConnection).not.toHaveProperty("dpopAccessToken"); | ||
| 325 | expect(mocks.createDpopProof).toHaveBeenCalledWith({ | ||
| 326 | method: "POST", | ||
| 327 | url: "https://rotated-desktop.example/api/auth/websocket-ticket", | ||
| 328 | accessToken: "fresh-environment-dpop-token", | ||
| 329 | }); | ||
| 330 | expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ | ||
| 331 | wsBaseUrl: refreshedConnection.wsBaseUrl, | ||
| 332 | httpBaseUrl: refreshedConnection.httpBaseUrl, | ||
| 333 | accessToken: "fresh-environment-dpop-token", | ||
| 334 | dpopProof: "dpop-proof", | ||
| 335 | }); | ||
| 336 | }), | ||
| 337 | ); | ||
| 338 | |||
| 339 | it.effect("fails interactive connects when the managed endpoint bootstrap fails", () => | ||
| 340 | Effect.gen(function* () { | ||
| 341 | mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( | ||
| 342 | new Error("bootstrap failed"), | ||
| 343 | ); | ||
| 344 | mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ | ||
| 345 | connection: mocks.sessionConnection, | ||
| 346 | } as never); | ||
| 347 | |||
| 348 | const result = yield* Effect.exit(connectSavedEnvironment(connection)); | ||
| 349 | |||
| 350 | expect(result._tag).toBe("Failure"); | ||
| 351 | expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( | ||
| 352 | { environmentId: connection.environmentId }, | ||
| 353 | expect.any(Function), | ||
| 354 | ); | ||
| 355 | expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); | ||
| 356 | expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); | ||
| 357 | expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); | ||
| 358 | }), | ||
| 359 | ); | ||
| 360 | |||
| 361 | it.effect("can suppress bootstrap failures during best-effort startup reconnect", () => | ||
| 362 | Effect.gen(function* () { | ||
| 363 | mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( | ||
| 364 | new Error("bootstrap failed"), | ||
| 365 | ); | ||
| 366 | mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ | ||
| 367 | connection: mocks.sessionConnection, | ||
| 368 | } as never); | ||
| 369 | |||
| 370 | yield* connectSavedEnvironment(connection, { | ||
| 371 | persist: false, | ||
| 372 | suppressBootstrapError: true, | ||
| 373 | }); | ||
| 374 | |||
| 375 | expect(mocks.saveConnection).not.toHaveBeenCalled(); | ||
| 376 | expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); | ||
| 377 | expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); | ||
| 378 | expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); | ||
| 379 | expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); | ||
| 380 | expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( | ||
| 381 | { environmentId: connection.environmentId }, | ||
| 382 | expect.any(Function), | ||
| 383 | ); | ||
| 384 | }), | ||
| 385 | ); | ||
| 386 | |||
| 387 | it.effect("reconnects a stale saved environment session after app resume", () => | ||
| 388 | Effect.gen(function* () { | ||
| 389 | yield* connectSavedEnvironment(connection); | ||
| 390 | vi.clearAllMocks(); | ||
| 391 | mocks.getEnvironmentSession.mockReturnValue({ | ||
| 392 | client: mocks.sessionClient, | ||
| 393 | connection: mocks.sessionConnection, | ||
| 394 | } as never); | ||
| 395 | |||
| 396 | reconnectEnvironmentConnectionsAfterAppResume("test"); | ||
| 397 | |||
| 398 | yield* Effect.promise(() => | ||
| 399 | vi.waitFor(() => { | ||
| 400 | expect(mocks.sessionConnection.reconnect).toHaveBeenCalledTimes(1); | ||
| 401 | }), | ||
| 402 | ); | ||
| 403 | expect(mocks.shellSnapshotMarkPending).toHaveBeenCalledWith({ | ||
| 404 | environmentId: connection.environmentId, | ||
| 405 | }); | ||
| 406 | expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( | ||
| 407 | { environmentId: connection.environmentId }, | ||
| 408 | expect.any(Function), | ||
| 409 | ); | ||
| 410 | }), | ||
| 411 | ); | ||
| 412 | |||
| 413 | it.effect("disconnects and removes persisted managed endpoint state when requested", () => | ||
| 414 | Effect.gen(function* () { | ||
| 415 | mocks.removeEnvironmentSession.mockReturnValue({ | ||
| 416 | connection: mocks.sessionConnection, | ||
| 417 | } as never); | ||
| 418 | |||
| 419 | yield* disconnectEnvironment(connection.environmentId, { removeSaved: true }); | ||
| 420 | |||
| 421 | expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); | ||
| 422 | expect(mocks.unregisterAgentAwarenessConnection).toHaveBeenCalledWith( | ||
| 423 | connection.environmentId, | ||
| 424 | ); | ||
| 425 | expect(mocks.clearSavedConnection).toHaveBeenCalledWith(connection.environmentId); | ||
| 426 | expect(mocks.clearCachedShellSnapshot).toHaveBeenCalledWith(connection.environmentId); | ||
| 427 | expect(mocks.clearCachedShellSnapshotMetadata).toHaveBeenCalledWith(connection.environmentId); | ||
| 428 | }), | ||
| 429 | ); | ||
| 430 | }); | ||
apps/mobile/src/state/use-remote-environment-registry.ts+160-713| ... | @@ -1,90 +1,26 @@ | ... | @@ -1,90 +1,26 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useEffect, useMemo } from "react"; | 2 | import type { PreparedConnection } from "@t3tools/client-runtime/connection"; |
| 3 | import { Alert, AppState } from "react-native"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type EnvironmentRuntimeState, | ||
| 7 | createEnvironmentConnection, | ||
| 8 | createEnvironmentConnectionAttemptRegistry, | ||
| 9 | createKnownEnvironment, | ||
| 10 | createWsRpcClient, | ||
| 11 | EnvironmentConnectionState, | ||
| 12 | ManagedRelayDpopSigner, | ||
| 13 | WsTransport, | ||
| 14 | remoteEndpointUrl, | ||
| 15 | resolveRemoteDpopWebSocketConnectionUrl, | ||
| 16 | resolveRemoteWebSocketConnectionUrl, | ||
| 17 | waitForManagedRelayClerkToken, | ||
| 18 | } from "@t3tools/client-runtime"; | ||
| 19 | import type { EnvironmentId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 20 | import * as Arr from "effect/Array"; | 4 | import type { ServerConfig } from "@t3tools/contracts"; |
| 21 | import * as Duration from "effect/Duration"; | 5 | import * as Cause from "effect/Cause"; |
| 22 | import * as Effect from "effect/Effect"; | ||
| 23 | import * as Order from "effect/Order"; | ||
| 24 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 25 | import { pipe } from "effect/Function"; | 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 26 | import { Atom } from "effect/unstable/reactivity"; | 8 | import { useCallback, useMemo } from "react"; |
| 27 | import { | 9 | import { Alert } from "react-native"; |
| 28 | type SavedRemoteConnection, | 10 | |
| 29 | bootstrapRemoteConnection, | 11 | import { useEnvironmentServerConfig } from "../state/entities"; |
| 30 | isRelayManagedConnection, | 12 | import { useConnectionController } from "../features/connection/useConnectionController"; |
| 31 | toStableSavedRemoteConnection, | 13 | import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; |
| 32 | } from "../lib/connection"; | ||
| 33 | import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment"; | ||
| 34 | import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; | ||
| 35 | import { | 14 | import { |
| 36 | clearCachedShellSnapshot, | 15 | projectEnvironmentPresentation, |
| 37 | clearSavedConnection, | 16 | type EnvironmentPresentation, |
| 38 | loadCachedShellSnapshot, | 17 | } from "../state/environments"; |
| 39 | loadSavedConnections, | 18 | import { useWorkspaceState } from "../state/workspace"; |
| 40 | saveCachedShellSnapshot, | 19 | import type { SavedRemoteConnection } from "../lib/connection"; |
| 41 | saveConnection, | ||
| 42 | } from "../lib/storage"; | ||
| 43 | import { appAtomRegistry } from "./atom-registry"; | 20 | import { appAtomRegistry } from "./atom-registry"; |
| 44 | import { mobileRuntime } from "../lib/runtime"; | 21 | import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types"; |
| 45 | import { | 22 | import { environmentSession, usePreparedConnection } from "./session"; |
| 46 | drainEnvironmentSessions, | 23 | import { environmentCatalog } from "../connection/catalog"; |
| 47 | getEnvironmentSession, | ||
| 48 | notifyEnvironmentConnectionListeners, | ||
| 49 | removeEnvironmentSession, | ||
| 50 | setEnvironmentSession, | ||
| 51 | } from "./environment-session-registry"; | ||
| 52 | import { type ConnectedEnvironmentSummary } from "./remote-runtime-types"; | ||
| 53 | import { | ||
| 54 | invalidateSourceControlDiscoveryForEnvironment, | ||
| 55 | resetSourceControlDiscoveryState, | ||
| 56 | } from "./use-source-control-discovery"; | ||
| 57 | import { | ||
| 58 | registerAgentAwarenessConnection, | ||
| 59 | unregisterAgentAwarenessConnection, | ||
| 60 | unregisterAllAgentAwarenessConnections, | ||
| 61 | } from "../features/agent-awareness/remoteRegistration"; | ||
| 62 | import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; | ||
| 63 | import { | ||
| 64 | clearCachedShellSnapshotMetadata, | ||
| 65 | hydrateCachedShellSnapshot, | ||
| 66 | markShellSnapshotLive, | ||
| 67 | shellSnapshotManager, | ||
| 68 | } from "./use-shell-snapshot"; | ||
| 69 | import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session"; | ||
| 70 | |||
| 71 | const terminalMetadataUnsubscribers = new Map<EnvironmentId, () => void>(); | ||
| 72 | const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); | ||
| 73 | const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000; | ||
| 74 | const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000; | ||
| 75 | let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY; | ||
| 76 | |||
| 77 | interface RemoteEnvironmentLocalState { | ||
| 78 | readonly isLoadingSavedConnection: boolean; | ||
| 79 | readonly connectionPairingUrl: string; | ||
| 80 | readonly pendingConnectionError: string | null; | ||
| 81 | readonly savedConnectionsById: Record<EnvironmentId, SavedRemoteConnection>; | ||
| 82 | } | ||
| 83 | |||
| 84 | const isLoadingSavedConnectionAtom = Atom.make(true).pipe( | ||
| 85 | Atom.keepAlive, | ||
| 86 | Atom.withLabel("mobile:is-loading-saved-connection"), | ||
| 87 | ); | ||
| 88 | 24 | ||
| 89 | const connectionPairingUrlAtom = Atom.make("").pipe( | 25 | const connectionPairingUrlAtom = Atom.make("").pipe( |
| 90 | Atom.keepAlive, | 26 | Atom.keepAlive, |
| ... | @@ -96,680 +32,191 @@ const pendingConnectionErrorAtom = Atom.make<string | null>(null).pipe( | ... | @@ -96,680 +32,191 @@ const pendingConnectionErrorAtom = Atom.make<string | null>(null).pipe( |
| 96 | Atom.withLabel("mobile:pending-connection-error"), | 32 | Atom.withLabel("mobile:pending-connection-error"), |
| 97 | ); | 33 | ); |
| 98 | 34 | ||
| 99 | const savedConnectionsByIdAtom = Atom.make<Record<EnvironmentId, SavedRemoteConnection>>({}).pipe( | ||
| 100 | Atom.keepAlive, | ||
| 101 | Atom.withLabel("mobile:saved-connections"), | ||
| 102 | ); | ||
| 103 | |||
| 104 | function getSavedConnectionsById(): Record<EnvironmentId, SavedRemoteConnection> { | ||
| 105 | return appAtomRegistry.get(savedConnectionsByIdAtom); | ||
| 106 | } | ||
| 107 | |||
| 108 | function setIsLoadingSavedConnection(value: boolean): void { | ||
| 109 | appAtomRegistry.set(isLoadingSavedConnectionAtom, value); | ||
| 110 | } | ||
| 111 | |||
| 112 | function setConnectionPairingUrl(pairingUrl: string): void { | ||
| 113 | appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); | ||
| 114 | } | ||
| 115 | |||
| 116 | function clearConnectionPairingUrl(): void { | ||
| 117 | appAtomRegistry.set(connectionPairingUrlAtom, ""); | ||
| 118 | } | ||
| 119 | |||
| 120 | export function setPendingConnectionError(message: string | null): void { | 35 | export function setPendingConnectionError(message: string | null): void { |
| 121 | appAtomRegistry.set(pendingConnectionErrorAtom, message); | 36 | appAtomRegistry.set(pendingConnectionErrorAtom, message); |
| 122 | } | 37 | } |
| 123 | 38 | ||
| 124 | function clearPendingConnectionError(): void { | 39 | function toSavedConnection( |
| 125 | appAtomRegistry.set(pendingConnectionErrorAtom, null); | 40 | environment: EnvironmentPresentation, |
| 126 | } | 41 | prepared: Option.Option<PreparedConnection>, |
| 42 | ): SavedRemoteConnection { | ||
| 43 | const displayUrl = environment.displayUrl ?? ""; | ||
| 44 | const active = Option.getOrNull(prepared); | ||
| 45 | const httpBaseUrl = active?.httpBaseUrl ?? displayUrl; | ||
| 46 | const socketUrl = active?.socketUrl ?? ""; | ||
| 47 | const wsBaseUrl = | ||
| 48 | socketUrl === "" | ||
| 49 | ? displayUrl.startsWith("https://") | ||
| 50 | ? displayUrl.replace(/^https:/, "wss:") | ||
| 51 | : displayUrl.replace(/^http:/, "ws:") | ||
| 52 | : new URL(socketUrl).origin; | ||
| 53 | const authorization = active?.httpAuthorization ?? null; | ||
| 127 | 54 | ||
| 128 | function replaceSavedConnections(connections: Record<EnvironmentId, SavedRemoteConnection>): void { | 55 | return { |
| 129 | appAtomRegistry.set(savedConnectionsByIdAtom, connections); | 56 | environmentId: environment.environmentId, |
| 130 | } | 57 | environmentLabel: environment.label, |
| 131 | 58 | pairingUrl: displayUrl, | |
| 132 | function upsertSavedConnection(connection: SavedRemoteConnection): void { | 59 | displayUrl, |
| 133 | const current = appAtomRegistry.get(savedConnectionsByIdAtom); | 60 | httpBaseUrl, |
| 134 | appAtomRegistry.set(savedConnectionsByIdAtom, { | 61 | wsBaseUrl, |
| 135 | ...current, | 62 | bearerToken: authorization?._tag === "Bearer" ? authorization.token : null, |
| 136 | [connection.environmentId]: connection, | 63 | ...(environment.relayManaged |
| 137 | }); | 64 | ? { |
| 65 | authenticationMethod: "dpop" as const, | ||
| 66 | relayManaged: true as const, | ||
| 67 | ...(authorization?._tag === "Dpop" ? { dpopAccessToken: authorization.accessToken } : {}), | ||
| 68 | } | ||
| 69 | : { authenticationMethod: "bearer" as const }), | ||
| 70 | }; | ||
| 138 | } | 71 | } |
| 139 | 72 | ||
| 140 | function removeSavedConnection(environmentId: EnvironmentId): void { | 73 | const savedConnectionsByIdAtom = Atom.make((get) => { |
| 141 | const current = appAtomRegistry.get(savedConnectionsByIdAtom); | 74 | const presentationById = get(environmentPresentations.presentationsAtom); |
| 142 | const next = { ...current }; | 75 | return Object.fromEntries( |
| 143 | delete next[environmentId]; | 76 | [...presentationById.entries()].map(([environmentId, presentation]) => [ |
| 144 | appAtomRegistry.set(savedConnectionsByIdAtom, next); | 77 | environmentId, |
| 78 | toSavedConnection( | ||
| 79 | projectEnvironmentPresentation(environmentId, presentation), | ||
| 80 | get(environmentSession.preparedConnectionValueAtom(environmentId)), | ||
| 81 | ), | ||
| 82 | ]), | ||
| 83 | ) as Record<EnvironmentId, SavedRemoteConnection>; | ||
| 84 | }).pipe(Atom.withLabel("mobile:saved-connections-by-id")); | ||
| 85 | |||
| 86 | function toRuntimeState( | ||
| 87 | environment: EnvironmentPresentation, | ||
| 88 | serverConfig: ServerConfig | null, | ||
| 89 | ): EnvironmentRuntimeState { | ||
| 90 | return { | ||
| 91 | connectionState: environment.connection.phase, | ||
| 92 | connectionError: environment.connection.error, | ||
| 93 | connectionErrorTraceId: environment.connection.traceId, | ||
| 94 | serverConfig, | ||
| 95 | }; | ||
| 145 | } | 96 | } |
| 146 | 97 | ||
| 147 | function useRemoteEnvironmentLocalState(): RemoteEnvironmentLocalState { | 98 | export function useSavedRemoteConnections() { |
| 148 | const isLoadingSavedConnection = useAtomValue(isLoadingSavedConnectionAtom); | 99 | const catalog = useAtomValue(environmentCatalog.catalogValueAtom); |
| 149 | const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); | ||
| 150 | const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); | ||
| 151 | const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom); | 100 | const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom); |
| 152 | 101 | ||
| 153 | return useMemo( | 102 | return { |
| 154 | () => ({ | 103 | isLoadingSavedConnection: !catalog.isReady, |
| 155 | isLoadingSavedConnection, | 104 | savedConnectionsById, |
| 156 | connectionPairingUrl, | 105 | }; |
| 157 | pendingConnectionError, | ||
| 158 | savedConnectionsById, | ||
| 159 | }), | ||
| 160 | [connectionPairingUrl, isLoadingSavedConnection, pendingConnectionError, savedConnectionsById], | ||
| 161 | ); | ||
| 162 | } | ||
| 163 | |||
| 164 | function setEnvironmentConnectionStatus( | ||
| 165 | environmentId: EnvironmentId, | ||
| 166 | state: ConnectedEnvironmentSummary["connectionState"], | ||
| 167 | error?: string | null, | ||
| 168 | ) { | ||
| 169 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 170 | ...current, | ||
| 171 | connectionState: state, | ||
| 172 | connectionError: error === undefined ? current.connectionError : error, | ||
| 173 | })); | ||
| 174 | } | ||
| 175 | |||
| 176 | function fromPromise<T>(tryPromise: () => Promise<T>): Effect.Effect<T, unknown> { | ||
| 177 | return Effect.tryPromise({ | ||
| 178 | try: tryPromise, | ||
| 179 | catch: (cause) => cause, | ||
| 180 | }); | ||
| 181 | } | ||
| 182 | |||
| 183 | export function disconnectEnvironment( | ||
| 184 | environmentId: EnvironmentId, | ||
| 185 | options?: { | ||
| 186 | readonly preserveShellSnapshot?: boolean; | ||
| 187 | readonly removeSaved?: boolean; | ||
| 188 | readonly preserveConnectionAttempt?: boolean; | ||
| 189 | }, | ||
| 190 | ): Effect.Effect<void, unknown> { | ||
| 191 | return Effect.gen(function* () { | ||
| 192 | if (!options?.preserveConnectionAttempt) { | ||
| 193 | environmentConnectionAttempts.cancel(environmentId); | ||
| 194 | } | ||
| 195 | |||
| 196 | const session = removeEnvironmentSession(environmentId); | ||
| 197 | notifyEnvironmentConnectionListeners(); | ||
| 198 | if (session) { | ||
| 199 | yield* fromPromise(() => session.connection.dispose()); | ||
| 200 | } | ||
| 201 | terminalMetadataUnsubscribers.get(environmentId)?.(); | ||
| 202 | terminalMetadataUnsubscribers.delete(environmentId); | ||
| 203 | unregisterAgentAwarenessConnection(environmentId); | ||
| 204 | if (!options?.preserveShellSnapshot) { | ||
| 205 | shellSnapshotManager.invalidate({ environmentId }); | ||
| 206 | } | ||
| 207 | invalidateSourceControlDiscoveryForEnvironment(environmentId); | ||
| 208 | terminalSessionManager.invalidateEnvironment(environmentId); | ||
| 209 | environmentRuntimeManager.invalidate({ environmentId }); | ||
| 210 | |||
| 211 | if (options?.removeSaved) { | ||
| 212 | yield* Effect.all( | ||
| 213 | [ | ||
| 214 | fromPromise(() => clearSavedConnection(environmentId)), | ||
| 215 | fromPromise(() => clearCachedShellSnapshot(environmentId)), | ||
| 216 | ], | ||
| 217 | { concurrency: 2 }, | ||
| 218 | ); | ||
| 219 | clearCachedShellSnapshotMetadata(environmentId); | ||
| 220 | removeSavedConnection(environmentId); | ||
| 221 | } | ||
| 222 | }); | ||
| 223 | } | ||
| 224 | |||
| 225 | export function connectSavedEnvironment( | ||
| 226 | connection: SavedRemoteConnection, | ||
| 227 | options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean }, | ||
| 228 | ): Effect.Effect<void, unknown> { | ||
| 229 | return Effect.gen(function* () { | ||
| 230 | const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); | ||
| 231 | const isCurrentAttempt = connectionAttempt.isCurrent; | ||
| 232 | let activeConnection = connection; | ||
| 233 | let initialDpopAccessToken = | ||
| 234 | options?.persist === false ? undefined : connection.dpopAccessToken; | ||
| 235 | |||
| 236 | yield* disconnectEnvironment(connection.environmentId, { | ||
| 237 | preserveShellSnapshot: true, | ||
| 238 | preserveConnectionAttempt: true, | ||
| 239 | }); | ||
| 240 | if (!isCurrentAttempt()) { | ||
| 241 | return; | ||
| 242 | } | ||
| 243 | |||
| 244 | if (options?.persist !== false) { | ||
| 245 | yield* fromPromise(() => saveConnection(toStableSavedRemoteConnection(connection))); | ||
| 246 | if (!isCurrentAttempt()) { | ||
| 247 | return; | ||
| 248 | } | ||
| 249 | } | ||
| 250 | |||
| 251 | upsertSavedConnection(toStableSavedRemoteConnection(connection)); | ||
| 252 | setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); | ||
| 253 | shellSnapshotManager.markPending({ environmentId: connection.environmentId }); | ||
| 254 | |||
| 255 | const transport = new WsTransport( | ||
| 256 | () => | ||
| 257 | mobileRuntime.runPromise( | ||
| 258 | isRelayManagedConnection(connection) | ||
| 259 | ? Effect.gen(function* () { | ||
| 260 | let dpopAccessToken = initialDpopAccessToken; | ||
| 261 | initialDpopAccessToken = undefined; | ||
| 262 | if (!dpopAccessToken) { | ||
| 263 | const clerkToken = yield* waitForManagedRelayClerkToken(appAtomRegistry); | ||
| 264 | const refreshedConnection = yield* refreshCloudEnvironmentConnection({ | ||
| 265 | clerkToken, | ||
| 266 | connection: activeConnection, | ||
| 267 | }); | ||
| 268 | const stableConnection = toStableSavedRemoteConnection(refreshedConnection); | ||
| 269 | activeConnection = refreshedConnection; | ||
| 270 | if (isCurrentAttempt()) { | ||
| 271 | yield* fromPromise(() => saveConnection(stableConnection)); | ||
| 272 | upsertSavedConnection(stableConnection); | ||
| 273 | } | ||
| 274 | dpopAccessToken = refreshedConnection.dpopAccessToken; | ||
| 275 | } | ||
| 276 | if (!dpopAccessToken) { | ||
| 277 | return yield* Effect.fail( | ||
| 278 | new Error("Managed environment connection did not return a DPoP access token."), | ||
| 279 | ); | ||
| 280 | } | ||
| 281 | const signer = yield* ManagedRelayDpopSigner; | ||
| 282 | const dpop = yield* signer.createProof({ | ||
| 283 | method: "POST", | ||
| 284 | url: remoteEndpointUrl( | ||
| 285 | activeConnection.httpBaseUrl, | ||
| 286 | "/api/auth/websocket-ticket", | ||
| 287 | ), | ||
| 288 | accessToken: dpopAccessToken, | ||
| 289 | }); | ||
| 290 | return yield* resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 291 | wsBaseUrl: activeConnection.wsBaseUrl, | ||
| 292 | httpBaseUrl: activeConnection.httpBaseUrl, | ||
| 293 | accessToken: dpopAccessToken, | ||
| 294 | dpopProof: dpop, | ||
| 295 | }); | ||
| 296 | }) | ||
| 297 | : resolveRemoteWebSocketConnectionUrl({ | ||
| 298 | wsBaseUrl: connection.wsBaseUrl, | ||
| 299 | httpBaseUrl: connection.httpBaseUrl, | ||
| 300 | bearerToken: connection.bearerToken ?? "", | ||
| 301 | }), | ||
| 302 | ), | ||
| 303 | { | ||
| 304 | onAttempt: () => { | ||
| 305 | if (!isCurrentAttempt()) { | ||
| 306 | return; | ||
| 307 | } | ||
| 308 | |||
| 309 | environmentRuntimeManager.patch( | ||
| 310 | { environmentId: connection.environmentId }, | ||
| 311 | (previous) => { | ||
| 312 | const nextState = | ||
| 313 | previous.connectionState === "ready" || previous.connectionState === "reconnecting" | ||
| 314 | ? "reconnecting" | ||
| 315 | : "connecting"; | ||
| 316 | const keepSettledFailure = | ||
| 317 | previous.connectionState === "disconnected" && previous.connectionError !== null; | ||
| 318 | return { | ||
| 319 | ...previous, | ||
| 320 | connectionState: keepSettledFailure ? "disconnected" : nextState, | ||
| 321 | connectionError: keepSettledFailure ? previous.connectionError : null, | ||
| 322 | }; | ||
| 323 | }, | ||
| 324 | ); | ||
| 325 | }, | ||
| 326 | onError: (message) => { | ||
| 327 | if (isCurrentAttempt()) { | ||
| 328 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); | ||
| 329 | } | ||
| 330 | }, | ||
| 331 | onClose: (details) => { | ||
| 332 | if (!isCurrentAttempt()) { | ||
| 333 | return; | ||
| 334 | } | ||
| 335 | |||
| 336 | const reason = | ||
| 337 | details.reason.trim().length > 0 | ||
| 338 | ? details.reason | ||
| 339 | : details.code === 1000 | ||
| 340 | ? null | ||
| 341 | : `Remote connection closed (${details.code}).`; | ||
| 342 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); | ||
| 343 | }, | ||
| 344 | }, | ||
| 345 | ); | ||
| 346 | |||
| 347 | const client = createWsRpcClient(transport); | ||
| 348 | const environmentConnection = createEnvironmentConnection({ | ||
| 349 | kind: "saved", | ||
| 350 | knownEnvironment: { | ||
| 351 | ...createKnownEnvironment({ | ||
| 352 | id: connection.environmentId, | ||
| 353 | label: connection.environmentLabel, | ||
| 354 | source: "manual", | ||
| 355 | target: { | ||
| 356 | httpBaseUrl: connection.httpBaseUrl, | ||
| 357 | wsBaseUrl: connection.wsBaseUrl, | ||
| 358 | }, | ||
| 359 | }), | ||
| 360 | environmentId: connection.environmentId, | ||
| 361 | }, | ||
| 362 | client, | ||
| 363 | applyShellEvent: (event, environmentId) => { | ||
| 364 | if (isCurrentAttempt()) { | ||
| 365 | shellSnapshotManager.applyEvent({ environmentId }, event); | ||
| 366 | } | ||
| 367 | }, | ||
| 368 | syncShellSnapshot: (snapshot, environmentId) => { | ||
| 369 | if (!isCurrentAttempt()) { | ||
| 370 | return; | ||
| 371 | } | ||
| 372 | |||
| 373 | shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); | ||
| 374 | markShellSnapshotLive(environmentId); | ||
| 375 | void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined); | ||
| 376 | environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ | ||
| 377 | ...runtime, | ||
| 378 | connectionState: "ready", | ||
| 379 | connectionError: null, | ||
| 380 | })); | ||
| 381 | }, | ||
| 382 | onShellResubscribe: (environmentId) => { | ||
| 383 | if (isCurrentAttempt()) { | ||
| 384 | shellSnapshotManager.markPending({ environmentId }); | ||
| 385 | } | ||
| 386 | }, | ||
| 387 | onConfigSnapshot: (serverConfig) => { | ||
| 388 | if (isCurrentAttempt()) { | ||
| 389 | environmentRuntimeManager.patch( | ||
| 390 | { environmentId: connection.environmentId }, | ||
| 391 | (runtime) => ({ | ||
| 392 | ...runtime, | ||
| 393 | serverConfig, | ||
| 394 | }), | ||
| 395 | ); | ||
| 396 | } | ||
| 397 | }, | ||
| 398 | }); | ||
| 399 | |||
| 400 | if (!isCurrentAttempt()) { | ||
| 401 | yield* fromPromise(() => environmentConnection.dispose()); | ||
| 402 | return; | ||
| 403 | } | ||
| 404 | |||
| 405 | setEnvironmentSession(connection.environmentId, { | ||
| 406 | client, | ||
| 407 | connection: environmentConnection, | ||
| 408 | }); | ||
| 409 | |||
| 410 | const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe( | ||
| 411 | Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)), | ||
| 412 | Effect.flatMap((result) => | ||
| 413 | Option.match(result, { | ||
| 414 | onNone: () => | ||
| 415 | Effect.fail(new Error("Environment did not respond before the connection timeout.")), | ||
| 416 | onSome: Effect.succeed, | ||
| 417 | }), | ||
| 418 | ), | ||
| 419 | Effect.tapError((error: unknown) => | ||
| 420 | isCurrentAttempt() | ||
| 421 | ? Effect.gen(function* () { | ||
| 422 | setEnvironmentConnectionStatus( | ||
| 423 | connection.environmentId, | ||
| 424 | "disconnected", | ||
| 425 | error instanceof Error ? error.message : "Failed to bootstrap remote connection.", | ||
| 426 | ); | ||
| 427 | const pendingSession = removeEnvironmentSession(connection.environmentId); | ||
| 428 | notifyEnvironmentConnectionListeners(); | ||
| 429 | if (pendingSession) { | ||
| 430 | yield* fromPromise(() => pendingSession.connection.dispose()); | ||
| 431 | } | ||
| 432 | }) | ||
| 433 | : Effect.void, | ||
| 434 | ), | ||
| 435 | ); | ||
| 436 | const bootstrapped = yield* options?.suppressBootstrapError | ||
| 437 | ? bootstrap.pipe( | ||
| 438 | Effect.as(true), | ||
| 439 | Effect.catch(() => Effect.succeed(false)), | ||
| 440 | ) | ||
| 441 | : bootstrap.pipe(Effect.as(true)); | ||
| 442 | |||
| 443 | if (!bootstrapped || !isCurrentAttempt()) { | ||
| 444 | return; | ||
| 445 | } | ||
| 446 | |||
| 447 | terminalMetadataUnsubscribers.set( | ||
| 448 | connection.environmentId, | ||
| 449 | subscribeTerminalMetadata({ | ||
| 450 | environmentId: connection.environmentId, | ||
| 451 | client, | ||
| 452 | }), | ||
| 453 | ); | ||
| 454 | terminalDebugLog("registry:terminal-metadata-subscribed", { | ||
| 455 | environmentId: connection.environmentId, | ||
| 456 | }); | ||
| 457 | registerAgentAwarenessConnection(toStableSavedRemoteConnection(activeConnection)); | ||
| 458 | notifyEnvironmentConnectionListeners(); | ||
| 459 | }); | ||
| 460 | } | 106 | } |
| 461 | 107 | ||
| 462 | export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void { | 108 | export function useSavedRemoteConnection( |
| 463 | const now = Date.now(); | 109 | environmentId: EnvironmentId | null, |
| 464 | if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) { | 110 | ): SavedRemoteConnection | null { |
| 465 | return; | 111 | const { presentation } = useEnvironmentPresentation(environmentId); |
| 112 | const prepared = usePreparedConnection(environmentId); | ||
| 113 | if (environmentId === null || presentation === null) { | ||
| 114 | return null; | ||
| 466 | } | 115 | } |
| 467 | 116 | return toSavedConnection(projectEnvironmentPresentation(environmentId, presentation), prepared); | |
| 468 | for (const connection of Object.values(getSavedConnectionsById())) { | ||
| 469 | const session = getEnvironmentSession(connection.environmentId); | ||
| 470 | if (session?.client.isHeartbeatFresh()) { | ||
| 471 | continue; | ||
| 472 | } | ||
| 473 | |||
| 474 | lastAppResumeReconnectAt = now; | ||
| 475 | terminalDebugLog("registry:app-resume-reconnect", { | ||
| 476 | environmentId: connection.environmentId, | ||
| 477 | reason, | ||
| 478 | hasSession: session !== null, | ||
| 479 | }); | ||
| 480 | |||
| 481 | if (!session) { | ||
| 482 | void mobileRuntime | ||
| 483 | .runPromise( | ||
| 484 | connectSavedEnvironment(connection, { | ||
| 485 | persist: false, | ||
| 486 | suppressBootstrapError: true, | ||
| 487 | }), | ||
| 488 | ) | ||
| 489 | .catch((error: unknown) => { | ||
| 490 | terminalDebugLog("registry:app-resume-reconnect-failed", { | ||
| 491 | environmentId: connection.environmentId, | ||
| 492 | reason, | ||
| 493 | error: error instanceof Error ? error.message : String(error), | ||
| 494 | }); | ||
| 495 | }); | ||
| 496 | continue; | ||
| 497 | } | ||
| 498 | |||
| 499 | setEnvironmentConnectionStatus(connection.environmentId, "reconnecting", null); | ||
| 500 | shellSnapshotManager.markPending({ environmentId: connection.environmentId }); | ||
| 501 | void session.connection.reconnect().catch((error: unknown) => { | ||
| 502 | const message = | ||
| 503 | error instanceof Error ? error.message : "Failed to reconnect remote environment."; | ||
| 504 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); | ||
| 505 | terminalDebugLog("registry:app-resume-reconnect-failed", { | ||
| 506 | environmentId: connection.environmentId, | ||
| 507 | reason, | ||
| 508 | error: message, | ||
| 509 | }); | ||
| 510 | }); | ||
| 511 | } | ||
| 512 | } | ||
| 513 | |||
| 514 | function subscribeAppResumeReconnects(): () => void { | ||
| 515 | let previousAppState = AppState.currentState; | ||
| 516 | const subscription = AppState.addEventListener("change", (nextAppState) => { | ||
| 517 | const wasInactive = previousAppState !== "active"; | ||
| 518 | previousAppState = nextAppState; | ||
| 519 | if (nextAppState === "active" && wasInactive) { | ||
| 520 | reconnectEnvironmentConnectionsAfterAppResume("appstate"); | ||
| 521 | } | ||
| 522 | }); | ||
| 523 | |||
| 524 | return () => subscription.remove(); | ||
| 525 | } | ||
| 526 | |||
| 527 | const environmentsSortOrder = Order.mapInput( | ||
| 528 | Order.Struct({ | ||
| 529 | environmentLabel: Order.String, | ||
| 530 | }), | ||
| 531 | (environment: ConnectedEnvironmentSummary) => ({ | ||
| 532 | environmentLabel: environment.environmentLabel, | ||
| 533 | }), | ||
| 534 | ); | ||
| 535 | |||
| 536 | function deriveConnectedEnvironments( | ||
| 537 | savedConnectionsById: Record<string, SavedRemoteConnection>, | ||
| 538 | environmentStateById: Record<EnvironmentId, EnvironmentRuntimeState>, | ||
| 539 | ): ReadonlyArray<ConnectedEnvironmentSummary> { | ||
| 540 | return Arr.sort( | ||
| 541 | Object.values(savedConnectionsById).map((connection) => { | ||
| 542 | const runtime = environmentStateById[connection.environmentId]; | ||
| 543 | return { | ||
| 544 | environmentId: connection.environmentId, | ||
| 545 | environmentLabel: connection.environmentLabel, | ||
| 546 | displayUrl: connection.displayUrl, | ||
| 547 | isRelayManaged: isRelayManagedConnection(connection), | ||
| 548 | connectionState: runtime?.connectionState ?? "idle", | ||
| 549 | connectionError: runtime?.connectionError ?? null, | ||
| 550 | }; | ||
| 551 | }), | ||
| 552 | environmentsSortOrder, | ||
| 553 | ); | ||
| 554 | } | ||
| 555 | |||
| 556 | export function useRemoteEnvironmentBootstrap() { | ||
| 557 | useEffect(() => { | ||
| 558 | let cancelled = false; | ||
| 559 | const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects(); | ||
| 560 | |||
| 561 | void (async () => { | ||
| 562 | try { | ||
| 563 | const connections = await loadSavedConnections(); | ||
| 564 | if (cancelled) { | ||
| 565 | return; | ||
| 566 | } | ||
| 567 | |||
| 568 | replaceSavedConnections( | ||
| 569 | Object.fromEntries( | ||
| 570 | connections.map((connection) => [connection.environmentId, connection]), | ||
| 571 | ), | ||
| 572 | ); | ||
| 573 | |||
| 574 | setIsLoadingSavedConnection(false); | ||
| 575 | |||
| 576 | await Promise.all( | ||
| 577 | connections.map(async (connection) => { | ||
| 578 | const cached = await loadCachedShellSnapshot(connection.environmentId); | ||
| 579 | if (!cancelled && cached) { | ||
| 580 | hydrateCachedShellSnapshot(cached); | ||
| 581 | } | ||
| 582 | }), | ||
| 583 | ); | ||
| 584 | |||
| 585 | if (cancelled) { | ||
| 586 | return; | ||
| 587 | } | ||
| 588 | |||
| 589 | await mobileRuntime.runPromise( | ||
| 590 | Effect.all( | ||
| 591 | connections.map((connection) => | ||
| 592 | connectSavedEnvironment(connection, { | ||
| 593 | persist: false, | ||
| 594 | suppressBootstrapError: true, | ||
| 595 | }), | ||
| 596 | ), | ||
| 597 | { concurrency: "unbounded" }, | ||
| 598 | ), | ||
| 599 | ); | ||
| 600 | } catch { | ||
| 601 | if (!cancelled) { | ||
| 602 | setIsLoadingSavedConnection(false); | ||
| 603 | } | ||
| 604 | } | ||
| 605 | })(); | ||
| 606 | |||
| 607 | return () => { | ||
| 608 | cancelled = true; | ||
| 609 | unsubscribeAppResumeReconnects(); | ||
| 610 | for (const session of drainEnvironmentSessions()) { | ||
| 611 | void session.connection.dispose(); | ||
| 612 | } | ||
| 613 | for (const unsubscribe of terminalMetadataUnsubscribers.values()) { | ||
| 614 | unsubscribe(); | ||
| 615 | } | ||
| 616 | terminalMetadataUnsubscribers.clear(); | ||
| 617 | environmentConnectionAttempts.clear(); | ||
| 618 | unregisterAllAgentAwarenessConnections(); | ||
| 619 | environmentRuntimeManager.invalidate(); | ||
| 620 | shellSnapshotManager.invalidate(); | ||
| 621 | resetSourceControlDiscoveryState(); | ||
| 622 | terminalSessionManager.invalidate(); | ||
| 623 | notifyEnvironmentConnectionListeners(); | ||
| 624 | }; | ||
| 625 | }, []); | ||
| 626 | } | 117 | } |
| 627 | 118 | ||
| 628 | export function useRemoteEnvironmentState() { | 119 | export function useRemoteEnvironmentRuntime( |
| 629 | const state = useRemoteEnvironmentLocalState(); | 120 | environmentId: EnvironmentId | null, |
| 630 | const environmentStateById = useEnvironmentRuntimeStates( | 121 | ): EnvironmentRuntimeState | null { |
| 631 | Object.values(state.savedConnectionsById).map((connection) => connection.environmentId), | 122 | const { presentation } = useEnvironmentPresentation(environmentId); |
| 632 | ); | 123 | const serverConfig = useEnvironmentServerConfig(environmentId); |
| 633 | 124 | if (environmentId === null || presentation === null) { | |
| 634 | return useMemo( | 125 | return null; |
| 635 | () => ({ | 126 | } |
| 636 | ...state, | 127 | return toRuntimeState(projectEnvironmentPresentation(environmentId, presentation), serverConfig); |
| 637 | environmentStateById, | ||
| 638 | }), | ||
| 639 | [environmentStateById, state], | ||
| 640 | ); | ||
| 641 | } | 128 | } |
| 642 | 129 | ||
| 643 | export function useRemoteConnectionStatus() { | 130 | export function useRemoteConnectionStatus() { |
| 644 | const { environmentStateById, pendingConnectionError, savedConnectionsById } = | 131 | const workspace = useWorkspaceState(); |
| 645 | useRemoteEnvironmentState(); | 132 | const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); |
| 646 | 133 | const connectedEnvironments = useMemo<ReadonlyArray<ConnectedEnvironmentSummary>>( | |
| 647 | const connectedEnvironments = useMemo( | ||
| 648 | () => deriveConnectedEnvironments(savedConnectionsById, environmentStateById), | ||
| 649 | [environmentStateById, savedConnectionsById], | ||
| 650 | ); | ||
| 651 | |||
| 652 | const connectionState = useMemo<EnvironmentConnectionState>(() => { | ||
| 653 | if (connectedEnvironments.length === 0) { | ||
| 654 | return "idle"; | ||
| 655 | } | ||
| 656 | if (connectedEnvironments.some((environment) => environment.connectionState === "ready")) { | ||
| 657 | return "ready"; | ||
| 658 | } | ||
| 659 | if ( | ||
| 660 | connectedEnvironments.some((environment) => environment.connectionState === "reconnecting") | ||
| 661 | ) { | ||
| 662 | return "reconnecting"; | ||
| 663 | } | ||
| 664 | if (connectedEnvironments.some((environment) => environment.connectionState === "connecting")) { | ||
| 665 | return "connecting"; | ||
| 666 | } | ||
| 667 | return "disconnected"; | ||
| 668 | }, [connectedEnvironments]); | ||
| 669 | |||
| 670 | const connectionError = useMemo( | ||
| 671 | () => | 134 | () => |
| 672 | pipe( | 135 | workspace.environments.map((environment) => ({ |
| 673 | Arr.appendAll( | 136 | environmentId: environment.environmentId, |
| 674 | [pendingConnectionError], | 137 | environmentLabel: environment.environmentLabel, |
| 675 | Arr.map(connectedEnvironments, (environment) => environment.connectionError), | 138 | displayUrl: environment.displayUrl, |
| 676 | ), | 139 | isRelayManaged: environment.isRelayManaged, |
| 677 | Arr.findFirst((value) => value !== null), | 140 | connectionState: environment.connectionState, |
| 678 | Option.getOrNull, | 141 | connectionError: environment.connectionError, |
| 679 | ), | 142 | connectionErrorTraceId: environment.connectionErrorTraceId, |
| 680 | [connectedEnvironments, pendingConnectionError], | 143 | })), |
| 144 | [workspace.environments], | ||
| 681 | ); | 145 | ); |
| 682 | 146 | ||
| 683 | return { | 147 | return { |
| 684 | connectedEnvironments, | 148 | connectedEnvironments, |
| 685 | connectionState, | 149 | connectionState: workspace.state.connectionState, |
| 686 | connectionError, | 150 | connectionError: pendingConnectionError ?? workspace.state.connectionError, |
| 687 | }; | 151 | }; |
| 688 | } | 152 | } |
| 689 | 153 | ||
| 690 | export function useRemoteConnections() { | 154 | export function useRemoteConnections() { |
| 691 | const { connectionPairingUrl, pendingConnectionError } = useRemoteEnvironmentState(); | 155 | const controller = useConnectionController(); |
| 156 | const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); | ||
| 157 | const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); | ||
| 692 | const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); | 158 | const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); |
| 693 | 159 | ||
| 160 | const onChangeConnectionPairingUrl = useCallback((pairingUrl: string) => { | ||
| 161 | appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); | ||
| 162 | }, []); | ||
| 163 | |||
| 694 | const onConnectPress = useCallback( | 164 | const onConnectPress = useCallback( |
| 695 | async (pairingUrl?: string) => { | 165 | async (pairingUrl?: string) => { |
| 696 | try { | 166 | const nextPairingUrl = pairingUrl ?? connectionPairingUrl; |
| 697 | const nextPairingUrl = pairingUrl ?? connectionPairingUrl; | 167 | setPendingConnectionError(null); |
| 698 | const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); | 168 | const result = await controller.connectPairingUrl(nextPairingUrl); |
| 699 | clearPendingConnectionError(); | 169 | if (AsyncResult.isFailure(result)) { |
| 700 | await mobileRuntime.runPromise(connectSavedEnvironment(connection)); | 170 | const error = Cause.squash(result.cause); |
| 701 | clearConnectionPairingUrl(); | 171 | const message = |
| 702 | } catch (error) { | 172 | error instanceof Error ? error.message : "Failed to pair with the environment."; |
| 703 | setPendingConnectionError( | 173 | setPendingConnectionError(message); |
| 704 | error instanceof Error ? error.message : "Failed to pair with the environment.", | 174 | } else { |
| 705 | ); | 175 | appAtomRegistry.set(connectionPairingUrlAtom, ""); |
| 706 | throw error; | ||
| 707 | } | 176 | } |
| 177 | return result; | ||
| 708 | }, | 178 | }, |
| 709 | [connectionPairingUrl], | 179 | [connectionPairingUrl, controller], |
| 710 | ); | 180 | ); |
| 711 | 181 | ||
| 182 | const onReconnectEnvironment = useCallback( | ||
| 183 | (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId), | ||
| 184 | [controller], | ||
| 185 | ); | ||
| 712 | const onUpdateEnvironment = useCallback( | 186 | const onUpdateEnvironment = useCallback( |
| 713 | async ( | 187 | ( |
| 714 | environmentId: EnvironmentId, | 188 | environmentId: EnvironmentId, |
| 715 | updates: { readonly label: string; readonly displayUrl: string }, | 189 | updates: { readonly label: string; readonly displayUrl: string }, |
| 716 | ) => { | 190 | ) => controller.updateEnvironment(environmentId, updates), |
| 717 | const connection = getSavedConnectionsById()[environmentId]; | 191 | [controller], |
| 718 | if (!connection || isRelayManagedConnection(connection)) { | 192 | ); |
| 193 | |||
| 194 | const onRemoveEnvironmentPress = useCallback( | ||
| 195 | (environmentId: EnvironmentId) => { | ||
| 196 | const environment = connectedEnvironments.find( | ||
| 197 | (candidate) => candidate.environmentId === environmentId, | ||
| 198 | ); | ||
| 199 | if (!environment) { | ||
| 719 | return; | 200 | return; |
| 720 | } | 201 | } |
| 721 | 202 | Alert.alert( | |
| 722 | const updated: SavedRemoteConnection = { | 203 | "Remove environment?", |
| 723 | ...connection, | 204 | `Disconnect and forget ${environment.environmentLabel} on this device.`, |
| 724 | environmentLabel: updates.label.trim() || connection.environmentLabel, | 205 | [ |
| 725 | displayUrl: updates.displayUrl.trim() || connection.displayUrl, | 206 | { text: "Cancel", style: "cancel" }, |
| 726 | }; | 207 | { |
| 727 | 208 | text: "Remove", | |
| 728 | await saveConnection(updated); | 209 | style: "destructive", |
| 729 | upsertSavedConnection(updated); | 210 | onPress: () => { |
| 211 | void controller.removeEnvironment(environmentId); | ||
| 212 | }, | ||
| 213 | }, | ||
| 214 | ], | ||
| 215 | ); | ||
| 730 | }, | 216 | }, |
| 731 | [], | 217 | [connectedEnvironments, controller], |
| 732 | ); | 218 | ); |
| 733 | 219 | ||
| 734 | const onReconnectEnvironment = useCallback((environmentId: EnvironmentId) => { | ||
| 735 | const connection = getSavedConnectionsById()[environmentId]; | ||
| 736 | if (!connection) { | ||
| 737 | return; | ||
| 738 | } | ||
| 739 | void mobileRuntime | ||
| 740 | .runPromise( | ||
| 741 | connectSavedEnvironment(connection, { | ||
| 742 | persist: false, | ||
| 743 | suppressBootstrapError: true, | ||
| 744 | }), | ||
| 745 | ) | ||
| 746 | .catch(() => undefined); | ||
| 747 | }, []); | ||
| 748 | |||
| 749 | const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { | ||
| 750 | const connection = getSavedConnectionsById()[environmentId]; | ||
| 751 | if (!connection) { | ||
| 752 | return; | ||
| 753 | } | ||
| 754 | |||
| 755 | Alert.alert( | ||
| 756 | "Remove environment?", | ||
| 757 | `Disconnect and forget ${connection.environmentLabel} on this device.`, | ||
| 758 | [ | ||
| 759 | { text: "Cancel", style: "cancel" }, | ||
| 760 | { | ||
| 761 | text: "Remove", | ||
| 762 | style: "destructive", | ||
| 763 | onPress: () => { | ||
| 764 | void mobileRuntime | ||
| 765 | .runPromise(disconnectEnvironment(environmentId, { removeSaved: true })) | ||
| 766 | .catch(() => undefined); | ||
| 767 | }, | ||
| 768 | }, | ||
| 769 | ], | ||
| 770 | ); | ||
| 771 | }, []); | ||
| 772 | |||
| 773 | return { | 220 | return { |
| 774 | connectionPairingUrl, | 221 | connectionPairingUrl, |
| 775 | connectionState, | 222 | connectionState, |
| ... | @@ -777,7 +224,7 @@ export function useRemoteConnections() { | ... | @@ -777,7 +224,7 @@ export function useRemoteConnections() { |
| 777 | pairingConnectionError: pendingConnectionError, | 224 | pairingConnectionError: pendingConnectionError, |
| 778 | connectedEnvironments, | 225 | connectedEnvironments, |
| 779 | connectedEnvironmentCount: connectedEnvironments.length, | 226 | connectedEnvironmentCount: connectedEnvironments.length, |
| 780 | onChangeConnectionPairingUrl: setConnectionPairingUrl, | 227 | onChangeConnectionPairingUrl, |
| 781 | onConnectPress, | 228 | onConnectPress, |
| 782 | onReconnectEnvironment, | 229 | onReconnectEnvironment, |
| 783 | onUpdateEnvironment, | 230 | onUpdateEnvironment, |
apps/mobile/src/state/use-selected-thread-commands.ts deleted-187| ... | @@ -1,187 +0,0 @@ | ||
| 1 | import { useCallback } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CommandId, | ||
| 5 | type ModelSelection, | ||
| 6 | type ProviderInteractionMode, | ||
| 7 | type RuntimeMode, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | import { uuidv4 } from "../lib/uuid"; | ||
| 11 | import { environmentRuntimeManager } from "./use-environment-runtime"; | ||
| 12 | import { getEnvironmentClient } from "./environment-session-registry"; | ||
| 13 | import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; | ||
| 14 | import { useThreadSelection } from "./use-thread-selection"; | ||
| 15 | |||
| 16 | export function useSelectedThreadCommands(input: { | ||
| 17 | readonly refreshSelectedThreadGitStatus: (options?: { | ||
| 18 | readonly quiet?: boolean; | ||
| 19 | readonly cwd?: string | null; | ||
| 20 | }) => Promise<unknown>; | ||
| 21 | }) { | ||
| 22 | const { refreshSelectedThreadGitStatus } = input; | ||
| 23 | const { selectedThread } = useThreadSelection(); | ||
| 24 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 25 | |||
| 26 | const onRefresh = useCallback(async () => { | ||
| 27 | const targets = selectedThread | ||
| 28 | ? [selectedThread.environmentId] | ||
| 29 | : Object.values(savedConnectionsById).map((connection) => connection.environmentId); | ||
| 30 | |||
| 31 | await Promise.all( | ||
| 32 | targets.map(async (environmentId) => { | ||
| 33 | const client = getEnvironmentClient(environmentId); | ||
| 34 | if (!client) { | ||
| 35 | return; | ||
| 36 | } | ||
| 37 | |||
| 38 | try { | ||
| 39 | const serverConfig = await client.server.getConfig(); | ||
| 40 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 41 | ...current, | ||
| 42 | serverConfig, | ||
| 43 | connectionError: null, | ||
| 44 | })); | ||
| 45 | } catch (error) { | ||
| 46 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 47 | ...current, | ||
| 48 | connectionError: | ||
| 49 | error instanceof Error ? error.message : "Failed to refresh remote data.", | ||
| 50 | })); | ||
| 51 | } | ||
| 52 | }), | ||
| 53 | ); | ||
| 54 | |||
| 55 | if (selectedThread) { | ||
| 56 | await refreshSelectedThreadGitStatus({ quiet: true }); | ||
| 57 | } | ||
| 58 | }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]); | ||
| 59 | |||
| 60 | const onUpdateThreadModelSelection = useCallback( | ||
| 61 | async (modelSelection: ModelSelection) => { | ||
| 62 | if (!selectedThread) { | ||
| 63 | return; | ||
| 64 | } | ||
| 65 | |||
| 66 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 67 | if (!client) { | ||
| 68 | return; | ||
| 69 | } | ||
| 70 | |||
| 71 | await client.orchestration.dispatchCommand({ | ||
| 72 | type: "thread.meta.update", | ||
| 73 | commandId: CommandId.make(uuidv4()), | ||
| 74 | threadId: selectedThread.id, | ||
| 75 | modelSelection, | ||
| 76 | }); | ||
| 77 | }, | ||
| 78 | [selectedThread], | ||
| 79 | ); | ||
| 80 | |||
| 81 | const onUpdateThreadRuntimeMode = useCallback( | ||
| 82 | async (runtimeMode: RuntimeMode) => { | ||
| 83 | if (!selectedThread) { | ||
| 84 | return; | ||
| 85 | } | ||
| 86 | |||
| 87 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 88 | if (!client) { | ||
| 89 | return; | ||
| 90 | } | ||
| 91 | |||
| 92 | await client.orchestration.dispatchCommand({ | ||
| 93 | type: "thread.runtime-mode.set", | ||
| 94 | commandId: CommandId.make(uuidv4()), | ||
| 95 | threadId: selectedThread.id, | ||
| 96 | runtimeMode, | ||
| 97 | createdAt: new Date().toISOString(), | ||
| 98 | }); | ||
| 99 | }, | ||
| 100 | [selectedThread], | ||
| 101 | ); | ||
| 102 | |||
| 103 | const onUpdateThreadInteractionMode = useCallback( | ||
| 104 | async (interactionMode: ProviderInteractionMode) => { | ||
| 105 | if (!selectedThread) { | ||
| 106 | return; | ||
| 107 | } | ||
| 108 | |||
| 109 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 110 | if (!client) { | ||
| 111 | return; | ||
| 112 | } | ||
| 113 | |||
| 114 | await client.orchestration.dispatchCommand({ | ||
| 115 | type: "thread.interaction-mode.set", | ||
| 116 | commandId: CommandId.make(uuidv4()), | ||
| 117 | threadId: selectedThread.id, | ||
| 118 | interactionMode, | ||
| 119 | createdAt: new Date().toISOString(), | ||
| 120 | }); | ||
| 121 | }, | ||
| 122 | [selectedThread], | ||
| 123 | ); | ||
| 124 | |||
| 125 | const onStopThread = useCallback(async () => { | ||
| 126 | if (!selectedThread) { | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | |||
| 130 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 131 | if (!client) { | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | |||
| 135 | if ( | ||
| 136 | selectedThread.session?.status !== "running" && | ||
| 137 | selectedThread.session?.status !== "starting" | ||
| 138 | ) { | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | |||
| 142 | await client.orchestration.dispatchCommand({ | ||
| 143 | type: "thread.turn.interrupt", | ||
| 144 | commandId: CommandId.make(uuidv4()), | ||
| 145 | threadId: selectedThread.id, | ||
| 146 | ...(selectedThread.session?.activeTurnId | ||
| 147 | ? { turnId: selectedThread.session.activeTurnId } | ||
| 148 | : {}), | ||
| 149 | createdAt: new Date().toISOString(), | ||
| 150 | }); | ||
| 151 | }, [selectedThread]); | ||
| 152 | |||
| 153 | const onRenameThread = useCallback( | ||
| 154 | async (title: string) => { | ||
| 155 | if (!selectedThread) { | ||
| 156 | return; | ||
| 157 | } | ||
| 158 | |||
| 159 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 160 | if (!client) { | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | |||
| 164 | const trimmed = title.trim(); | ||
| 165 | if (!trimmed || trimmed === selectedThread.title) { | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | |||
| 169 | await client.orchestration.dispatchCommand({ | ||
| 170 | type: "thread.meta.update", | ||
| 171 | commandId: CommandId.make(uuidv4()), | ||
| 172 | threadId: selectedThread.id, | ||
| 173 | title: trimmed, | ||
| 174 | }); | ||
| 175 | }, | ||
| 176 | [selectedThread], | ||
| 177 | ); | ||
| 178 | |||
| 179 | return { | ||
| 180 | onRefresh, | ||
| 181 | onUpdateThreadModelSelection, | ||
| 182 | onUpdateThreadRuntimeMode, | ||
| 183 | onUpdateThreadInteractionMode, | ||
| 184 | onRenameThread, | ||
| 185 | onStopThread, | ||
| 186 | }; | ||
| 187 | } | ||
apps/mobile/src/state/use-selected-thread-git-actions.ts+246-203| ... | @@ -1,32 +1,60 @@ | ... | @@ -1,32 +1,60 @@ |
| 1 | import { useCallback, useEffect } from "react"; | 1 | import { useCallback, useEffect, useMemo } from "react"; |
| 2 | 2 | ||
| 3 | import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 4 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | ||
| 3 | import { | 5 | import { |
| 4 | EnvironmentScopedProjectShell, | ||
| 5 | EnvironmentScopedThreadShell, | ||
| 6 | type VcsRef, | ||
| 7 | type GitActionRequestInput, | 6 | type GitActionRequestInput, |
| 8 | } from "@t3tools/client-runtime"; | 7 | type VcsActionOperation, |
| 9 | import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts"; | 8 | type VcsRef, |
| 9 | } from "@t3tools/client-runtime/state/vcs"; | ||
| 10 | import type { GitRunStackedActionResult } from "@t3tools/contracts"; | ||
| 10 | import { | 11 | import { |
| 11 | dedupeRemoteBranchesWithLocalMatches, | 12 | dedupeRemoteBranchesWithLocalMatches, |
| 12 | sanitizeFeatureBranchName, | 13 | sanitizeFeatureBranchName, |
| 13 | } from "@t3tools/shared/git"; | 14 | } from "@t3tools/shared/git"; |
| 15 | import * as Cause from "effect/Cause"; | ||
| 16 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 14 | 17 | ||
| 18 | import { useBranches } from "../state/queries"; | ||
| 19 | import { threadEnvironment } from "../state/threads"; | ||
| 20 | import { vcsActionManager, vcsEnvironment } from "../state/vcs"; | ||
| 15 | import { uuidv4 } from "../lib/uuid"; | 21 | import { uuidv4 } from "../lib/uuid"; |
| 16 | import { getEnvironmentClient } from "./environment-session-registry"; | 22 | import { appAtomRegistry } from "./atom-registry"; |
| 17 | import { setPendingConnectionError } from "./use-remote-environment-registry"; | 23 | import { setPendingConnectionError } from "./use-remote-environment-registry"; |
| 18 | import { vcsActionManager, showGitActionResult } from "./use-vcs-action-state"; | 24 | import { useAtomCommand } from "./use-atom-command"; |
| 19 | import { vcsRefManager } from "./use-vcs-refs"; | 25 | import { showGitActionResult } from "./use-vcs-action-state"; |
| 20 | import { vcsStatusManager } from "./use-vcs-status"; | ||
| 21 | import { useThreadSelection } from "./use-thread-selection"; | 26 | import { useThreadSelection } from "./use-thread-selection"; |
| 22 | import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; | 27 | import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; |
| 23 | 28 | ||
| 24 | export function useSelectedThreadGitActions() { | 29 | export function useSelectedThreadGitActions() { |
| 30 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | ||
| 31 | reportFailure: false, | ||
| 32 | }); | ||
| 33 | const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); | ||
| 34 | const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); | ||
| 35 | const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false }); | ||
| 36 | const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); | ||
| 37 | const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); | ||
| 25 | const { selectedThread, selectedThreadProject } = useThreadSelection(); | 38 | const { selectedThread, selectedThreadProject } = useThreadSelection(); |
| 26 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); | 39 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 40 | const runStackedAction = useAtomCommand( | ||
| 41 | vcsActionManager.runStackedAction({ | ||
| 42 | environmentId: selectedThread?.environmentId ?? null, | ||
| 43 | cwd: selectedThreadCwd, | ||
| 44 | }), | ||
| 45 | { reportFailure: false }, | ||
| 46 | ); | ||
| 27 | 47 | ||
| 28 | const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null; | 48 | const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null; |
| 29 | 49 | const branchTarget = useMemo( | |
| 50 | () => ({ | ||
| 51 | environmentId: selectedThread?.environmentId ?? null, | ||
| 52 | cwd: selectedThreadGitRootCwd, | ||
| 53 | query: null, | ||
| 54 | }), | ||
| 55 | [selectedThread?.environmentId, selectedThreadGitRootCwd], | ||
| 56 | ); | ||
| 57 | const branchState = useBranches(branchTarget); | ||
| 30 | const updateThreadGitContext = useCallback( | 58 | const updateThreadGitContext = useCallback( |
| 31 | async ( | 59 | async ( |
| 32 | thread: NonNullable<typeof selectedThread>, | 60 | thread: NonNullable<typeof selectedThread>, |
| ... | @@ -35,20 +63,16 @@ export function useSelectedThreadGitActions() { | ... | @@ -35,20 +63,16 @@ export function useSelectedThreadGitActions() { |
| 35 | readonly worktreePath?: string | null; | 63 | readonly worktreePath?: string | null; |
| 36 | }, | 64 | }, |
| 37 | ) => { | 65 | ) => { |
| 38 | const client = getEnvironmentClient(thread.environmentId); | 66 | return updateThreadMetadata({ |
| 39 | if (!client) { | 67 | environmentId: thread.environmentId, |
| 40 | return; | 68 | input: { |
| 41 | } | 69 | threadId: thread.id, |
| 42 | 70 | ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), | |
| 43 | await client.orchestration.dispatchCommand({ | 71 | ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), |
| 44 | type: "thread.meta.update", | 72 | }, |
| 45 | commandId: CommandId.make(uuidv4()), | ||
| 46 | threadId: thread.id, | ||
| 47 | ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), | ||
| 48 | ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), | ||
| 49 | }); | 73 | }); |
| 50 | }, | 74 | }, |
| 51 | [], | 75 | [updateThreadMetadata], |
| 52 | ); | 76 | ); |
| 53 | 77 | ||
| 54 | const refreshSelectedThreadGitStatus = useCallback( | 78 | const refreshSelectedThreadGitStatus = useCallback( |
| ... | @@ -62,266 +86,285 @@ export function useSelectedThreadGitActions() { | ... | @@ -62,266 +86,285 @@ export function useSelectedThreadGitActions() { |
| 62 | return null; | 86 | return null; |
| 63 | } | 87 | } |
| 64 | 88 | ||
| 65 | try { | 89 | const target = { environmentId: selectedThread.environmentId, cwd }; |
| 66 | const client = getEnvironmentClient(selectedThread.environmentId); | 90 | const execute = () => |
| 67 | if (!client) { | 91 | refreshStatus({ |
| 68 | return null; | 92 | environmentId: selectedThread.environmentId, |
| 69 | } | 93 | input: { cwd }, |
| 70 | 94 | }); | |
| 71 | const status = await vcsActionManager.refreshStatus( | 95 | const result = options?.quiet |
| 72 | { environmentId: selectedThread.environmentId, cwd }, | 96 | ? await execute() |
| 73 | { ...client.vcs, runChangeRequest: client.git.runStackedAction }, | 97 | : await vcsActionManager.track( |
| 74 | options, | 98 | appAtomRegistry, |
| 75 | ); | 99 | target, |
| 76 | setPendingConnectionError(null); | 100 | { |
| 77 | return status; | 101 | operation: "refresh_status", |
| 78 | } catch (error) { | 102 | label: "Refreshing source control status", |
| 103 | }, | ||
| 104 | execute, | ||
| 105 | ); | ||
| 106 | if (AsyncResult.isFailure(result)) { | ||
| 107 | const error = Cause.squash(result.cause); | ||
| 79 | const message = error instanceof Error ? error.message : "Failed to refresh git status."; | 108 | const message = error instanceof Error ? error.message : "Failed to refresh git status."; |
| 80 | setPendingConnectionError(message); | 109 | setPendingConnectionError(message); |
| 81 | return null; | 110 | return null; |
| 82 | } | 111 | } |
| 112 | setPendingConnectionError(null); | ||
| 113 | return result.value; | ||
| 83 | }, | 114 | }, |
| 84 | [selectedThread, selectedThreadCwd, selectedThreadProject], | 115 | [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject], |
| 85 | ); | 116 | ); |
| 86 | 117 | ||
| 87 | useEffect(() => { | 118 | useEffect(() => { |
| 88 | if (!selectedThread || !selectedThreadProject) { | 119 | if (!selectedThread || !selectedThreadProject) { |
| 89 | return; | 120 | return; |
| 90 | } | 121 | } |
| 91 | |||
| 92 | void refreshSelectedThreadGitStatus({ quiet: true }); | 122 | void refreshSelectedThreadGitStatus({ quiet: true }); |
| 93 | }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]); | 123 | }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]); |
| 94 | 124 | ||
| 95 | const runSelectedThreadGitMutation = useCallback( | 125 | const runSelectedThreadGitMutation = useCallback( |
| 96 | async <T>( | 126 | async <T, E>( |
| 97 | operation: (input: { | 127 | operation: VcsActionOperation, |
| 98 | readonly thread: EnvironmentScopedThreadShell; | 128 | label: string, |
| 99 | readonly project: EnvironmentScopedProjectShell; | 129 | execute: (input: { |
| 130 | readonly thread: EnvironmentThreadShell; | ||
| 131 | readonly project: EnvironmentProject; | ||
| 100 | readonly cwd: string; | 132 | readonly cwd: string; |
| 101 | }) => Promise<T>, | 133 | }) => Promise<AtomCommandResult<T, E>>, |
| 134 | options?: { readonly managedExternally?: boolean }, | ||
| 102 | ): Promise<T | null> => { | 135 | ): Promise<T | null> => { |
| 103 | if (!selectedThread || !selectedThreadProject) { | 136 | if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { |
| 104 | return null; | 137 | return null; |
| 105 | } | 138 | } |
| 106 | 139 | ||
| 107 | const cwd = selectedThreadCwd; | 140 | const target = { |
| 108 | if (!cwd) { | 141 | environmentId: selectedThread.environmentId, |
| 109 | return null; | 142 | cwd: selectedThreadCwd, |
| 110 | } | 143 | }; |
| 111 | 144 | setPendingConnectionError(null); | |
| 112 | try { | 145 | const run = () => |
| 113 | setPendingConnectionError(null); | 146 | execute({ |
| 114 | return await operation({ | ||
| 115 | thread: selectedThread, | 147 | thread: selectedThread, |
| 116 | project: selectedThreadProject, | 148 | project: selectedThreadProject, |
| 117 | cwd, | 149 | cwd: selectedThreadCwd, |
| 118 | }); | 150 | }); |
| 119 | } catch (error) { | 151 | const result = |
| 152 | options?.managedExternally === true | ||
| 153 | ? await run() | ||
| 154 | : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run); | ||
| 155 | if (AsyncResult.isFailure(result)) { | ||
| 156 | const error = Cause.squash(result.cause); | ||
| 120 | const message = error instanceof Error ? error.message : "Git action failed."; | 157 | const message = error instanceof Error ? error.message : "Git action failed."; |
| 121 | setPendingConnectionError(message); | 158 | setPendingConnectionError(message); |
| 122 | showGitActionResult({ type: "error", title: "Git action failed", description: message }); | 159 | showGitActionResult({ type: "error", title: "Git action failed", description: message }); |
| 123 | return null; | 160 | return null; |
| 124 | } | 161 | } |
| 162 | return result.value; | ||
| 125 | }, | 163 | }, |
| 126 | [selectedThread, selectedThreadCwd, selectedThreadProject], | 164 | [selectedThread, selectedThreadCwd, selectedThreadProject], |
| 127 | ); | 165 | ); |
| 128 | 166 | ||
| 129 | const refreshSelectedThreadBranches = useCallback(async (): Promise<ReadonlyArray<VcsRef>> => { | 167 | const refreshSelectedThreadBranches = useCallback(async (): Promise<ReadonlyArray<VcsRef>> => { |
| 130 | if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) { | 168 | branchState.refresh(); |
| 131 | return []; | 169 | return dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []).filter( |
| 132 | } | 170 | (branch) => !branch.isRemote, |
| 133 | 171 | ); | |
| 134 | const client = getEnvironmentClient(selectedThread.environmentId); | 172 | }, [branchState]); |
| 135 | if (!client) { | ||
| 136 | return []; | ||
| 137 | } | ||
| 138 | |||
| 139 | try { | ||
| 140 | const result = await vcsRefManager.load( | ||
| 141 | { environmentId: selectedThread.environmentId, cwd: selectedThreadGitRootCwd, query: null }, | ||
| 142 | client.vcs, | ||
| 143 | { limit: 100 }, | ||
| 144 | ); | ||
| 145 | return dedupeRemoteBranchesWithLocalMatches(result?.refs ?? []).filter( | ||
| 146 | (branch) => !branch.isRemote, | ||
| 147 | ); | ||
| 148 | } catch (error) { | ||
| 149 | setPendingConnectionError( | ||
| 150 | error instanceof Error ? error.message : "Failed to load branches.", | ||
| 151 | ); | ||
| 152 | return []; | ||
| 153 | } | ||
| 154 | }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject]); | ||
| 155 | 173 | ||
| 156 | const syncSelectedThreadBranchState = useCallback( | 174 | const syncSelectedThreadBranchState = useCallback( |
| 157 | async (input: { | 175 | async (input: { |
| 158 | readonly thread: EnvironmentScopedThreadShell; | 176 | readonly thread: EnvironmentThreadShell; |
| 159 | readonly cwd: string; | 177 | readonly cwd: string; |
| 160 | readonly branchRootCwd?: string | null; | ||
| 161 | readonly nextThreadState?: { | 178 | readonly nextThreadState?: { |
| 162 | readonly branch?: string | null; | 179 | readonly branch?: string | null; |
| 163 | readonly worktreePath?: string | null; | 180 | readonly worktreePath?: string | null; |
| 164 | }; | 181 | }; |
| 165 | }) => { | 182 | }): Promise<AtomCommandResult<void, unknown>> => { |
| 166 | if (input.nextThreadState) { | 183 | if (input.nextThreadState) { |
| 167 | await updateThreadGitContext(input.thread, input.nextThreadState); | 184 | const updateResult = await updateThreadGitContext(input.thread, input.nextThreadState); |
| 168 | } | 185 | if (AsyncResult.isFailure(updateResult)) { |
| 169 | 186 | return AsyncResult.failure(updateResult.cause); | |
| 170 | const branchRootCwd = input.branchRootCwd ?? selectedThreadProject?.workspaceRoot ?? null; | 187 | } |
| 171 | if (branchRootCwd) { | ||
| 172 | vcsRefManager.invalidate({ | ||
| 173 | environmentId: input.thread.environmentId, | ||
| 174 | cwd: branchRootCwd, | ||
| 175 | query: null, | ||
| 176 | }); | ||
| 177 | await refreshSelectedThreadBranches(); | ||
| 178 | } | 188 | } |
| 179 | 189 | branchState.refresh(); | |
| 180 | await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); | 190 | await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); |
| 191 | return AsyncResult.success(undefined); | ||
| 181 | }, | 192 | }, |
| 182 | [ | 193 | [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext], |
| 183 | refreshSelectedThreadBranches, | ||
| 184 | refreshSelectedThreadGitStatus, | ||
| 185 | selectedThreadProject?.workspaceRoot, | ||
| 186 | updateThreadGitContext, | ||
| 187 | ], | ||
| 188 | ); | 194 | ); |
| 189 | 195 | ||
| 190 | const onCheckoutSelectedThreadBranch = useCallback( | 196 | const onCheckoutSelectedThreadBranch = useCallback( |
| 191 | async (branch: string) => { | 197 | async (branch: string) => { |
| 192 | await runSelectedThreadGitMutation(async ({ thread, cwd }) => { | 198 | await runSelectedThreadGitMutation( |
| 193 | const result = await vcsActionManager.switchRef( | 199 | "switch_ref", |
| 194 | { environmentId: thread.environmentId, cwd }, | 200 | "Switching branch", |
| 195 | { refName: branch }, | 201 | async ({ thread, cwd }) => { |
| 196 | ); | 202 | const result = await switchRef({ |
| 197 | await syncSelectedThreadBranchState({ | 203 | environmentId: thread.environmentId, |
| 198 | thread, | 204 | input: { cwd, refName: branch }, |
| 199 | cwd, | 205 | }); |
| 200 | nextThreadState: { | 206 | if (AsyncResult.isFailure(result)) { |
| 201 | branch: result?.refName ?? thread.branch, | 207 | return result; |
| 202 | worktreePath: selectedThreadWorktreePath, | 208 | } |
| 203 | }, | 209 | const syncResult = await syncSelectedThreadBranchState({ |
| 204 | }); | 210 | thread, |
| 205 | }); | 211 | cwd, |
| 212 | nextThreadState: { | ||
| 213 | branch: result.value.refName ?? thread.branch, | ||
| 214 | worktreePath: selectedThreadWorktreePath, | ||
| 215 | }, | ||
| 216 | }); | ||
| 217 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; | ||
| 218 | }, | ||
| 219 | ); | ||
| 206 | }, | 220 | }, |
| 207 | [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], | 221 | [ |
| 222 | runSelectedThreadGitMutation, | ||
| 223 | selectedThreadWorktreePath, | ||
| 224 | syncSelectedThreadBranchState, | ||
| 225 | switchRef, | ||
| 226 | ], | ||
| 208 | ); | 227 | ); |
| 209 | 228 | ||
| 210 | const onCreateSelectedThreadBranch = useCallback( | 229 | const onCreateSelectedThreadBranch = useCallback( |
| 211 | async (branch: string) => { | 230 | async (branch: string) => { |
| 212 | await runSelectedThreadGitMutation(async ({ thread, cwd }) => { | 231 | await runSelectedThreadGitMutation( |
| 213 | const result = await vcsActionManager.createRef( | 232 | "create_ref", |
| 214 | { environmentId: thread.environmentId, cwd }, | 233 | "Creating branch", |
| 215 | { | 234 | async ({ thread, cwd }) => { |
| 216 | refName: branch, | 235 | const result = await createRef({ |
| 217 | switchRef: true, | 236 | environmentId: thread.environmentId, |
| 218 | }, | 237 | input: { cwd, refName: branch, switchRef: true }, |
| 219 | ); | 238 | }); |
| 220 | await syncSelectedThreadBranchState({ | 239 | if (AsyncResult.isFailure(result)) { |
| 221 | thread, | 240 | return result; |
| 222 | cwd, | 241 | } |
| 223 | nextThreadState: { | 242 | const syncResult = await syncSelectedThreadBranchState({ |
| 224 | branch: result?.refName ?? thread.branch, | 243 | thread, |
| 225 | worktreePath: selectedThreadWorktreePath, | 244 | cwd, |
| 226 | }, | 245 | nextThreadState: { |
| 227 | }); | 246 | branch: result.value.refName ?? thread.branch, |
| 228 | }); | 247 | worktreePath: selectedThreadWorktreePath, |
| 248 | }, | ||
| 249 | }); | ||
| 250 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; | ||
| 251 | }, | ||
| 252 | ); | ||
| 229 | }, | 253 | }, |
| 230 | [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], | 254 | [ |
| 255 | runSelectedThreadGitMutation, | ||
| 256 | selectedThreadWorktreePath, | ||
| 257 | syncSelectedThreadBranchState, | ||
| 258 | createRef, | ||
| 259 | ], | ||
| 231 | ); | 260 | ); |
| 232 | 261 | ||
| 233 | const onCreateSelectedThreadWorktree = useCallback( | 262 | const onCreateSelectedThreadWorktree = useCallback( |
| 234 | async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { | 263 | async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { |
| 235 | await runSelectedThreadGitMutation(async ({ thread, project }) => { | 264 | await runSelectedThreadGitMutation( |
| 236 | const result = await vcsActionManager.createWorktree( | 265 | "create_worktree", |
| 237 | { environmentId: thread.environmentId, cwd: project.workspaceRoot }, | 266 | "Creating worktree", |
| 238 | { | 267 | async ({ thread, project }) => { |
| 239 | refName: nextWorktree.baseBranch, | 268 | const result = await createWorktree({ |
| 240 | newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), | 269 | environmentId: thread.environmentId, |
| 241 | path: null, | 270 | input: { |
| 242 | }, | 271 | cwd: project.workspaceRoot, |
| 243 | ); | 272 | refName: nextWorktree.baseBranch, |
| 244 | if (!result) { | 273 | newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), |
| 245 | return; | 274 | path: null, |
| 246 | } | 275 | }, |
| 247 | 276 | }); | |
| 248 | await syncSelectedThreadBranchState({ | 277 | if (AsyncResult.isFailure(result)) { |
| 249 | thread, | 278 | return result; |
| 250 | cwd: result.worktree.path, | 279 | } |
| 251 | branchRootCwd: project.workspaceRoot, | 280 | const syncResult = await syncSelectedThreadBranchState({ |
| 252 | nextThreadState: { | 281 | thread, |
| 253 | branch: result.worktree.refName, | 282 | cwd: result.value.worktree.path, |
| 254 | worktreePath: result.worktree.path, | 283 | nextThreadState: { |
| 255 | }, | 284 | branch: result.value.worktree.refName, |
| 256 | }); | 285 | worktreePath: result.value.worktree.path, |
| 257 | }); | 286 | }, |
| 287 | }); | ||
| 288 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; | ||
| 289 | }, | ||
| 290 | ); | ||
| 258 | }, | 291 | }, |
| 259 | [runSelectedThreadGitMutation, syncSelectedThreadBranchState], | 292 | [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], |
| 260 | ); | 293 | ); |
| 261 | 294 | ||
| 262 | const onPullSelectedThreadBranch = useCallback(async () => { | 295 | const onPullSelectedThreadBranch = useCallback(async () => { |
| 263 | await runSelectedThreadGitMutation(async ({ thread, cwd }) => { | 296 | await runSelectedThreadGitMutation( |
| 264 | const result = await vcsActionManager.pull({ environmentId: thread.environmentId, cwd }); | 297 | "pull", |
| 265 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | 298 | "Pulling latest changes", |
| 266 | if (result) { | 299 | async ({ thread, cwd }) => { |
| 300 | const result = await pull({ | ||
| 301 | environmentId: thread.environmentId, | ||
| 302 | input: { cwd }, | ||
| 303 | }); | ||
| 304 | if (AsyncResult.isFailure(result)) { | ||
| 305 | return result; | ||
| 306 | } | ||
| 307 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | ||
| 267 | showGitActionResult({ | 308 | showGitActionResult({ |
| 268 | type: "success", | 309 | type: "success", |
| 269 | title: | 310 | title: |
| 270 | result.status === "skipped_up_to_date" | 311 | result.value.status === "skipped_up_to_date" |
| 271 | ? "Already up to date" | 312 | ? "Already up to date" |
| 272 | : `Pulled latest on ${result.refName}`, | 313 | : `Pulled latest on ${result.value.refName}`, |
| 273 | }); | 314 | }); |
| 274 | } | 315 | return result; |
| 275 | }); | 316 | }, |
| 276 | }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); | 317 | ); |
| 318 | }, [pull, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); | ||
| 277 | 319 | ||
| 278 | const onRunSelectedThreadGitAction = useCallback( | 320 | const onRunSelectedThreadGitAction = useCallback( |
| 279 | async (input: GitActionRequestInput): Promise<GitRunStackedActionResult | null> => { | 321 | async (input: GitActionRequestInput): Promise<GitRunStackedActionResult | null> => { |
| 280 | return await runSelectedThreadGitMutation(async ({ thread, cwd }) => { | 322 | const actionId = uuidv4(); |
| 281 | const result = await vcsActionManager.runChangeRequest( | 323 | return await runSelectedThreadGitMutation( |
| 282 | { environmentId: thread.environmentId, cwd }, | 324 | "run_change_request", |
| 283 | { | 325 | "Running source control action", |
| 284 | actionId: uuidv4(), | 326 | async ({ thread, cwd }) => { |
| 327 | const result = await runStackedAction({ | ||
| 328 | actionId, | ||
| 285 | action: input.action, | 329 | action: input.action, |
| 286 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | 330 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), |
| 287 | ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), | 331 | ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), |
| 288 | ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), | 332 | ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), |
| 289 | }, | 333 | }); |
| 290 | { | 334 | if (AsyncResult.isFailure(result)) { |
| 291 | gitStatus: vcsStatusManager.getSnapshot({ | 335 | return result; |
| 292 | environmentId: thread.environmentId, | 336 | } |
| 293 | cwd, | ||
| 294 | }).data, | ||
| 295 | }, | ||
| 296 | ); | ||
| 297 | if (!result) { | ||
| 298 | return null; | ||
| 299 | } | ||
| 300 | |||
| 301 | showGitActionResult({ | ||
| 302 | type: "success", | ||
| 303 | title: result.toast.title, | ||
| 304 | description: result.toast.description, | ||
| 305 | prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined, | ||
| 306 | }); | ||
| 307 | 337 | ||
| 308 | if (result.branch.status === "created" && result.branch.name) { | 338 | showGitActionResult({ |
| 309 | await syncSelectedThreadBranchState({ | 339 | type: "success", |
| 310 | thread, | 340 | title: result.value.toast.title, |
| 311 | cwd, | 341 | description: result.value.toast.description, |
| 312 | nextThreadState: { | 342 | prUrl: |
| 313 | branch: result.branch.name, | 343 | result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, |
| 314 | worktreePath: selectedThreadWorktreePath, | ||
| 315 | }, | ||
| 316 | }); | 344 | }); |
| 317 | return result; | ||
| 318 | } | ||
| 319 | 345 | ||
| 320 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | 346 | if (result.value.branch.status === "created" && result.value.branch.name) { |
| 321 | return result; | 347 | const syncResult = await syncSelectedThreadBranchState({ |
| 322 | }); | 348 | thread, |
| 349 | cwd, | ||
| 350 | nextThreadState: { | ||
| 351 | branch: result.value.branch.name, | ||
| 352 | worktreePath: selectedThreadWorktreePath, | ||
| 353 | }, | ||
| 354 | }); | ||
| 355 | if (AsyncResult.isFailure(syncResult)) { | ||
| 356 | return AsyncResult.failure(syncResult.cause); | ||
| 357 | } | ||
| 358 | } else { | ||
| 359 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | ||
| 360 | } | ||
| 361 | return result; | ||
| 362 | }, | ||
| 363 | { managedExternally: true }, | ||
| 364 | ); | ||
| 323 | }, | 365 | }, |
| 324 | [ | 366 | [ |
| 367 | runStackedAction, | ||
| 325 | refreshSelectedThreadGitStatus, | 368 | refreshSelectedThreadGitStatus, |
| 326 | runSelectedThreadGitMutation, | 369 | runSelectedThreadGitMutation, |
| 327 | selectedThreadWorktreePath, | 370 | selectedThreadWorktreePath, |
apps/mobile/src/state/use-selected-thread-git-state.ts+12-4| ... | @@ -2,9 +2,10 @@ import { useMemo } from "react"; | ... | @@ -2,9 +2,10 @@ import { useMemo } from "react"; |
| 2 | 2 | ||
| 3 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; | 3 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; |
| 4 | 4 | ||
| 5 | import { useBranches } from "./queries"; | ||
| 6 | import { useEnvironmentQuery } from "./query"; | ||
| 7 | import { sourceControlEnvironment } from "./sourceControl"; | ||
| 5 | import { useVcsActionState } from "./use-vcs-action-state"; | 8 | import { useVcsActionState } from "./use-vcs-action-state"; |
| 6 | import { useVcsRefs } from "./use-vcs-refs"; | ||
| 7 | import { useSourceControlDiscovery } from "./use-source-control-discovery"; | ||
| 8 | import { useThreadSelection } from "./use-thread-selection"; | 9 | import { useThreadSelection } from "./use-thread-selection"; |
| 9 | import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; | 10 | import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; |
| 10 | 11 | ||
| ... | @@ -20,7 +21,14 @@ export function useSelectedThreadGitState() { | ... | @@ -20,7 +21,14 @@ export function useSelectedThreadGitState() { |
| 20 | [selectedThread?.environmentId, selectedThreadCwd], | 21 | [selectedThread?.environmentId, selectedThreadCwd], |
| 21 | ); | 22 | ); |
| 22 | const gitActionState = useVcsActionState(selectedThreadGitTarget); | 23 | const gitActionState = useVcsActionState(selectedThreadGitTarget); |
| 23 | const sourceControlDiscovery = useSourceControlDiscovery(selectedThread?.environmentId ?? null); | 24 | const sourceControlDiscovery = useEnvironmentQuery( |
| 25 | selectedThread === null | ||
| 26 | ? null | ||
| 27 | : sourceControlEnvironment.discovery({ | ||
| 28 | environmentId: selectedThread.environmentId, | ||
| 29 | input: {}, | ||
| 30 | }), | ||
| 31 | ); | ||
| 24 | 32 | ||
| 25 | const selectedThreadBranchTarget = useMemo( | 33 | const selectedThreadBranchTarget = useMemo( |
| 26 | () => ({ | 34 | () => ({ |
| ... | @@ -30,7 +38,7 @@ export function useSelectedThreadGitState() { | ... | @@ -30,7 +38,7 @@ export function useSelectedThreadGitState() { |
| 30 | }), | 38 | }), |
| 31 | [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot], | 39 | [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot], |
| 32 | ); | 40 | ); |
| 33 | const selectedThreadBranchState = useVcsRefs(selectedThreadBranchTarget); | 41 | const selectedThreadBranchState = useBranches(selectedThreadBranchTarget); |
| 34 | const selectedThreadBranches = useMemo( | 42 | const selectedThreadBranches = useMemo( |
| 35 | () => | 43 | () => |
| 36 | dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.refs ?? []).filter( | 44 | dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.refs ?? []).filter( |
apps/mobile/src/state/use-selected-thread-requests.ts+34-35| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useMemo, useState } from "react"; | 2 | import { useCallback, useMemo, useState } from "react"; |
| 3 | 3 | ||
| 4 | import { ApprovalRequestId, CommandId, type ProviderApprovalDecision } from "@t3tools/contracts"; | 4 | import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; |
| 5 | import { Atom } from "effect/unstable/reactivity"; | 5 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 6 | ||
| 7 | import { threadEnvironment } from "../state/threads"; | ||
| 7 | import { scopedRequestKey } from "../lib/scopedEntities"; | 8 | import { scopedRequestKey } from "../lib/scopedEntities"; |
| 8 | import { | 9 | import { |
| 9 | buildPendingUserInputAnswers, | 10 | buildPendingUserInputAnswers, |
| ... | @@ -12,11 +13,10 @@ import { | ... | @@ -12,11 +13,10 @@ import { |
| 12 | setPendingUserInputCustomAnswer, | 13 | setPendingUserInputCustomAnswer, |
| 13 | type PendingUserInputDraftAnswer, | 14 | type PendingUserInputDraftAnswer, |
| 14 | } from "../lib/threadActivity"; | 15 | } from "../lib/threadActivity"; |
| 15 | import { uuidv4 } from "../lib/uuid"; | ||
| 16 | import { appAtomRegistry } from "./atom-registry"; | 16 | import { appAtomRegistry } from "./atom-registry"; |
| 17 | import { getEnvironmentClient } from "./environment-session-registry"; | ||
| 18 | import { useSelectedThreadDetail } from "./use-thread-detail"; | 17 | import { useSelectedThreadDetail } from "./use-thread-detail"; |
| 19 | import { useThreadSelection } from "./use-thread-selection"; | 18 | import { useThreadSelection } from "./use-thread-selection"; |
| 19 | import { useAtomCommand } from "./use-atom-command"; | ||
| 20 | 20 | ||
| 21 | const userInputDraftsByRequestKeyAtom = Atom.make< | 21 | const userInputDraftsByRequestKeyAtom = Atom.make< |
| 22 | Record<string, Record<string, PendingUserInputDraftAnswer>> | 22 | Record<string, Record<string, PendingUserInputDraftAnswer>> |
| ... | @@ -54,6 +54,14 @@ function setUserInputDraftCustomAnswer( | ... | @@ -54,6 +54,14 @@ function setUserInputDraftCustomAnswer( |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | export function useSelectedThreadRequests() { | 56 | export function useSelectedThreadRequests() { |
| 57 | const respondToApproval = useAtomCommand( | ||
| 58 | threadEnvironment.respondToApproval, | ||
| 59 | "thread approval response", | ||
| 60 | ); | ||
| 61 | const respondToUserInput = useAtomCommand( | ||
| 62 | threadEnvironment.respondToUserInput, | ||
| 63 | "thread user input response", | ||
| 64 | ); | ||
| 57 | const { selectedThread: selectedThreadShell } = useThreadSelection(); | 65 | const { selectedThread: selectedThreadShell } = useThreadSelection(); |
| 58 | const selectedThread = useSelectedThreadDetail(); | 66 | const selectedThread = useSelectedThreadDetail(); |
| 59 | const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); | 67 | const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); |
| ... | @@ -112,26 +120,19 @@ export function useSelectedThreadRequests() { | ... | @@ -112,26 +120,19 @@ export function useSelectedThreadRequests() { |
| 112 | return; | 120 | return; |
| 113 | } | 121 | } |
| 114 | 122 | ||
| 115 | const client = getEnvironmentClient(selectedThreadShell.environmentId); | ||
| 116 | if (!client) { | ||
| 117 | return; | ||
| 118 | } | ||
| 119 | |||
| 120 | setRespondingApprovalId(requestId); | 123 | setRespondingApprovalId(requestId); |
| 121 | try { | 124 | const result = await respondToApproval({ |
| 122 | await client.orchestration.dispatchCommand({ | 125 | environmentId: selectedThreadShell.environmentId, |
| 123 | type: "thread.approval.respond", | 126 | input: { |
| 124 | commandId: CommandId.make(uuidv4()), | ||
| 125 | threadId: selectedThreadShell.id, | 127 | threadId: selectedThreadShell.id, |
| 126 | requestId, | 128 | requestId, |
| 127 | decision, | 129 | decision, |
| 128 | createdAt: new Date().toISOString(), | 130 | }, |
| 129 | }); | 131 | }); |
| 130 | } finally { | 132 | setRespondingApprovalId((current) => (current === requestId ? null : current)); |
| 131 | setRespondingApprovalId((current) => (current === requestId ? null : current)); | 133 | return result; |
| 132 | } | ||
| 133 | }, | 134 | }, |
| 134 | [selectedThreadShell], | 135 | [respondToApproval, selectedThreadShell], |
| 135 | ); | 136 | ); |
| 136 | 137 | ||
| 137 | const onSubmitUserInput = useCallback(async () => { | 138 | const onSubmitUserInput = useCallback(async () => { |
| ... | @@ -139,27 +140,25 @@ export function useSelectedThreadRequests() { | ... | @@ -139,27 +140,25 @@ export function useSelectedThreadRequests() { |
| 139 | return; | 140 | return; |
| 140 | } | 141 | } |
| 141 | 142 | ||
| 142 | const client = getEnvironmentClient(selectedThreadShell.environmentId); | ||
| 143 | if (!client) { | ||
| 144 | return; | ||
| 145 | } | ||
| 146 | |||
| 147 | setRespondingUserInputId(activePendingUserInput.requestId); | 143 | setRespondingUserInputId(activePendingUserInput.requestId); |
| 148 | try { | 144 | const result = await respondToUserInput({ |
| 149 | await client.orchestration.dispatchCommand({ | 145 | environmentId: selectedThreadShell.environmentId, |
| 150 | type: "thread.user-input.respond", | 146 | input: { |
| 151 | commandId: CommandId.make(uuidv4()), | ||
| 152 | threadId: selectedThreadShell.id, | 147 | threadId: selectedThreadShell.id, |
| 153 | requestId: activePendingUserInput.requestId, | 148 | requestId: activePendingUserInput.requestId, |
| 154 | answers: activePendingUserInputAnswers, | 149 | answers: activePendingUserInputAnswers, |
| 155 | createdAt: new Date().toISOString(), | 150 | }, |
| 156 | }); | 151 | }); |
| 157 | } finally { | 152 | setRespondingUserInputId((current) => |
| 158 | setRespondingUserInputId((current) => | 153 | current === activePendingUserInput.requestId ? null : current, |
| 159 | current === activePendingUserInput.requestId ? null : current, | 154 | ); |
| 160 | ); | 155 | return result; |
| 161 | } | 156 | }, [ |
| 162 | }, [activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]); | 157 | activePendingUserInput, |
| 158 | activePendingUserInputAnswers, | ||
| 159 | respondToUserInput, | ||
| 160 | selectedThreadShell, | ||
| 161 | ]); | ||
| 163 | 162 | ||
| 164 | return { | 163 | return { |
| 165 | activePendingApproval, | 164 | activePendingApproval, |
apps/mobile/src/state/use-shell-snapshot.ts deleted-111| ... | @@ -1,111 +0,0 @@ | ||
| 1 | import * as Arr from "effect/Array"; | ||
| 2 | import * as Order from "effect/Order"; | ||
| 3 | import { useAtomValue } from "@effect/atom-react"; | ||
| 4 | import { Atom } from "effect/unstable/reactivity"; | ||
| 5 | import { | ||
| 6 | EMPTY_SHELL_SNAPSHOT_ATOM, | ||
| 7 | EMPTY_SHELL_SNAPSHOT_STATE, | ||
| 8 | createShellSnapshotManager, | ||
| 9 | getShellSnapshotTargetKey, | ||
| 10 | shellSnapshotStateAtom, | ||
| 11 | type ShellSnapshotState, | ||
| 12 | } from "@t3tools/client-runtime"; | ||
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 14 | import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; | ||
| 15 | |||
| 16 | import { appAtomRegistry } from "./atom-registry"; | ||
| 17 | import type { CachedShellSnapshot } from "../lib/storage"; | ||
| 18 | |||
| 19 | const cachedShellSnapshotMetadataAtom = Atom.make< | ||
| 20 | Readonly<Record<EnvironmentId, { readonly snapshotReceivedAt: string }>> | ||
| 21 | >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:cached-shell-snapshot-metadata")); | ||
| 22 | |||
| 23 | export const shellSnapshotManager = createShellSnapshotManager({ | ||
| 24 | getRegistry: () => appAtomRegistry, | ||
| 25 | }); | ||
| 26 | |||
| 27 | export function hydrateCachedShellSnapshot(cached: CachedShellSnapshot): void { | ||
| 28 | shellSnapshotManager.syncSnapshot({ environmentId: cached.environmentId }, cached.snapshot); | ||
| 29 | appAtomRegistry.set(cachedShellSnapshotMetadataAtom, { | ||
| 30 | ...appAtomRegistry.get(cachedShellSnapshotMetadataAtom), | ||
| 31 | [cached.environmentId]: { | ||
| 32 | snapshotReceivedAt: cached.snapshotReceivedAt, | ||
| 33 | }, | ||
| 34 | }); | ||
| 35 | } | ||
| 36 | |||
| 37 | export function markShellSnapshotLive(environmentId: EnvironmentId): void { | ||
| 38 | const current = appAtomRegistry.get(cachedShellSnapshotMetadataAtom); | ||
| 39 | if (current[environmentId] === undefined) { | ||
| 40 | return; | ||
| 41 | } | ||
| 42 | |||
| 43 | const next = { ...current }; | ||
| 44 | delete next[environmentId]; | ||
| 45 | appAtomRegistry.set(cachedShellSnapshotMetadataAtom, next); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function clearCachedShellSnapshotMetadata(environmentId: EnvironmentId): void { | ||
| 49 | markShellSnapshotLive(environmentId); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function useCachedShellSnapshotMetadata(): Readonly< | ||
| 53 | Record<EnvironmentId, { readonly snapshotReceivedAt: string }> | ||
| 54 | > { | ||
| 55 | return useAtomValue(cachedShellSnapshotMetadataAtom); | ||
| 56 | } | ||
| 57 | |||
| 58 | export function useShellSnapshot(environmentId: EnvironmentId | null): ShellSnapshotState { | ||
| 59 | const targetKey = getShellSnapshotTargetKey({ environmentId }); | ||
| 60 | const state = useAtomValue( | ||
| 61 | targetKey !== null ? shellSnapshotStateAtom(targetKey) : EMPTY_SHELL_SNAPSHOT_ATOM, | ||
| 62 | ); | ||
| 63 | return targetKey === null ? EMPTY_SHELL_SNAPSHOT_STATE : state; | ||
| 64 | } | ||
| 65 | |||
| 66 | export function useShellSnapshotStates( | ||
| 67 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 68 | ): Readonly<Record<EnvironmentId, ShellSnapshotState>> { | ||
| 69 | const stableEnvironmentIds = useMemo( | ||
| 70 | () => Arr.sort(new Set(environmentIds), Order.String), | ||
| 71 | [environmentIds], | ||
| 72 | ); | ||
| 73 | const snapshotCacheRef = useRef<Readonly<Record<EnvironmentId, ShellSnapshotState>>>({}); | ||
| 74 | |||
| 75 | const subscribe = useCallback( | ||
| 76 | (onStoreChange: () => void) => { | ||
| 77 | const unsubs = stableEnvironmentIds.map((environmentId) => | ||
| 78 | appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), onStoreChange), | ||
| 79 | ); | ||
| 80 | return () => { | ||
| 81 | for (const unsub of unsubs) { | ||
| 82 | unsub(); | ||
| 83 | } | ||
| 84 | }; | ||
| 85 | }, | ||
| 86 | [stableEnvironmentIds], | ||
| 87 | ); | ||
| 88 | |||
| 89 | const getSnapshot = useCallback(() => { | ||
| 90 | const previous = snapshotCacheRef.current; | ||
| 91 | let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; | ||
| 92 | const next: Record<EnvironmentId, ShellSnapshotState> = {}; | ||
| 93 | |||
| 94 | for (const environmentId of stableEnvironmentIds) { | ||
| 95 | const snapshot = shellSnapshotManager.getSnapshot({ environmentId }); | ||
| 96 | next[environmentId] = snapshot; | ||
| 97 | if (!hasChanged && previous[environmentId] !== snapshot) { | ||
| 98 | hasChanged = true; | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | if (!hasChanged) { | ||
| 103 | return previous; | ||
| 104 | } | ||
| 105 | |||
| 106 | snapshotCacheRef.current = next; | ||
| 107 | return next; | ||
| 108 | }, [stableEnvironmentIds]); | ||
| 109 | |||
| 110 | return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); | ||
| 111 | } | ||
apps/mobile/src/state/use-source-control-discovery.ts deleted-78| ... | @@ -1,78 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | ||
| 4 | EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | ||
| 5 | type SourceControlDiscoveryClient, | ||
| 6 | type SourceControlDiscoveryState, | ||
| 7 | type SourceControlDiscoveryTarget, | ||
| 8 | createSourceControlDiscoveryManager, | ||
| 9 | getSourceControlDiscoveryTargetKey, | ||
| 10 | sourceControlDiscoveryStateAtom, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts"; | ||
| 13 | import { useEffect, useMemo } from "react"; | ||
| 14 | |||
| 15 | import { appAtomRegistry } from "./atom-registry"; | ||
| 16 | import { | ||
| 17 | getEnvironmentClient, | ||
| 18 | subscribeEnvironmentConnections, | ||
| 19 | } from "./environment-session-registry"; | ||
| 20 | |||
| 21 | const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({ | ||
| 22 | getRegistry: () => appAtomRegistry, | ||
| 23 | getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null, | ||
| 24 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 25 | }); | ||
| 26 | |||
| 27 | function sourceControlDiscoveryTargetForEnvironment( | ||
| 28 | environmentId: EnvironmentId | null, | ||
| 29 | ): SourceControlDiscoveryTarget<EnvironmentId> { | ||
| 30 | return { key: environmentId ?? null }; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function refreshSourceControlDiscoveryForEnvironment( | ||
| 34 | environmentId: EnvironmentId | null, | ||
| 35 | client?: SourceControlDiscoveryClient | null, | ||
| 36 | ): Promise<SourceControlDiscoveryResult | null> { | ||
| 37 | return sourceControlDiscoveryManager.refresh( | ||
| 38 | sourceControlDiscoveryTargetForEnvironment(environmentId), | ||
| 39 | client ?? undefined, | ||
| 40 | ); | ||
| 41 | } | ||
| 42 | |||
| 43 | export function invalidateSourceControlDiscoveryForEnvironment( | ||
| 44 | environmentId: EnvironmentId | null, | ||
| 45 | ): void { | ||
| 46 | sourceControlDiscoveryManager.invalidate( | ||
| 47 | sourceControlDiscoveryTargetForEnvironment(environmentId), | ||
| 48 | ); | ||
| 49 | } | ||
| 50 | |||
| 51 | export function resetSourceControlDiscoveryState(): void { | ||
| 52 | sourceControlDiscoveryManager.reset(); | ||
| 53 | } | ||
| 54 | |||
| 55 | export function resetSourceControlDiscoveryStateForTests(): void { | ||
| 56 | resetSourceControlDiscoveryState(); | ||
| 57 | } | ||
| 58 | |||
| 59 | export function useSourceControlDiscovery( | ||
| 60 | environmentId: EnvironmentId | null, | ||
| 61 | ): SourceControlDiscoveryState { | ||
| 62 | const target = useMemo( | ||
| 63 | () => sourceControlDiscoveryTargetForEnvironment(environmentId), | ||
| 64 | [environmentId], | ||
| 65 | ); | ||
| 66 | |||
| 67 | useEffect(() => { | ||
| 68 | return sourceControlDiscoveryManager.watch(target); | ||
| 69 | }, [target]); | ||
| 70 | |||
| 71 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 72 | const state = useAtomValue( | ||
| 73 | targetKey !== null | ||
| 74 | ? sourceControlDiscoveryStateAtom(targetKey) | ||
| 75 | : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | ||
| 76 | ); | ||
| 77 | return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; | ||
| 78 | } | ||
apps/mobile/src/state/use-terminal-session.ts+70-72| ... | @@ -1,84 +1,82 @@ | ... | @@ -1,84 +1,82 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | 1 | import { |
| 3 | createTerminalSessionManager, | 2 | combineTerminalSessionState, |
| 4 | EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, | 3 | EMPTY_TERMINAL_BUFFER_STATE, |
| 5 | EMPTY_TERMINAL_SESSION_ATOM, | 4 | EMPTY_TERMINAL_SESSION_STATE, |
| 6 | getKnownTerminalSessionTarget, | 5 | type KnownTerminalSession, |
| 7 | getKnownTerminalSessionListFilter, | ||
| 8 | knownTerminalSessionsAtom, | ||
| 9 | terminalSessionStateAtom, | ||
| 10 | type TerminalSessionTarget, | ||
| 11 | type TerminalSessionState, | 6 | type TerminalSessionState, |
| 12 | } from "@t3tools/client-runtime"; | 7 | } from "@t3tools/client-runtime/state/terminal"; |
| 13 | import type { | 8 | import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; |
| 14 | EnvironmentId, | ||
| 15 | TerminalAttachInput, | ||
| 16 | TerminalAttachStreamEvent, | ||
| 17 | TerminalMetadataStreamEvent, | ||
| 18 | TerminalSessionSnapshot, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | import { useMemo } from "react"; | 9 | import { useMemo } from "react"; |
| 21 | 10 | ||
| 22 | import { appAtomRegistry } from "./atom-registry"; | 11 | import { useEnvironmentQuery } from "./query"; |
| 12 | import { terminalEnvironment } from "./terminal"; | ||
| 23 | 13 | ||
| 24 | export const terminalSessionManager = createTerminalSessionManager({ | 14 | export function useAttachedTerminalSession(input: { |
| 25 | getRegistry: () => appAtomRegistry, | 15 | readonly environmentId: EnvironmentId | null; |
| 26 | }); | 16 | readonly terminal: TerminalAttachInput | null; |
| 27 | 17 | }): TerminalSessionState { | |
| 28 | export function subscribeTerminalMetadata(input: { | 18 | const attach = useEnvironmentQuery( |
| 29 | readonly environmentId: EnvironmentId; | 19 | input.environmentId !== null && input.terminal !== null |
| 30 | readonly client: { | 20 | ? terminalEnvironment.attach({ |
| 31 | readonly terminal: { | 21 | environmentId: input.environmentId, |
| 32 | readonly onMetadata: ( | 22 | input: input.terminal, |
| 33 | listener: (event: TerminalMetadataStreamEvent) => void, | 23 | }) |
| 34 | options?: { readonly onResubscribe?: () => void }, | 24 | : null, |
| 35 | ) => () => void; | ||
| 36 | }; | ||
| 37 | }; | ||
| 38 | }) { | ||
| 39 | return terminalSessionManager.subscribeMetadata(input); | ||
| 40 | } | ||
| 41 | |||
| 42 | export function attachTerminalSession(input: { | ||
| 43 | readonly environmentId: EnvironmentId; | ||
| 44 | readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"]; | ||
| 45 | readonly terminal: TerminalAttachInput; | ||
| 46 | readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; | ||
| 47 | readonly onEvent?: (event: TerminalAttachStreamEvent) => void; | ||
| 48 | }) { | ||
| 49 | return terminalSessionManager.attach({ | ||
| 50 | environmentId: input.environmentId, | ||
| 51 | client: input.client, | ||
| 52 | terminal: input.terminal, | ||
| 53 | ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), | ||
| 54 | ...(input.onEvent ? { onEvent: input.onEvent } : {}), | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | |||
| 58 | export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { | ||
| 59 | const target = getKnownTerminalSessionTarget(input); | ||
| 60 | return useAtomValue( | ||
| 61 | target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, | ||
| 62 | ); | 25 | ); |
| 63 | } | 26 | const metadata = useEnvironmentQuery( |
| 64 | 27 | input.environmentId === null | |
| 65 | export function useTerminalSessionTarget(input: TerminalSessionTarget) { | 28 | ? null |
| 66 | return useMemo( | 29 | : terminalEnvironment.metadata({ |
| 67 | () => ({ | 30 | environmentId: input.environmentId, |
| 68 | environmentId: input.environmentId, | 31 | input: null, |
| 69 | threadId: input.threadId, | 32 | }), |
| 70 | terminalId: input.terminalId, | ||
| 71 | }), | ||
| 72 | [input.environmentId, input.threadId, input.terminalId], | ||
| 73 | ); | 33 | ); |
| 34 | |||
| 35 | return useMemo(() => { | ||
| 36 | if (input.environmentId === null || input.terminal === null) { | ||
| 37 | return EMPTY_TERMINAL_SESSION_STATE; | ||
| 38 | } | ||
| 39 | const summary = | ||
| 40 | metadata.data?.find( | ||
| 41 | (terminal) => | ||
| 42 | terminal.threadId === input.terminal?.threadId && | ||
| 43 | terminal.terminalId === input.terminal?.terminalId, | ||
| 44 | ) ?? null; | ||
| 45 | const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); | ||
| 46 | return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; | ||
| 47 | }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); | ||
| 74 | } | 48 | } |
| 75 | 49 | ||
| 76 | export function useKnownTerminalSessions(input: { | 50 | export function useKnownTerminalSessions(input: { |
| 77 | readonly environmentId: TerminalSessionTarget["environmentId"]; | 51 | readonly environmentId: EnvironmentId | null; |
| 78 | readonly threadId: TerminalSessionTarget["threadId"]; | 52 | readonly threadId: ThreadId | null; |
| 79 | }) { | 53 | }): ReadonlyArray<KnownTerminalSession> { |
| 80 | const filter = getKnownTerminalSessionListFilter(input); | 54 | const metadata = useEnvironmentQuery( |
| 81 | return useAtomValue( | 55 | input.environmentId === null |
| 82 | filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, | 56 | ? null |
| 57 | : terminalEnvironment.metadata({ | ||
| 58 | environmentId: input.environmentId, | ||
| 59 | input: null, | ||
| 60 | }), | ||
| 83 | ); | 61 | ); |
| 62 | return useMemo(() => { | ||
| 63 | if (input.environmentId === null) { | ||
| 64 | return []; | ||
| 65 | } | ||
| 66 | return (metadata.data ?? []) | ||
| 67 | .filter((summary) => input.threadId === null || summary.threadId === input.threadId) | ||
| 68 | .map((summary) => ({ | ||
| 69 | target: { | ||
| 70 | environmentId: input.environmentId!, | ||
| 71 | threadId: ThreadId.make(summary.threadId), | ||
| 72 | terminalId: summary.terminalId, | ||
| 73 | }, | ||
| 74 | state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), | ||
| 75 | })) | ||
| 76 | .sort((left, right) => | ||
| 77 | left.target.terminalId.localeCompare(right.target.terminalId, undefined, { | ||
| 78 | numeric: true, | ||
| 79 | }), | ||
| 80 | ); | ||
| 81 | }, [input.environmentId, input.threadId, metadata.data]); | ||
| 84 | } | 82 | } |
apps/mobile/src/state/use-thread-composer-state.ts+41-195| ... | @@ -1,10 +1,8 @@ | ... | @@ -1,10 +1,8 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useEffect, useMemo } from "react"; | 2 | import { useCallback, useEffect, useMemo } from "react"; |
| 3 | 3 | ||
| 4 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | ||
| 5 | import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; | 4 | import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; |
| 6 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; | 5 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; |
| 7 | import { Atom } from "effect/unstable/reactivity"; | ||
| 8 | 6 | ||
| 9 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; | 7 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; |
| 10 | import { | 8 | import { |
| ... | @@ -14,7 +12,7 @@ import { | ... | @@ -14,7 +12,7 @@ import { |
| 14 | } from "../lib/composerImages"; | 12 | } from "../lib/composerImages"; |
| 15 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 13 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 16 | import { scopedThreadKey } from "../lib/scopedEntities"; | 14 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 17 | import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity"; | 15 | import { buildThreadFeed } from "../lib/threadActivity"; |
| 18 | import { appAtomRegistry } from "../state/atom-registry"; | 16 | import { appAtomRegistry } from "../state/atom-registry"; |
| 19 | import { | 17 | import { |
| 20 | appendComposerDraftAttachments, | 18 | appendComposerDraftAttachments, |
| ... | @@ -26,24 +24,12 @@ import { | ... | @@ -26,24 +24,12 @@ import { |
| 26 | setComposerDraftText, | 24 | setComposerDraftText, |
| 27 | useComposerDraft, | 25 | useComposerDraft, |
| 28 | } from "./use-composer-drafts"; | 26 | } from "./use-composer-drafts"; |
| 29 | import { getEnvironmentClient } from "./environment-session-registry"; | 27 | import { setPendingConnectionError } from "../state/use-remote-environment-registry"; |
| 30 | import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types"; | ||
| 31 | import { | ||
| 32 | setPendingConnectionError, | ||
| 33 | useRemoteConnectionStatus, | ||
| 34 | } from "../state/use-remote-environment-registry"; | ||
| 35 | import { useRemoteCatalog } from "../state/use-remote-catalog"; | ||
| 36 | import { useSelectedThreadDetail } from "../state/use-thread-detail"; | 28 | import { useSelectedThreadDetail } from "../state/use-thread-detail"; |
| 37 | import { useThreadSelection } from "../state/use-thread-selection"; | 29 | import { useThreadSelection } from "../state/use-thread-selection"; |
| 38 | 30 | import { enqueueThreadOutboxMessage } from "./thread-outbox"; | |
| 39 | const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( | 31 | import { useThreadOutboxMessages } from "./use-thread-outbox"; |
| 40 | Atom.keepAlive, | 32 | import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox-drain"; |
| 41 | Atom.withLabel("mobile:thread-composer:dispatching-message-id"), | ||
| 42 | ); | ||
| 43 | |||
| 44 | const queuedMessagesByThreadKeyAtom = Atom.make<Record<string, ReadonlyArray<QueuedThreadMessage>>>( | ||
| 45 | {}, | ||
| 46 | ).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages")); | ||
| 47 | 33 | ||
| 48 | export function appendReviewCommentToDraft(input: { | 34 | export function appendReviewCommentToDraft(input: { |
| 49 | readonly environmentId: EnvironmentId; | 35 | readonly environmentId: EnvironmentId; |
| ... | @@ -76,112 +62,12 @@ export function useThreadDraftForThread(input: { | ... | @@ -76,112 +62,12 @@ export function useThreadDraftForThread(input: { |
| 76 | }; | 62 | }; |
| 77 | } | 63 | } |
| 78 | 64 | ||
| 79 | function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { | ||
| 80 | appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); | ||
| 81 | } | ||
| 82 | |||
| 83 | function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { | ||
| 84 | const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); | ||
| 85 | appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); | ||
| 86 | } | ||
| 87 | |||
| 88 | function enqueueQueuedMessage(message: QueuedThreadMessage): void { | ||
| 89 | const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); | ||
| 90 | const threadKey = scopedThreadKey(message.environmentId, message.threadId); | ||
| 91 | appAtomRegistry.set(queuedMessagesByThreadKeyAtom, { | ||
| 92 | ...current, | ||
| 93 | [threadKey]: [...(current[threadKey] ?? []), message], | ||
| 94 | }); | ||
| 95 | } | ||
| 96 | |||
| 97 | function removeQueuedMessage( | ||
| 98 | environmentId: EnvironmentId, | ||
| 99 | threadId: ThreadId, | ||
| 100 | queuedMessageId: MessageId, | ||
| 101 | ): void { | ||
| 102 | const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); | ||
| 103 | const threadKey = scopedThreadKey(environmentId, threadId); | ||
| 104 | const existing = current[threadKey]; | ||
| 105 | if (!existing) { | ||
| 106 | return; | ||
| 107 | } | ||
| 108 | |||
| 109 | const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId); | ||
| 110 | const next = { ...current }; | ||
| 111 | if (nextQueue.length === 0) { | ||
| 112 | delete next[threadKey]; | ||
| 113 | } else { | ||
| 114 | next[threadKey] = nextQueue; | ||
| 115 | } | ||
| 116 | |||
| 117 | appAtomRegistry.set(queuedMessagesByThreadKeyAtom, next); | ||
| 118 | } | ||
| 119 | |||
| 120 | function useQueueDrain(input: { | ||
| 121 | readonly dispatchingQueuedMessageId: MessageId | null; | ||
| 122 | readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>; | ||
| 123 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | ||
| 124 | readonly environments: ReadonlyArray<ConnectedEnvironmentSummary>; | ||
| 125 | readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise<void>; | ||
| 126 | }) { | ||
| 127 | const { | ||
| 128 | dispatchingQueuedMessageId, | ||
| 129 | environments, | ||
| 130 | queuedMessagesByThreadKey, | ||
| 131 | sendQueuedMessage, | ||
| 132 | threads, | ||
| 133 | } = input; | ||
| 134 | |||
| 135 | useEffect(() => { | ||
| 136 | if (dispatchingQueuedMessageId !== null) { | ||
| 137 | return; | ||
| 138 | } | ||
| 139 | |||
| 140 | for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { | ||
| 141 | const nextQueuedMessage = queuedMessages[0]; | ||
| 142 | if (!nextQueuedMessage) { | ||
| 143 | continue; | ||
| 144 | } | ||
| 145 | |||
| 146 | const thread = threads.find( | ||
| 147 | (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === threadKey, | ||
| 148 | ); | ||
| 149 | if (!thread) { | ||
| 150 | continue; | ||
| 151 | } | ||
| 152 | |||
| 153 | const environment = environments.find( | ||
| 154 | (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, | ||
| 155 | ); | ||
| 156 | if (!environment || environment.connectionState !== "ready") { | ||
| 157 | continue; | ||
| 158 | } | ||
| 159 | |||
| 160 | const threadStatus = thread.session?.status; | ||
| 161 | if (threadStatus === "running" || threadStatus === "starting") { | ||
| 162 | continue; | ||
| 163 | } | ||
| 164 | |||
| 165 | void sendQueuedMessage(nextQueuedMessage); | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | }, [ | ||
| 169 | dispatchingQueuedMessageId, | ||
| 170 | environments, | ||
| 171 | queuedMessagesByThreadKey, | ||
| 172 | sendQueuedMessage, | ||
| 173 | threads, | ||
| 174 | ]); | ||
| 175 | } | ||
| 176 | |||
| 177 | export function useThreadComposerState() { | 65 | export function useThreadComposerState() { |
| 178 | const { connectedEnvironments } = useRemoteConnectionStatus(); | ||
| 179 | const { threads } = useRemoteCatalog(); | ||
| 180 | const { selectedThread: selectedThreadShell } = useThreadSelection(); | 66 | const { selectedThread: selectedThreadShell } = useThreadSelection(); |
| 181 | const selectedThread = useSelectedThreadDetail(); | 67 | const selectedThreadDetail = useSelectedThreadDetail(); |
| 182 | const composerDrafts = useAtomValue(composerDraftsAtom); | 68 | const composerDrafts = useAtomValue(composerDraftsAtom); |
| 183 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); | 69 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); |
| 184 | const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom); | 70 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); |
| 185 | 71 | ||
| 186 | useEffect(() => { | 72 | useEffect(() => { |
| 187 | ensureComposerDraftsLoaded(); | 73 | ensureComposerDraftsLoaded(); |
| ... | @@ -197,10 +83,14 @@ export function useThreadComposerState() { | ... | @@ -197,10 +83,14 @@ export function useThreadComposerState() { |
| 197 | 83 | ||
| 198 | const selectedThreadFeed = useMemo( | 84 | const selectedThreadFeed = useMemo( |
| 199 | () => | 85 | () => |
| 200 | selectedThread | 86 | selectedThreadDetail |
| 201 | ? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId) | 87 | ? buildThreadFeed( |
| 88 | selectedThreadDetail, | ||
| 89 | selectedThreadQueuedMessages, | ||
| 90 | dispatchingQueuedMessageId, | ||
| 91 | ) | ||
| 202 | : [], | 92 | : [], |
| 203 | [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages], | 93 | [dispatchingQueuedMessageId, selectedThreadDetail, selectedThreadQueuedMessages], |
| 204 | ); | 94 | ); |
| 205 | 95 | ||
| 206 | const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; | 96 | const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; |
| ... | @@ -209,6 +99,7 @@ export function useThreadComposerState() { | ... | @@ -209,6 +99,7 @@ export function useThreadComposerState() { |
| 209 | const selectedThreadQueueCount = selectedThreadQueuedMessages.length; | 99 | const selectedThreadQueueCount = selectedThreadQueuedMessages.length; |
| 210 | 100 | ||
| 211 | const selectedThreadSessionActivity = useMemo(() => { | 101 | const selectedThreadSessionActivity = useMemo(() => { |
| 102 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; | ||
| 212 | if (!selectedThread?.session) { | 103 | if (!selectedThread?.session) { |
| 213 | return null; | 104 | return null; |
| 214 | } | 105 | } |
| ... | @@ -217,10 +108,11 @@ export function useThreadComposerState() { | ... | @@ -217,10 +108,11 @@ export function useThreadComposerState() { |
| 217 | orchestrationStatus: selectedThread.session.status, | 108 | orchestrationStatus: selectedThread.session.status, |
| 218 | activeTurnId: selectedThread.session.activeTurnId ?? undefined, | 109 | activeTurnId: selectedThread.session.activeTurnId ?? undefined, |
| 219 | }; | 110 | }; |
| 220 | }, [selectedThread]); | 111 | }, [selectedThreadDetail, selectedThreadShell]); |
| 221 | 112 | ||
| 222 | const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; | 113 | const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; |
| 223 | const activeWorkStartedAt = useMemo(() => { | 114 | const activeWorkStartedAt = useMemo(() => { |
| 115 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; | ||
| 224 | if (!selectedThread) { | 116 | if (!selectedThread) { |
| 225 | return null; | 117 | return null; |
| 226 | } | 118 | } |
| ... | @@ -230,71 +122,19 @@ export function useThreadComposerState() { | ... | @@ -230,71 +122,19 @@ export function useThreadComposerState() { |
| 230 | selectedThreadSessionActivity, | 122 | selectedThreadSessionActivity, |
| 231 | queuedSendStartedAt, | 123 | queuedSendStartedAt, |
| 232 | ); | 124 | ); |
| 233 | }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]); | 125 | }, [ |
| 126 | queuedSendStartedAt, | ||
| 127 | selectedThreadDetail, | ||
| 128 | selectedThreadSessionActivity, | ||
| 129 | selectedThreadShell, | ||
| 130 | ]); | ||
| 234 | 131 | ||
| 132 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; | ||
| 235 | const activeThreadBusy = | 133 | const activeThreadBusy = |
| 236 | !!selectedThread && | 134 | !!selectedThread && |
| 237 | (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); | 135 | (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); |
| 238 | 136 | ||
| 239 | const sendQueuedMessage = useCallback( | 137 | const onSendMessage = useCallback(async () => { |
| 240 | async (queuedMessage: QueuedThreadMessage) => { | ||
| 241 | const client = getEnvironmentClient(queuedMessage.environmentId); | ||
| 242 | const thread = threads.find( | ||
| 243 | (candidate) => | ||
| 244 | candidate.environmentId === queuedMessage.environmentId && | ||
| 245 | candidate.id === queuedMessage.threadId, | ||
| 246 | ); | ||
| 247 | if (!client || !thread) { | ||
| 248 | return; | ||
| 249 | } | ||
| 250 | |||
| 251 | beginDispatchingQueuedMessage(queuedMessage.messageId); | ||
| 252 | try { | ||
| 253 | await client.orchestration.dispatchCommand({ | ||
| 254 | type: "thread.turn.start", | ||
| 255 | commandId: queuedMessage.commandId, | ||
| 256 | threadId: queuedMessage.threadId, | ||
| 257 | message: { | ||
| 258 | messageId: queuedMessage.messageId, | ||
| 259 | role: "user", | ||
| 260 | text: queuedMessage.text, | ||
| 261 | attachments: queuedMessage.attachments, | ||
| 262 | }, | ||
| 263 | runtimeMode: thread.runtimeMode, | ||
| 264 | interactionMode: thread.interactionMode, | ||
| 265 | createdAt: queuedMessage.createdAt, | ||
| 266 | }); | ||
| 267 | |||
| 268 | removeQueuedMessage( | ||
| 269 | queuedMessage.environmentId, | ||
| 270 | queuedMessage.threadId, | ||
| 271 | queuedMessage.messageId, | ||
| 272 | ); | ||
| 273 | } catch (error) { | ||
| 274 | removeQueuedMessage( | ||
| 275 | queuedMessage.environmentId, | ||
| 276 | queuedMessage.threadId, | ||
| 277 | queuedMessage.messageId, | ||
| 278 | ); | ||
| 279 | setPendingConnectionError( | ||
| 280 | error instanceof Error ? error.message : "Failed to send message.", | ||
| 281 | ); | ||
| 282 | } finally { | ||
| 283 | finishDispatchingQueuedMessage(queuedMessage.messageId); | ||
| 284 | } | ||
| 285 | }, | ||
| 286 | [threads], | ||
| 287 | ); | ||
| 288 | |||
| 289 | useQueueDrain({ | ||
| 290 | dispatchingQueuedMessageId, | ||
| 291 | queuedMessagesByThreadKey, | ||
| 292 | threads, | ||
| 293 | environments: connectedEnvironments, | ||
| 294 | sendQueuedMessage, | ||
| 295 | }); | ||
| 296 | |||
| 297 | const onSendMessage = useCallback(() => { | ||
| 298 | if (!selectedThreadShell) { | 138 | if (!selectedThreadShell) { |
| 299 | return; | 139 | return; |
| 300 | } | 140 | } |
| ... | @@ -308,16 +148,22 @@ export function useThreadComposerState() { | ... | @@ -308,16 +148,22 @@ export function useThreadComposerState() { |
| 308 | } | 148 | } |
| 309 | 149 | ||
| 310 | const metadata = makeQueuedMessageMetadata(); | 150 | const metadata = makeQueuedMessageMetadata(); |
| 311 | enqueueQueuedMessage({ | 151 | try { |
| 312 | environmentId: selectedThreadShell.environmentId, | 152 | await enqueueThreadOutboxMessage({ |
| 313 | threadId: selectedThreadShell.id, | 153 | environmentId: selectedThreadShell.environmentId, |
| 314 | messageId: MessageId.make(metadata.messageId), | 154 | threadId: selectedThreadShell.id, |
| 315 | commandId: CommandId.make(metadata.commandId), | 155 | messageId: MessageId.make(metadata.messageId), |
| 316 | text, | 156 | commandId: CommandId.make(metadata.commandId), |
| 317 | attachments, | 157 | text, |
| 318 | createdAt: metadata.createdAt, | 158 | attachments, |
| 319 | }); | 159 | createdAt: metadata.createdAt, |
| 320 | clearComposerDraft(threadKey); | 160 | }); |
| 161 | clearComposerDraft(threadKey); | ||
| 162 | } catch (error) { | ||
| 163 | setPendingConnectionError( | ||
| 164 | error instanceof Error ? error.message : "Failed to save the queued message.", | ||
| 165 | ); | ||
| 166 | } | ||
| 321 | }, [composerDrafts, selectedThreadShell]); | 167 | }, [composerDrafts, selectedThreadShell]); |
| 322 | 168 | ||
| 323 | const onChangeDraftMessage = useCallback( | 169 | const onChangeDraftMessage = useCallback( |
apps/mobile/src/state/use-thread-detail.ts+13-69| ... | @@ -1,82 +1,26 @@ | ... | @@ -1,82 +1,26 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { | 2 | import * as Option from "effect/Option"; |
| 3 | EMPTY_THREAD_DETAIL_ATOM, | ||
| 4 | EMPTY_THREAD_DETAIL_STATE, | ||
| 5 | createThreadDetailManager, | ||
| 6 | getThreadDetailTargetKey, | ||
| 7 | threadDetailStateAtom, | ||
| 8 | type ThreadDetailState, | ||
| 9 | type ThreadDetailTarget, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useEffect, useMemo } from "react"; | ||
| 12 | 3 | ||
| 13 | import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity"; | 4 | import { useEnvironmentThread } from "./threads"; |
| 14 | import { appAtomRegistry } from "./atom-registry"; | ||
| 15 | import { | ||
| 16 | getEnvironmentClient, | ||
| 17 | subscribeEnvironmentConnections, | ||
| 18 | } from "./environment-session-registry"; | ||
| 19 | import { useThreadSelection } from "./use-thread-selection"; | 5 | import { useThreadSelection } from "./use-thread-selection"; |
| 20 | 6 | ||
| 21 | function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean { | 7 | export interface ThreadDetailTarget { |
| 22 | const thread = state.data; | 8 | readonly environmentId: EnvironmentId | null; |
| 23 | if (!thread || state.isDeleted) { | 9 | readonly threadId: ThreadId | null; |
| 24 | return false; | ||
| 25 | } | ||
| 26 | |||
| 27 | if (thread.latestTurn?.sourceProposedPlan) { | ||
| 28 | return true; | ||
| 29 | } | ||
| 30 | |||
| 31 | const sessionStatus = thread.session?.status; | ||
| 32 | if (sessionStatus && sessionStatus !== "idle" && sessionStatus !== "stopped") { | ||
| 33 | return true; | ||
| 34 | } | ||
| 35 | |||
| 36 | return ( | ||
| 37 | derivePendingApprovals(thread.activities).length > 0 || | ||
| 38 | derivePendingUserInputs(thread.activities).length > 0 | ||
| 39 | ); | ||
| 40 | } | 10 | } |
| 41 | 11 | ||
| 42 | const threadDetailManager = createThreadDetailManager({ | 12 | export function useThreadDetail(target: ThreadDetailTarget) { |
| 43 | getRegistry: () => appAtomRegistry, | 13 | return useEnvironmentThread(target.environmentId, target.threadId); |
| 44 | getClient: (environmentId) => { | ||
| 45 | const client = getEnvironmentClient(environmentId); | ||
| 46 | return client ? client.orchestration : null; | ||
| 47 | }, | ||
| 48 | getClientIdentity: (environmentId) => { | ||
| 49 | return getEnvironmentClient(environmentId) ? environmentId : null; | ||
| 50 | }, | ||
| 51 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 52 | retention: { | ||
| 53 | idleTtlMs: 5 * 60 * 1_000, | ||
| 54 | maxRetainedEntries: 24, | ||
| 55 | shouldKeepWarm: (_target, state) => shouldKeepThreadDetailWarm(state), | ||
| 56 | }, | ||
| 57 | }); | ||
| 58 | |||
| 59 | export function useThreadDetail(target: ThreadDetailTarget): ThreadDetailState { | ||
| 60 | const { environmentId, threadId } = target; | ||
| 61 | const targetKey = getThreadDetailTargetKey(target); | ||
| 62 | |||
| 63 | useEffect( | ||
| 64 | () => threadDetailManager.watch({ environmentId, threadId }), | ||
| 65 | [environmentId, threadId], | ||
| 66 | ); | ||
| 67 | |||
| 68 | const state = useAtomValue( | ||
| 69 | targetKey !== null ? threadDetailStateAtom(targetKey) : EMPTY_THREAD_DETAIL_ATOM, | ||
| 70 | ); | ||
| 71 | return targetKey === null ? EMPTY_THREAD_DETAIL_STATE : state; | ||
| 72 | } | 14 | } |
| 73 | 15 | ||
| 74 | export function useSelectedThreadDetail() { | 16 | export function useSelectedThreadDetailState() { |
| 75 | const { selectedThread } = useThreadSelection(); | 17 | const { selectedThread } = useThreadSelection(); |
| 76 | const state = useThreadDetail({ | 18 | return useThreadDetail({ |
| 77 | environmentId: selectedThread?.environmentId ?? null, | 19 | environmentId: selectedThread?.environmentId ?? null, |
| 78 | threadId: selectedThread?.id ?? null, | 20 | threadId: selectedThread?.id ?? null, |
| 79 | }); | 21 | }); |
| 22 | } | ||
| 80 | 23 | ||
| 81 | return useMemo(() => state.data, [state.data]); | 24 | export function useSelectedThreadDetail() { |
| 25 | return Option.getOrNull(useSelectedThreadDetailState().data); | ||
| 82 | } | 26 | } |
apps/mobile/src/state/use-thread-outbox-drain.ts created+210| ... | @@ -0,0 +1,210 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 3 | import { type MessageId } from "@t3tools/contracts"; | ||
| 4 | import * as Cause from "effect/Cause"; | ||
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 6 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 7 | |||
| 8 | import { scopedThreadKey } from "../lib/scopedEntities"; | ||
| 9 | import { appAtomRegistry } from "./atom-registry"; | ||
| 10 | import { useThreadShells } from "./entities"; | ||
| 11 | import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; | ||
| 12 | import { | ||
| 13 | resolveThreadOutboxDeliveryAction, | ||
| 14 | shouldRetryThreadOutboxDelivery, | ||
| 15 | threadOutboxRetryDelayMs, | ||
| 16 | type QueuedThreadMessage, | ||
| 17 | } from "./thread-outbox-model"; | ||
| 18 | import { threadEnvironment } from "./threads"; | ||
| 19 | import { useAtomCommand } from "./use-atom-command"; | ||
| 20 | import { useThreadOutboxMessages, useThreadOutboxShellStatuses } from "./use-thread-outbox"; | ||
| 21 | import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; | ||
| 22 | |||
| 23 | export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( | ||
| 24 | Atom.keepAlive, | ||
| 25 | Atom.withLabel("mobile:thread-outbox:dispatching-message-id"), | ||
| 26 | ); | ||
| 27 | |||
| 28 | function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { | ||
| 29 | appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); | ||
| 30 | } | ||
| 31 | |||
| 32 | function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { | ||
| 33 | const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); | ||
| 34 | appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); | ||
| 35 | } | ||
| 36 | |||
| 37 | function findThread( | ||
| 38 | threads: ReadonlyArray<EnvironmentThreadShell>, | ||
| 39 | message: QueuedThreadMessage, | ||
| 40 | ): EnvironmentThreadShell | undefined { | ||
| 41 | return threads.find( | ||
| 42 | (candidate) => | ||
| 43 | candidate.environmentId === message.environmentId && candidate.id === message.threadId, | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | export function useThreadOutboxDrain(): void { | ||
| 48 | const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); | ||
| 49 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); | ||
| 50 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); | ||
| 51 | const shellStatuses = useThreadOutboxShellStatuses(); | ||
| 52 | const threads = useThreadShells(); | ||
| 53 | const { connectedEnvironments } = useRemoteConnectionStatus(); | ||
| 54 | const [retryTick, setRetryTick] = useState(0); | ||
| 55 | const retryAttemptRef = useRef(new Map<MessageId, number>()); | ||
| 56 | const retryNotBeforeRef = useRef(new Map<MessageId, number>()); | ||
| 57 | const retryTimersRef = useRef(new Map<MessageId, ReturnType<typeof setTimeout>>()); | ||
| 58 | |||
| 59 | useEffect(() => { | ||
| 60 | ensureThreadOutboxLoaded(); | ||
| 61 | return () => { | ||
| 62 | for (const timer of retryTimersRef.current.values()) { | ||
| 63 | clearTimeout(timer); | ||
| 64 | } | ||
| 65 | retryTimersRef.current.clear(); | ||
| 66 | }; | ||
| 67 | }, []); | ||
| 68 | |||
| 69 | const sendQueuedMessage = useCallback( | ||
| 70 | async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { | ||
| 71 | const deliveryResult = await startTurn({ | ||
| 72 | environmentId: queuedMessage.environmentId, | ||
| 73 | input: { | ||
| 74 | commandId: queuedMessage.commandId, | ||
| 75 | threadId: queuedMessage.threadId, | ||
| 76 | message: { | ||
| 77 | messageId: queuedMessage.messageId, | ||
| 78 | role: "user", | ||
| 79 | text: queuedMessage.text, | ||
| 80 | attachments: queuedMessage.attachments, | ||
| 81 | }, | ||
| 82 | runtimeMode: thread.runtimeMode, | ||
| 83 | interactionMode: thread.interactionMode, | ||
| 84 | createdAt: queuedMessage.createdAt, | ||
| 85 | }, | ||
| 86 | }); | ||
| 87 | if (AsyncResult.isFailure(deliveryResult)) { | ||
| 88 | const error = Cause.squash(deliveryResult.cause); | ||
| 89 | const retry = | ||
| 90 | Cause.hasInterruptsOnly(deliveryResult.cause) || shouldRetryThreadOutboxDelivery(error); | ||
| 91 | console.warn("[thread-outbox] queued message delivery failed", { | ||
| 92 | environmentId: queuedMessage.environmentId, | ||
| 93 | threadId: queuedMessage.threadId, | ||
| 94 | messageId: queuedMessage.messageId, | ||
| 95 | cause: deliveryResult.cause, | ||
| 96 | retry, | ||
| 97 | }); | ||
| 98 | if (retry) { | ||
| 99 | return false; | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | try { | ||
| 104 | await removeThreadOutboxMessage(queuedMessage); | ||
| 105 | return true; | ||
| 106 | } catch (error) { | ||
| 107 | console.warn("[thread-outbox] failed to remove delivered queued message", { | ||
| 108 | environmentId: queuedMessage.environmentId, | ||
| 109 | threadId: queuedMessage.threadId, | ||
| 110 | messageId: queuedMessage.messageId, | ||
| 111 | error, | ||
| 112 | }); | ||
| 113 | return false; | ||
| 114 | } | ||
| 115 | }, | ||
| 116 | [startTurn], | ||
| 117 | ); | ||
| 118 | |||
| 119 | useEffect(() => { | ||
| 120 | if (dispatchingQueuedMessageId !== null) { | ||
| 121 | return; | ||
| 122 | } | ||
| 123 | |||
| 124 | for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { | ||
| 125 | const nextQueuedMessage = queuedMessages[0]; | ||
| 126 | if (!nextQueuedMessage) { | ||
| 127 | continue; | ||
| 128 | } | ||
| 129 | if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { | ||
| 130 | continue; | ||
| 131 | } | ||
| 132 | |||
| 133 | const thread = findThread(threads, nextQueuedMessage); | ||
| 134 | if (thread && scopedThreadKey(thread.environmentId, thread.id) !== threadKey) { | ||
| 135 | continue; | ||
| 136 | } | ||
| 137 | |||
| 138 | const environment = connectedEnvironments.find( | ||
| 139 | (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, | ||
| 140 | ); | ||
| 141 | const deliveryAction = resolveThreadOutboxDeliveryAction({ | ||
| 142 | threadExists: thread !== undefined, | ||
| 143 | shellStatus: shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty", | ||
| 144 | environmentConnected: environment?.connectionState === "connected", | ||
| 145 | threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", | ||
| 146 | }); | ||
| 147 | if (deliveryAction === "wait") { | ||
| 148 | continue; | ||
| 149 | } | ||
| 150 | |||
| 151 | beginDispatchingQueuedMessage(nextQueuedMessage.messageId); | ||
| 152 | const delivery = | ||
| 153 | deliveryAction === "remove" | ||
| 154 | ? removeThreadOutboxMessage(nextQueuedMessage).then( | ||
| 155 | () => true, | ||
| 156 | (error) => { | ||
| 157 | console.warn("[thread-outbox] failed to remove message for a missing thread", { | ||
| 158 | environmentId: nextQueuedMessage.environmentId, | ||
| 159 | threadId: nextQueuedMessage.threadId, | ||
| 160 | messageId: nextQueuedMessage.messageId, | ||
| 161 | error, | ||
| 162 | }); | ||
| 163 | return false; | ||
| 164 | }, | ||
| 165 | ) | ||
| 166 | : thread !== undefined | ||
| 167 | ? sendQueuedMessage(nextQueuedMessage, thread) | ||
| 168 | : Promise.resolve(false); | ||
| 169 | void delivery | ||
| 170 | .then((sent) => { | ||
| 171 | if (sent) { | ||
| 172 | retryAttemptRef.current.delete(nextQueuedMessage.messageId); | ||
| 173 | retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); | ||
| 174 | const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); | ||
| 175 | if (pendingTimer !== undefined) { | ||
| 176 | clearTimeout(pendingTimer); | ||
| 177 | retryTimersRef.current.delete(nextQueuedMessage.messageId); | ||
| 178 | } | ||
| 179 | return; | ||
| 180 | } | ||
| 181 | |||
| 182 | const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1; | ||
| 183 | retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt); | ||
| 184 | const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); | ||
| 185 | retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs); | ||
| 186 | const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); | ||
| 187 | if (pendingTimer !== undefined) { | ||
| 188 | clearTimeout(pendingTimer); | ||
| 189 | } | ||
| 190 | const retryTimer = setTimeout(() => { | ||
| 191 | retryTimersRef.current.delete(nextQueuedMessage.messageId); | ||
| 192 | setRetryTick((current) => current + 1); | ||
| 193 | }, retryDelayMs); | ||
| 194 | retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer); | ||
| 195 | }) | ||
| 196 | .finally(() => { | ||
| 197 | finishDispatchingQueuedMessage(nextQueuedMessage.messageId); | ||
| 198 | }); | ||
| 199 | return; | ||
| 200 | } | ||
| 201 | }, [ | ||
| 202 | connectedEnvironments, | ||
| 203 | dispatchingQueuedMessageId, | ||
| 204 | queuedMessagesByThreadKey, | ||
| 205 | retryTick, | ||
| 206 | sendQueuedMessage, | ||
| 207 | shellStatuses, | ||
| 208 | threads, | ||
| 209 | ]); | ||
| 210 | } | ||
apps/mobile/src/state/use-thread-outbox.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import { Atom } from "effect/unstable/reactivity"; | ||
| 5 | |||
| 6 | import { environmentShell } from "./shell"; | ||
| 7 | import { threadOutboxManager } from "./thread-outbox"; | ||
| 8 | |||
| 9 | const threadOutboxShellStatusesAtom = Atom.make( | ||
| 10 | (get): ReadonlyMap<EnvironmentId, EnvironmentShellStatus> => { | ||
| 11 | const statuses = new Map<EnvironmentId, EnvironmentShellStatus>(); | ||
| 12 | for (const queue of Object.values(get(threadOutboxManager.queuedMessagesByThreadKeyAtom))) { | ||
| 13 | const environmentId = queue[0]?.environmentId; | ||
| 14 | if (environmentId !== undefined && !statuses.has(environmentId)) { | ||
| 15 | statuses.set(environmentId, get(environmentShell.stateValueAtom(environmentId)).status); | ||
| 16 | } | ||
| 17 | } | ||
| 18 | return statuses; | ||
| 19 | }, | ||
| 20 | ).pipe(Atom.withLabel("mobile:thread-outbox:shell-statuses")); | ||
| 21 | |||
| 22 | export function useThreadOutboxMessages() { | ||
| 23 | return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom); | ||
| 24 | } | ||
| 25 | |||
| 26 | export function useThreadOutboxShellStatuses() { | ||
| 27 | return useAtomValue(threadOutboxShellStatusesAtom); | ||
| 28 | } | ||
apps/mobile/src/state/use-thread-selection.ts+20-56| ... | @@ -1,11 +1,12 @@ | ... | @@ -1,11 +1,12 @@ |
| 1 | import { useLocalSearchParams } from "expo-router"; | 1 | import { useLocalSearchParams } from "expo-router"; |
| 2 | import { useMemo } from "react"; | 2 | import { useMemo } from "react"; |
| 3 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 3 | import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | 5 | import { useProject, useThreadShell } from "../state/entities"; |
| 6 | import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime"; | 6 | import { |
| 7 | import { useRemoteCatalog } from "./use-remote-catalog"; | 7 | useRemoteEnvironmentRuntime, |
| 8 | import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; | 8 | useSavedRemoteConnection, |
| 9 | } from "./use-remote-environment-registry"; | ||
| 9 | 10 | ||
| 10 | function firstRouteParam(value: string | string[] | undefined): string | null { | 11 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 11 | if (Array.isArray(value)) { | 12 | if (Array.isArray(value)) { |
| ... | @@ -15,43 +16,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null { | ... | @@ -15,43 +16,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null { |
| 15 | return value ?? null; | 16 | return value ?? null; |
| 16 | } | 17 | } |
| 17 | 18 | ||
| 18 | function deriveSelectedThread( | ||
| 19 | selectedThreadRef: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null, | ||
| 20 | threads: ReadonlyArray<EnvironmentScopedThreadShell>, | ||
| 21 | ): EnvironmentScopedThreadShell | null { | ||
| 22 | if (!selectedThreadRef) { | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | |||
| 26 | return ( | ||
| 27 | threads.find( | ||
| 28 | (thread) => | ||
| 29 | thread.environmentId === selectedThreadRef.environmentId && | ||
| 30 | thread.id === selectedThreadRef.threadId, | ||
| 31 | ) ?? null | ||
| 32 | ); | ||
| 33 | } | ||
| 34 | |||
| 35 | function deriveSelectedThreadProject( | ||
| 36 | selectedThread: EnvironmentScopedThreadShell | null, | ||
| 37 | projects: ReadonlyArray<EnvironmentScopedProjectShell>, | ||
| 38 | ): EnvironmentScopedProjectShell | null { | ||
| 39 | if (!selectedThread) { | ||
| 40 | return null; | ||
| 41 | } | ||
| 42 | |||
| 43 | return ( | ||
| 44 | projects.find( | ||
| 45 | (project) => | ||
| 46 | project.environmentId === selectedThread.environmentId && | ||
| 47 | project.id === selectedThread.projectId, | ||
| 48 | ) ?? null | ||
| 49 | ); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function useThreadSelection() { | 19 | export function useThreadSelection() { |
| 53 | const { projects, threads } = useRemoteCatalog(); | ||
| 54 | const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 55 | const params = useLocalSearchParams<{ | 20 | const params = useLocalSearchParams<{ |
| 56 | environmentId?: string | string[]; | 21 | environmentId?: string | string[]; |
| 57 | threadId?: string | string[]; | 22 | threadId?: string | string[]; |
| ... | @@ -68,22 +33,21 @@ export function useThreadSelection() { | ... | @@ -68,22 +33,21 @@ export function useThreadSelection() { |
| 68 | threadId: ThreadId.make(threadId), | 33 | threadId: ThreadId.make(threadId), |
| 69 | }; | 34 | }; |
| 70 | }, [params.environmentId, params.threadId]); | 35 | }, [params.environmentId, params.threadId]); |
| 71 | const selectedThread = useMemo( | 36 | const selectedThread = useThreadShell(selectedThreadRef); |
| 72 | () => deriveSelectedThread(selectedThreadRef, threads), | 37 | const selectedProjectRef = useMemo<ScopedProjectRef | null>( |
| 73 | [selectedThreadRef, threads], | 38 | () => |
| 39 | selectedThread === null | ||
| 40 | ? null | ||
| 41 | : { | ||
| 42 | environmentId: selectedThread.environmentId, | ||
| 43 | projectId: selectedThread.projectId, | ||
| 44 | }, | ||
| 45 | [selectedThread], | ||
| 74 | ); | 46 | ); |
| 75 | 47 | const selectedThreadProject = useProject(selectedProjectRef); | |
| 76 | const selectedThreadProject = useMemo( | 48 | const selectedEnvironmentId = selectedThread?.environmentId ?? null; |
| 77 | () => deriveSelectedThreadProject(selectedThread, projects), | 49 | const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); |
| 78 | [projects, selectedThread], | 50 | const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); |
| 79 | ); | ||
| 80 | |||
| 81 | const selectedEnvironmentConnection = selectedThread | ||
| 82 | ? (savedConnectionsById[selectedThread.environmentId] ?? null) | ||
| 83 | : null; | ||
| 84 | const selectedEnvironmentRuntime = selectedThread | ||
| 85 | ? (environmentStateById[selectedThread.environmentId] ?? null) | ||
| 86 | : null; | ||
| 87 | 51 | ||
| 88 | return { | 52 | return { |
| 89 | selectedThreadRef, | 53 | selectedThreadRef, |
apps/mobile/src/state/use-vcs-action-state.ts+15-51| ... | @@ -1,40 +1,15 @@ | ... | @@ -1,40 +1,15 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { type VcsActionState, type VcsActionTarget } from "@t3tools/client-runtime/state/vcs"; |
| 3 | type VcsActionState, | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 4 | type VcsActionTarget, | ||
| 5 | EMPTY_VCS_ACTION_ATOM, | ||
| 6 | EMPTY_VCS_ACTION_STATE, | ||
| 7 | createVcsActionManager, | ||
| 8 | getVcsActionTargetKey, | ||
| 9 | vcsActionStateAtom, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useCallback, useEffect, useRef, useState } from "react"; | 4 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 12 | 5 | ||
| 13 | import { uuidv4 } from "../lib/uuid"; | ||
| 14 | import { appAtomRegistry } from "./atom-registry"; | 6 | import { appAtomRegistry } from "./atom-registry"; |
| 15 | import { getEnvironmentClient } from "./environment-session-registry"; | 7 | import { vcsActionManager } from "./vcs"; |
| 16 | |||
| 17 | export const vcsActionManager = createVcsActionManager({ | ||
| 18 | getRegistry: () => appAtomRegistry, | ||
| 19 | getClient: (environmentId) => { | ||
| 20 | const client = getEnvironmentClient(environmentId); | ||
| 21 | return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; | ||
| 22 | }, | ||
| 23 | getActionId: uuidv4, | ||
| 24 | }); | ||
| 25 | 8 | ||
| 26 | export function useVcsActionState(target: VcsActionTarget): VcsActionState { | 9 | export function useVcsActionState(target: VcsActionTarget): VcsActionState { |
| 27 | const targetKey = getVcsActionTargetKey(target); | 10 | return useAtomValue(vcsActionManager.stateAtom(target)); |
| 28 | const state = useAtomValue( | ||
| 29 | targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, | ||
| 30 | ); | ||
| 31 | return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; | ||
| 32 | } | 11 | } |
| 33 | 12 | ||
| 34 | // --------------------------------------------------------------------------- | ||
| 35 | // Git action result notification | ||
| 36 | // --------------------------------------------------------------------------- | ||
| 37 | |||
| 38 | export interface GitActionResultNotification { | 13 | export interface GitActionResultNotification { |
| 39 | readonly type: "success" | "error"; | 14 | readonly type: "success" | "error"; |
| 40 | readonly title: string; | 15 | readonly title: string; |
| ... | @@ -44,26 +19,28 @@ export interface GitActionResultNotification { | ... | @@ -44,26 +19,28 @@ export interface GitActionResultNotification { |
| 44 | 19 | ||
| 45 | const RESULT_DISMISS_MS = 5_000; | 20 | const RESULT_DISMISS_MS = 5_000; |
| 46 | 21 | ||
| 47 | type ResultListener = (result: GitActionResultNotification | null) => void; | 22 | const gitActionResultAtom = Atom.make<GitActionResultNotification | null>(null).pipe( |
| 48 | const resultListeners = new Set<ResultListener>(); | 23 | Atom.keepAlive, |
| 49 | let currentResult: GitActionResultNotification | null = null; | 24 | Atom.withLabel("mobile:git-action-result"), |
| 25 | ); | ||
| 50 | let dismissTimer: ReturnType<typeof setTimeout> | null = null; | 26 | let dismissTimer: ReturnType<typeof setTimeout> | null = null; |
| 51 | 27 | ||
| 52 | function broadcast(result: GitActionResultNotification | null): void { | 28 | function broadcast(result: GitActionResultNotification | null): void { |
| 53 | currentResult = result; | 29 | appAtomRegistry.set(gitActionResultAtom, result); |
| 54 | for (const listener of resultListeners) { | ||
| 55 | listener(result); | ||
| 56 | } | ||
| 57 | } | 30 | } |
| 58 | 31 | ||
| 59 | export function showGitActionResult(result: GitActionResultNotification): void { | 32 | export function showGitActionResult(result: GitActionResultNotification): void { |
| 60 | if (dismissTimer) clearTimeout(dismissTimer); | 33 | if (dismissTimer) clearTimeout(dismissTimer); |
| 61 | broadcast(result); | 34 | broadcast(result); |
| 62 | dismissTimer = setTimeout(() => broadcast(null), RESULT_DISMISS_MS); | 35 | dismissTimer = setTimeout(() => { |
| 36 | dismissTimer = null; | ||
| 37 | broadcast(null); | ||
| 38 | }, RESULT_DISMISS_MS); | ||
| 63 | } | 39 | } |
| 64 | 40 | ||
| 65 | export function dismissGitActionResult(): void { | 41 | export function dismissGitActionResult(): void { |
| 66 | if (dismissTimer) clearTimeout(dismissTimer); | 42 | if (dismissTimer) clearTimeout(dismissTimer); |
| 43 | dismissTimer = null; | ||
| 67 | broadcast(null); | 44 | broadcast(null); |
| 68 | } | 45 | } |
| 69 | 46 | ||
| ... | @@ -71,23 +48,10 @@ export function useGitActionResultNotification(): { | ... | @@ -71,23 +48,10 @@ export function useGitActionResultNotification(): { |
| 71 | readonly result: GitActionResultNotification | null; | 48 | readonly result: GitActionResultNotification | null; |
| 72 | readonly dismiss: () => void; | 49 | readonly dismiss: () => void; |
| 73 | } { | 50 | } { |
| 74 | const [result, setResult] = useState<GitActionResultNotification | null>(currentResult); | 51 | const result = useAtomValue(gitActionResultAtom); |
| 75 | |||
| 76 | useEffect(() => { | ||
| 77 | resultListeners.add(setResult); | ||
| 78 | setResult(currentResult); | ||
| 79 | return () => { | ||
| 80 | resultListeners.delete(setResult); | ||
| 81 | }; | ||
| 82 | }, []); | ||
| 83 | |||
| 84 | return { result, dismiss: dismissGitActionResult }; | 52 | return { result, dismiss: dismissGitActionResult }; |
| 85 | } | 53 | } |
| 86 | 54 | ||
| 87 | // --------------------------------------------------------------------------- | ||
| 88 | // Unified git action progress (combines running state + result notification) | ||
| 89 | // --------------------------------------------------------------------------- | ||
| 90 | |||
| 91 | export type GitActionProgressPhase = "idle" | "running" | "success" | "error"; | 55 | export type GitActionProgressPhase = "idle" | "running" | "success" | "error"; |
| 92 | 56 | ||
| 93 | export interface GitActionProgress { | 57 | export interface GitActionProgress { |
apps/mobile/src/state/use-vcs-refs.ts deleted-51| ... | @@ -1,51 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useEffect, useMemo } from "react"; | ||
| 3 | import { | ||
| 4 | type VcsRefState, | ||
| 5 | type VcsRefTarget, | ||
| 6 | EMPTY_VCS_REF_ATOM, | ||
| 7 | EMPTY_VCS_REF_STATE, | ||
| 8 | createVcsRefManager, | ||
| 9 | getVcsRefTargetKey, | ||
| 10 | vcsRefStateAtom, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | |||
| 13 | import { appAtomRegistry } from "./atom-registry"; | ||
| 14 | import { | ||
| 15 | getEnvironmentClient, | ||
| 16 | subscribeEnvironmentConnections, | ||
| 17 | } from "./environment-session-registry"; | ||
| 18 | |||
| 19 | const VCS_REF_LIST_LIMIT = 100; | ||
| 20 | const VCS_REF_STALE_TIME_MS = 5_000; | ||
| 21 | |||
| 22 | export const vcsRefManager = createVcsRefManager({ | ||
| 23 | getRegistry: () => appAtomRegistry, | ||
| 24 | getClient: (environmentId) => { | ||
| 25 | const client = getEnvironmentClient(environmentId); | ||
| 26 | return client ? client.vcs : null; | ||
| 27 | }, | ||
| 28 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 29 | watchLimit: VCS_REF_LIST_LIMIT, | ||
| 30 | staleTimeMs: VCS_REF_STALE_TIME_MS, | ||
| 31 | onBackgroundError: (error) => { | ||
| 32 | console.warn("[vcs-refs] background refresh failed", error); | ||
| 33 | }, | ||
| 34 | }); | ||
| 35 | |||
| 36 | export function useVcsRefs(target: VcsRefTarget): VcsRefState { | ||
| 37 | const stableTarget = useMemo( | ||
| 38 | () => ({ | ||
| 39 | environmentId: target.environmentId, | ||
| 40 | cwd: target.cwd, | ||
| 41 | query: target.query ?? null, | ||
| 42 | }), | ||
| 43 | [target.cwd, target.environmentId, target.query], | ||
| 44 | ); | ||
| 45 | const targetKey = getVcsRefTargetKey(stableTarget); | ||
| 46 | |||
| 47 | useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); | ||
| 48 | |||
| 49 | const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); | ||
| 50 | return targetKey === null ? EMPTY_VCS_REF_STATE : state; | ||
| 51 | } | ||
apps/mobile/src/state/use-vcs-status.ts deleted-62| ... | @@ -1,62 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type VcsStatusState, | ||
| 4 | type VcsStatusTarget, | ||
| 5 | EMPTY_VCS_STATUS_ATOM, | ||
| 6 | EMPTY_VCS_STATUS_STATE, | ||
| 7 | createVcsStatusManager, | ||
| 8 | getVcsStatusTargetKey, | ||
| 9 | vcsStatusStateAtom, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useEffect } from "react"; | ||
| 12 | |||
| 13 | import { appAtomRegistry } from "./atom-registry"; | ||
| 14 | import { | ||
| 15 | getEnvironmentClient, | ||
| 16 | subscribeEnvironmentConnections, | ||
| 17 | } from "./environment-session-registry"; | ||
| 18 | |||
| 19 | /** | ||
| 20 | * Singleton VCS status manager for the mobile app. | ||
| 21 | * | ||
| 22 | * Uses ref-counted `onStatus` subscriptions (one per unique cwd) | ||
| 23 | * rather than one-shot `refreshStatus` RPCs. Multiple threads | ||
| 24 | * sharing the same cwd (i.e. same project, no worktree) share | ||
| 25 | * a single WS subscription. | ||
| 26 | * | ||
| 27 | * `subscribeClientChanges` ensures subscriptions are established | ||
| 28 | * even when the WS connection isn't ready at mount time, and | ||
| 29 | * re-established on reconnection. | ||
| 30 | */ | ||
| 31 | export const vcsStatusManager = createVcsStatusManager({ | ||
| 32 | getRegistry: () => appAtomRegistry, | ||
| 33 | getClient: (environmentId) => { | ||
| 34 | const client = getEnvironmentClient(environmentId); | ||
| 35 | return client ? client.vcs : null; | ||
| 36 | }, | ||
| 37 | getClientIdentity: (environmentId) => { | ||
| 38 | return getEnvironmentClient(environmentId) ? environmentId : null; | ||
| 39 | }, | ||
| 40 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 41 | }); | ||
| 42 | |||
| 43 | /** | ||
| 44 | * Subscribe to live VCS status for a target (environmentId + cwd). | ||
| 45 | * | ||
| 46 | * Mirrors the web's `useVcsStatus` hook. Automatically subscribes | ||
| 47 | * on mount, ref-counts shared cwds, and unsubscribes on unmount. | ||
| 48 | * Returns reactive `VcsStatusState` via Effect atoms. | ||
| 49 | */ | ||
| 50 | export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { | ||
| 51 | const targetKey = getVcsStatusTargetKey(target); | ||
| 52 | |||
| 53 | useEffect( | ||
| 54 | () => vcsStatusManager.watch({ environmentId: target.environmentId, cwd: target.cwd }), | ||
| 55 | [target.environmentId, target.cwd], | ||
| 56 | ); | ||
| 57 | |||
| 58 | const state = useAtomValue( | ||
| 59 | targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, | ||
| 60 | ); | ||
| 61 | return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; | ||
| 62 | } | ||
apps/mobile/src/state/vcs.ts created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | import { | ||
| 2 | createVcsActionManager, | ||
| 3 | createVcsEnvironmentAtoms, | ||
| 4 | } from "@t3tools/client-runtime/state/vcs"; | ||
| 5 | |||
| 6 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 7 | |||
| 8 | export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); | ||
| 9 | export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); | ||
apps/mobile/src/state/workspace.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useMemo } from "react"; | ||
| 3 | |||
| 4 | import { environmentShellSummaryAtom } from "./shell"; | ||
| 5 | import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; | ||
| 6 | import { useEnvironments } from "./environments"; | ||
| 7 | |||
| 8 | export function useWorkspaceState() { | ||
| 9 | const { isReady, networkStatus, environments } = useEnvironments(); | ||
| 10 | const shellSummary = useAtomValue(environmentShellSummaryAtom); | ||
| 11 | const projectedEnvironments = useMemo( | ||
| 12 | () => environments.map(projectWorkspaceEnvironment), | ||
| 13 | [environments], | ||
| 14 | ); | ||
| 15 | const state = useMemo( | ||
| 16 | () => | ||
| 17 | projectWorkspaceState({ | ||
| 18 | isReady, | ||
| 19 | networkStatus, | ||
| 20 | environments: projectedEnvironments, | ||
| 21 | shellSummary, | ||
| 22 | }), | ||
| 23 | [isReady, networkStatus, projectedEnvironments, shellSummary], | ||
| 24 | ); | ||
| 25 | |||
| 26 | return { | ||
| 27 | environments: projectedEnvironments, | ||
| 28 | state, | ||
| 29 | }; | ||
| 30 | } | ||
apps/mobile/src/state/workspaceModel.test.ts created+123| ... | @@ -0,0 +1,123 @@ | ||
| 1 | import type { EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import { | ||
| 3 | BearerConnectionProfile, | ||
| 4 | BearerConnectionTarget, | ||
| 5 | } from "@t3tools/client-runtime/connection"; | ||
| 6 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | |||
| 10 | import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; | ||
| 11 | import type { EnvironmentPresentation } from "./environments"; | ||
| 12 | |||
| 13 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | ||
| 14 | |||
| 15 | function environment( | ||
| 16 | phase: EnvironmentPresentation["connection"]["phase"], | ||
| 17 | ): EnvironmentPresentation { | ||
| 18 | const connectionId = `bearer:${ENVIRONMENT_ID}`; | ||
| 19 | return { | ||
| 20 | environmentId: ENVIRONMENT_ID, | ||
| 21 | label: "Julius's MacBook Pro", | ||
| 22 | displayUrl: "https://environment.example.test", | ||
| 23 | relayManaged: false, | ||
| 24 | entry: { | ||
| 25 | target: new BearerConnectionTarget({ | ||
| 26 | environmentId: ENVIRONMENT_ID, | ||
| 27 | label: "Julius's MacBook Pro", | ||
| 28 | connectionId, | ||
| 29 | }), | ||
| 30 | profile: Option.some( | ||
| 31 | new BearerConnectionProfile({ | ||
| 32 | connectionId, | ||
| 33 | environmentId: ENVIRONMENT_ID, | ||
| 34 | label: "Julius's MacBook Pro", | ||
| 35 | httpBaseUrl: "https://environment.example.test", | ||
| 36 | wsBaseUrl: "wss://environment.example.test", | ||
| 37 | }), | ||
| 38 | ), | ||
| 39 | }, | ||
| 40 | connection: { | ||
| 41 | phase, | ||
| 42 | error: phase === "error" ? "Connection failed." : null, | ||
| 43 | traceId: phase === "error" ? "trace-1" : null, | ||
| 44 | }, | ||
| 45 | serverConfig: null, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | |||
| 49 | const EMPTY_SHELL_SUMMARY: EnvironmentShellSummary = { | ||
| 50 | hasSnapshot: false, | ||
| 51 | hasSynchronizingShell: false, | ||
| 52 | hasCachedShell: false, | ||
| 53 | hasLiveShell: false, | ||
| 54 | firstError: null, | ||
| 55 | latestSnapshotUpdatedAt: null, | ||
| 56 | }; | ||
| 57 | |||
| 58 | const CACHED_SHELL_SUMMARY: EnvironmentShellSummary = { | ||
| 59 | ...EMPTY_SHELL_SUMMARY, | ||
| 60 | hasSnapshot: true, | ||
| 61 | hasSynchronizingShell: true, | ||
| 62 | hasCachedShell: true, | ||
| 63 | latestSnapshotUpdatedAt: "2026-06-07T00:00:00.000Z", | ||
| 64 | }; | ||
| 65 | |||
| 66 | describe("mobile workspace projection", () => { | ||
| 67 | it("preserves explicit offline state without presenting it as a connection error", () => { | ||
| 68 | const projected = projectWorkspaceEnvironment(environment("offline")); | ||
| 69 | |||
| 70 | expect(projected.connectionState).toBe("offline"); | ||
| 71 | expect(projected.connectionError).toBeNull(); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("reports offline before stale connected presentations", () => { | ||
| 75 | const environments = [projectWorkspaceEnvironment(environment("connected"))]; | ||
| 76 | const state = projectWorkspaceState({ | ||
| 77 | isReady: true, | ||
| 78 | networkStatus: "offline", | ||
| 79 | environments, | ||
| 80 | shellSummary: EMPTY_SHELL_SUMMARY, | ||
| 81 | }); | ||
| 82 | |||
| 83 | expect(state.connectionState).toBe("offline"); | ||
| 84 | expect(state.networkStatus).toBe("offline"); | ||
| 85 | expect(state.hasReadyEnvironment).toBe(false); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("projects reconnecting environments dynamically from active phases", () => { | ||
| 89 | const environments = [ | ||
| 90 | projectWorkspaceEnvironment(environment("reconnecting")), | ||
| 91 | projectWorkspaceEnvironment({ | ||
| 92 | ...environment("connected"), | ||
| 93 | environmentId: EnvironmentId.make("environment-2"), | ||
| 94 | }), | ||
| 95 | ]; | ||
| 96 | const state = projectWorkspaceState({ | ||
| 97 | isReady: true, | ||
| 98 | networkStatus: "online", | ||
| 99 | environments, | ||
| 100 | shellSummary: EMPTY_SHELL_SUMMARY, | ||
| 101 | }); | ||
| 102 | |||
| 103 | expect(state.connectingEnvironments).toHaveLength(1); | ||
| 104 | expect(state.connectingEnvironments[0]?.connectionState).toBe("reconnecting"); | ||
| 105 | expect(state.hasConnectingEnvironment).toBe(true); | ||
| 106 | expect(state.hasReadyEnvironment).toBe(true); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("keeps retained snapshots visible while reconnecting without claiming readiness", () => { | ||
| 110 | const environments = [projectWorkspaceEnvironment(environment("reconnecting"))]; | ||
| 111 | const state = projectWorkspaceState({ | ||
| 112 | isReady: true, | ||
| 113 | networkStatus: "online", | ||
| 114 | environments, | ||
| 115 | shellSummary: CACHED_SHELL_SUMMARY, | ||
| 116 | }); | ||
| 117 | |||
| 118 | expect(state.hasLoadedShellSnapshot).toBe(true); | ||
| 119 | expect(state.hasPendingShellSnapshot).toBe(true); | ||
| 120 | expect(state.hasReadyEnvironment).toBe(false); | ||
| 121 | expect(state.connectionState).toBe("reconnecting"); | ||
| 122 | }); | ||
| 123 | }); | ||
apps/mobile/src/state/workspaceModel.ts created+107| ... | @@ -0,0 +1,107 @@ | ||
| 1 | import { type EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import { type NetworkStatus } from "@t3tools/client-runtime/connection"; | ||
| 3 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 4 | import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import type { EnvironmentPresentation } from "./environments"; | ||
| 7 | |||
| 8 | export interface WorkspaceEnvironment { | ||
| 9 | readonly environmentId: EnvironmentId; | ||
| 10 | readonly environmentLabel: string; | ||
| 11 | readonly displayUrl: string; | ||
| 12 | readonly isRelayManaged: boolean; | ||
| 13 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 14 | readonly connectionError: string | null; | ||
| 15 | readonly connectionErrorTraceId: string | null; | ||
| 16 | } | ||
| 17 | |||
| 18 | export interface WorkspaceState { | ||
| 19 | readonly isLoadingConnections: boolean; | ||
| 20 | readonly hasConnections: boolean; | ||
| 21 | readonly hasLoadedShellSnapshot: boolean; | ||
| 22 | readonly hasPendingShellSnapshot: boolean; | ||
| 23 | readonly hasReadyEnvironment: boolean; | ||
| 24 | readonly hasConnectingEnvironment: boolean; | ||
| 25 | readonly connectingEnvironments: ReadonlyArray<WorkspaceEnvironment>; | ||
| 26 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 27 | readonly connectionError: string | null; | ||
| 28 | readonly shellSnapshotError: string | null; | ||
| 29 | readonly latestCachedSnapshotReceivedAt: string | null; | ||
| 30 | readonly networkStatus: NetworkStatus; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function projectWorkspaceEnvironment( | ||
| 34 | environment: EnvironmentPresentation, | ||
| 35 | ): WorkspaceEnvironment { | ||
| 36 | return { | ||
| 37 | environmentId: environment.environmentId, | ||
| 38 | environmentLabel: environment.label, | ||
| 39 | displayUrl: environment.displayUrl ?? "", | ||
| 40 | isRelayManaged: environment.relayManaged, | ||
| 41 | connectionState: environment.connection.phase, | ||
| 42 | connectionError: environment.connection.error, | ||
| 43 | connectionErrorTraceId: environment.connection.traceId, | ||
| 44 | }; | ||
| 45 | } | ||
| 46 | |||
| 47 | function overallConnectionState( | ||
| 48 | environments: ReadonlyArray<WorkspaceEnvironment>, | ||
| 49 | networkStatus: NetworkStatus, | ||
| 50 | ): EnvironmentConnectionPhase { | ||
| 51 | if (environments.length === 0) { | ||
| 52 | return "available"; | ||
| 53 | } | ||
| 54 | if (networkStatus === "offline") { | ||
| 55 | return "offline"; | ||
| 56 | } | ||
| 57 | if (environments.some((environment) => environment.connectionState === "connected")) { | ||
| 58 | return "connected"; | ||
| 59 | } | ||
| 60 | if (environments.some((environment) => environment.connectionState === "reconnecting")) { | ||
| 61 | return "reconnecting"; | ||
| 62 | } | ||
| 63 | if (environments.some((environment) => environment.connectionState === "connecting")) { | ||
| 64 | return "connecting"; | ||
| 65 | } | ||
| 66 | if (environments.some((environment) => environment.connectionState === "error")) { | ||
| 67 | return "error"; | ||
| 68 | } | ||
| 69 | if (environments.some((environment) => environment.connectionState === "offline")) { | ||
| 70 | return "offline"; | ||
| 71 | } | ||
| 72 | return "available"; | ||
| 73 | } | ||
| 74 | |||
| 75 | export function projectWorkspaceState(input: { | ||
| 76 | readonly isReady: boolean; | ||
| 77 | readonly networkStatus: NetworkStatus; | ||
| 78 | readonly environments: ReadonlyArray<WorkspaceEnvironment>; | ||
| 79 | readonly shellSummary: EnvironmentShellSummary; | ||
| 80 | }): WorkspaceState { | ||
| 81 | const connectingEnvironments = input.environments.filter( | ||
| 82 | (environment) => | ||
| 83 | environment.connectionState === "connecting" || | ||
| 84 | environment.connectionState === "reconnecting", | ||
| 85 | ); | ||
| 86 | |||
| 87 | return { | ||
| 88 | isLoadingConnections: !input.isReady, | ||
| 89 | hasConnections: input.environments.length > 0, | ||
| 90 | hasLoadedShellSnapshot: input.shellSummary.hasSnapshot, | ||
| 91 | hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, | ||
| 92 | hasReadyEnvironment: | ||
| 93 | input.networkStatus !== "offline" && | ||
| 94 | input.environments.some((environment) => environment.connectionState === "connected"), | ||
| 95 | hasConnectingEnvironment: connectingEnvironments.length > 0, | ||
| 96 | connectingEnvironments, | ||
| 97 | connectionState: overallConnectionState(input.environments, input.networkStatus), | ||
| 98 | connectionError: | ||
| 99 | input.environments.find((environment) => environment.connectionError !== null) | ||
| 100 | ?.connectionError ?? null, | ||
| 101 | shellSnapshotError: input.shellSummary.firstError, | ||
| 102 | latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt, | ||
| 103 | networkStatus: input.networkStatus, | ||
| 104 | }; | ||
| 105 | } | ||
| 106 | |||
| 107 | export type ServerConfigByEnvironmentId = ReadonlyMap<EnvironmentId, ServerConfig>; | ||
apps/server/src/assets/AssetAccess.test.ts+6-3| ... | @@ -35,6 +35,8 @@ describe("AssetAccess", () => { | ... | @@ -35,6 +35,8 @@ describe("AssetAccess", () => { |
| 35 | yield* fileSystem.writeFileString(htmlPath, '<link rel="stylesheet" href="report.css">'); | 35 | yield* fileSystem.writeFileString(htmlPath, '<link rel="stylesheet" href="report.css">'); |
| 36 | yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); | 36 | yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); |
| 37 | yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value"); | 37 | yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value"); |
| 38 | const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); | ||
| 39 | const canonicalCssPath = yield* fileSystem.realPath(cssPath); | ||
| 38 | 40 | ||
| 39 | const result = yield* issueAssetUrl({ | 41 | const result = yield* issueAssetUrl({ |
| 40 | resource: { | 42 | resource: { |
| ... | @@ -50,11 +52,11 @@ describe("AssetAccess", () => { | ... | @@ -50,11 +52,11 @@ describe("AssetAccess", () => { |
| 50 | 52 | ||
| 51 | expect(yield* resolveAsset(token, "report.html")).toEqual({ | 53 | expect(yield* resolveAsset(token, "report.html")).toEqual({ |
| 52 | kind: "file", | 54 | kind: "file", |
| 53 | path: htmlPath, | 55 | path: canonicalHtmlPath, |
| 54 | }); | 56 | }); |
| 55 | expect(yield* resolveAsset(token, "report.css")).toEqual({ | 57 | expect(yield* resolveAsset(token, "report.css")).toEqual({ |
| 56 | kind: "file", | 58 | kind: "file", |
| 57 | path: cssPath, | 59 | path: canonicalCssPath, |
| 58 | }); | 60 | }); |
| 59 | expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); | 61 | expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); |
| 60 | expect(yield* resolveAsset(token, ".env")).toBeNull(); | 62 | expect(yield* resolveAsset(token, ".env")).toBeNull(); |
| ... | @@ -120,6 +122,7 @@ describe("AssetAccess", () => { | ... | @@ -120,6 +122,7 @@ describe("AssetAccess", () => { |
| 120 | }); | 122 | }); |
| 121 | const faviconPath = path.join(root, "favicon.svg"); | 123 | const faviconPath = path.join(root, "favicon.svg"); |
| 122 | yield* fileSystem.writeFileString(faviconPath, "<svg />"); | 124 | yield* fileSystem.writeFileString(faviconPath, "<svg />"); |
| 125 | const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); | ||
| 123 | 126 | ||
| 124 | const faviconResult = yield* issueAssetUrl({ | 127 | const faviconResult = yield* issueAssetUrl({ |
| 125 | resource: { _tag: "project-favicon", cwd: root }, | 128 | resource: { _tag: "project-favicon", cwd: root }, |
| ... | @@ -131,7 +134,7 @@ describe("AssetAccess", () => { | ... | @@ -131,7 +134,7 @@ describe("AssetAccess", () => { |
| 131 | faviconSuffix.slice(0, faviconSeparatorIndex), | 134 | faviconSuffix.slice(0, faviconSeparatorIndex), |
| 132 | faviconSuffix.slice(faviconSeparatorIndex + 1), | 135 | faviconSuffix.slice(faviconSeparatorIndex + 1), |
| 133 | ), | 136 | ), |
| 134 | ).toEqual({ kind: "file", path: faviconPath }); | 137 | ).toEqual({ kind: "file", path: canonicalFaviconPath }); |
| 135 | 138 | ||
| 136 | yield* fileSystem.remove(faviconPath); | 139 | yield* fileSystem.remove(faviconPath); |
| 137 | const fallbackResult = yield* issueAssetUrl({ | 140 | const fallbackResult = yield* issueAssetUrl({ |
apps/server/src/auth/http.ts+4| ... | @@ -25,6 +25,7 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; | ... | @@ -25,6 +25,7 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; |
| 25 | import { causeErrorTag } from "@t3tools/shared/observability"; | 25 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 26 | import * as DateTime from "effect/DateTime"; | 26 | import * as DateTime from "effect/DateTime"; |
| 27 | import * as Effect from "effect/Effect"; | 27 | import * as Effect from "effect/Effect"; |
| 28 | import { identity } from "effect/Function"; | ||
| 28 | import * as Layer from "effect/Layer"; | 29 | import * as Layer from "effect/Layer"; |
| 29 | import * as Cookies from "effect/unstable/http/Cookies"; | 30 | import * as Cookies from "effect/unstable/http/Cookies"; |
| 30 | import * as HttpEffect from "effect/unstable/http/HttpEffect"; | 31 | import * as HttpEffect from "effect/unstable/http/HttpEffect"; |
| ... | @@ -33,6 +34,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ... | @@ -33,6 +34,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 33 | 34 | ||
| 34 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 35 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 35 | import * as SessionStore from "./SessionStore.ts"; | 36 | import * as SessionStore from "./SessionStore.ts"; |
| 37 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; | ||
| 36 | import { deriveAuthClientMetadata } from "./utils.ts"; | 38 | import { deriveAuthClientMetadata } from "./utils.ts"; |
| 37 | import { verifyRequestDpopProof } from "./dpop.ts"; | 39 | import { verifyRequestDpopProof } from "./dpop.ts"; |
| 38 | 40 | ||
| ... | @@ -177,6 +179,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( | ... | @@ -177,6 +179,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( |
| 177 | ...session, | 179 | ...session, |
| 178 | scopes: new Set(session.scopes), | 180 | scopes: new Set(session.scopes), |
| 179 | }), | 181 | }), |
| 182 | session.subject === "cloud-connect" ? traceAuthenticatedRelayRequest : identity, | ||
| 180 | ); | 183 | ); |
| 181 | }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); | 184 | }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); |
| 182 | }), | 185 | }), |
| ... | @@ -289,6 +292,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -289,6 +292,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 289 | proofKeyThumbprint ? { proofKeyThumbprint } : undefined, | 292 | proofKeyThumbprint ? { proofKeyThumbprint } : undefined, |
| 290 | ); | 293 | ); |
| 291 | }, | 294 | }, |
| 295 | traceRelayRequest, | ||
| 292 | Effect.catchTags({ | 296 | Effect.catchTags({ |
| 293 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | 297 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), |
| 294 | ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), | 298 | ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), |
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+20-3| ... | @@ -10,7 +10,10 @@ import * as Stream from "effect/Stream"; | ... | @@ -10,7 +10,10 @@ import * as Stream from "effect/Stream"; |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | import * as RelayClient from "@t3tools/shared/relayClient"; | 11 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 12 | 12 | ||
| 13 | import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; | 13 | import { |
| 14 | classifyRelayClientOutput, | ||
| 15 | makeCloudManagedEndpointRuntime, | ||
| 16 | } from "./ManagedEndpointRuntime.ts"; | ||
| 14 | 17 | ||
| 15 | const relayClientAvailableLayer = Layer.succeed( | 18 | const relayClientAvailableLayer = Layer.succeed( |
| 16 | RelayClient.RelayClient, | 19 | RelayClient.RelayClient, |
| ... | @@ -57,6 +60,20 @@ function makeHandle(input: { | ... | @@ -57,6 +60,20 @@ function makeHandle(input: { |
| 57 | } | 60 | } |
| 58 | 61 | ||
| 59 | describe("CloudManagedEndpointRuntime", () => { | 62 | describe("CloudManagedEndpointRuntime", () => { |
| 63 | it("classifies Cloudflare connection and warning output", () => { | ||
| 64 | expect( | ||
| 65 | classifyRelayClientOutput( | ||
| 66 | "2026-06-17T02:00:00Z INF Registered tunnel connection connIndex=0", | ||
| 67 | ), | ||
| 68 | ).toBe("connected"); | ||
| 69 | expect( | ||
| 70 | classifyRelayClientOutput("2026-06-17T02:00:00Z ERR Failed to serve tunnel connection"), | ||
| 71 | ).toBe("warning"); | ||
| 72 | expect(classifyRelayClientOutput("2026-06-17T02:00:00Z INF Starting metrics server")).toBe( | ||
| 73 | "debug", | ||
| 74 | ); | ||
| 75 | }); | ||
| 76 | |||
| 60 | it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => | 77 | it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => |
| 61 | Effect.gen(function* () { | 78 | Effect.gen(function* () { |
| 62 | const spawned: Array<ChildProcess.StandardCommand> = []; | 79 | const spawned: Array<ChildProcess.StandardCommand> = []; |
| ... | @@ -113,8 +130,8 @@ describe("CloudManagedEndpointRuntime", () => { | ... | @@ -113,8 +130,8 @@ describe("CloudManagedEndpointRuntime", () => { |
| 113 | "token-1", | 130 | "token-1", |
| 114 | "token-2", | 131 | "token-2", |
| 115 | ]); | 132 | ]); |
| 116 | expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]); | 133 | expect(spawned.map((command) => command.options.stdout)).toEqual(["pipe", "pipe"]); |
| 117 | expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]); | 134 | expect(spawned.map((command) => command.options.stderr)).toEqual(["pipe", "pipe"]); |
| 118 | expect(spawned.map((command) => command.options.detached)).toEqual([false, false]); | 135 | expect(spawned.map((command) => command.options.detached)).toEqual([false, false]); |
| 119 | expect(spawned.map((command) => command.options.shell)).toEqual([false, false]); | 136 | expect(spawned.map((command) => command.options.shell)).toEqual([false, false]); |
| 120 | expect(killed).toEqual([100, 101]); | 137 | expect(killed).toEqual([100, 101]); |
apps/server/src/cloud/ManagedEndpointRuntime.ts+47-4| ... | @@ -9,6 +9,7 @@ import * as Ref from "effect/Ref"; | ... | @@ -9,6 +9,7 @@ import * as Ref from "effect/Ref"; |
| 9 | import * as Result from "effect/Result"; | 9 | import * as Result from "effect/Result"; |
| 10 | import * as Semaphore from "effect/Semaphore"; | 10 | import * as Semaphore from "effect/Semaphore"; |
| 11 | import * as Scope from "effect/Scope"; | 11 | import * as Scope from "effect/Scope"; |
| 12 | import * as Stream from "effect/Stream"; | ||
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | 14 | ||
| 14 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 15 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| ... | @@ -68,6 +69,13 @@ interface ActiveConnector { | ... | @@ -68,6 +69,13 @@ interface ActiveConnector { |
| 68 | readonly config: RelayManagedEndpointRuntimeConfig; | 69 | readonly config: RelayManagedEndpointRuntimeConfig; |
| 69 | } | 70 | } |
| 70 | 71 | ||
| 72 | export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { | ||
| 73 | if (/\bRegistered tunnel connection\b/iu.test(line)) { | ||
| 74 | return "connected"; | ||
| 75 | } | ||
| 76 | return /\b(?:ERR|WRN)\b/u.test(line) ? "warning" : "debug"; | ||
| 77 | } | ||
| 78 | |||
| 71 | function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { | 79 | function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { |
| 72 | return JSON.stringify({ | 80 | return JSON.stringify({ |
| 73 | providerKind: config.providerKind, | 81 | providerKind: config.providerKind, |
| ... | @@ -141,6 +149,39 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { | ... | @@ -141,6 +149,39 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { |
| 141 | Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), | 149 | Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), |
| 142 | ); | 150 | ); |
| 143 | 151 | ||
| 152 | const observeConnectorOutput = (connector: ActiveConnector) => | ||
| 153 | connector.child.all.pipe( | ||
| 154 | Stream.decodeText(), | ||
| 155 | Stream.splitLines, | ||
| 156 | Stream.map((line) => line.trim()), | ||
| 157 | Stream.filter((line) => line.length > 0), | ||
| 158 | Stream.runForEach((line) => { | ||
| 159 | const output = line.replaceAll(connector.config.connectorToken, "<redacted>"); | ||
| 160 | const attributes = { | ||
| 161 | pid: Number(connector.child.pid), | ||
| 162 | tunnelId: connector.config.tunnelId, | ||
| 163 | tunnelName: connector.config.tunnelName, | ||
| 164 | output, | ||
| 165 | }; | ||
| 166 | switch (classifyRelayClientOutput(line)) { | ||
| 167 | case "connected": | ||
| 168 | return Effect.logInfo("Relay client tunnel connection registered", attributes); | ||
| 169 | case "warning": | ||
| 170 | return Effect.logWarning("Relay client reported a transport warning", attributes); | ||
| 171 | case "debug": | ||
| 172 | return Effect.logDebug("Relay client output", attributes); | ||
| 173 | } | ||
| 174 | }), | ||
| 175 | Effect.catchCause((cause) => | ||
| 176 | Effect.logWarning("Relay client output observer failed", { | ||
| 177 | cause, | ||
| 178 | pid: Number(connector.child.pid), | ||
| 179 | tunnelId: connector.config.tunnelId, | ||
| 180 | tunnelName: connector.config.tunnelName, | ||
| 181 | }), | ||
| 182 | ), | ||
| 183 | ); | ||
| 184 | |||
| 144 | reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { | 185 | reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { |
| 145 | if (!config || config.providerKind !== "cloudflare_tunnel") { | 186 | if (!config || config.providerKind !== "cloudflare_tunnel") { |
| 146 | yield* stopActive; | 187 | yield* stopActive; |
| ... | @@ -190,14 +231,15 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { | ... | @@ -190,14 +231,15 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { |
| 190 | TUNNEL_TOKEN: config.connectorToken, | 231 | TUNNEL_TOKEN: config.connectorToken, |
| 191 | }, | 232 | }, |
| 192 | shell: false, | 233 | shell: false, |
| 193 | stderr: "ignore", | 234 | stderr: "pipe", |
| 194 | stdout: "ignore", | 235 | stdout: "pipe", |
| 195 | }), | 236 | }), |
| 196 | ) | 237 | ) |
| 197 | .pipe( | 238 | .pipe( |
| 198 | Effect.provideService(Scope.Scope, connectorScope), | 239 | Effect.provideService(Scope.Scope, connectorScope), |
| 199 | Effect.tap(() => | 240 | Effect.tap((child) => |
| 200 | Effect.logInfo("Relay client started", { | 241 | Effect.logInfo("Relay client process started; waiting for tunnel connection", { |
| 242 | pid: Number(child.pid), | ||
| 201 | tunnelId: config.tunnelId, | 243 | tunnelId: config.tunnelId, |
| 202 | tunnelName: config.tunnelName, | 244 | tunnelName: config.tunnelName, |
| 203 | }), | 245 | }), |
| ... | @@ -232,6 +274,7 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { | ... | @@ -232,6 +274,7 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { |
| 232 | config, | 274 | config, |
| 233 | } satisfies ActiveConnector; | 275 | } satisfies ActiveConnector; |
| 234 | yield* Ref.set(activeRef, connector); | 276 | yield* Ref.set(activeRef, connector); |
| 277 | yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); | ||
| 235 | yield* Effect.forkIn(superviseConnector(connector), connectorScope); | 278 | yield* Effect.forkIn(superviseConnector(connector), connectorScope); |
| 236 | return { | 279 | return { |
| 237 | status: "running", | 280 | status: "running", |
apps/server/src/cloud/http.test.ts+37-8| ... | @@ -11,15 +11,12 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ... | @@ -11,15 +11,12 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 12 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | 12 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; |
| 13 | import * as CliTokenManager from "./CliTokenManager.ts"; | 13 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 14 | import { | 14 | import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; |
| 15 | consumeCloudReplayGuards, | ||
| 16 | reconcileDesiredCloudLink, | ||
| 17 | traceRelayBrokerHandler, | ||
| 18 | } from "./http.ts"; | ||
| 19 | import { | 15 | import { |
| 20 | CloudManagedEndpointRuntime, | 16 | CloudManagedEndpointRuntime, |
| 21 | type CloudManagedEndpointRuntimeShape, | 17 | type CloudManagedEndpointRuntimeShape, |
| 22 | } from "./ManagedEndpointRuntime.ts"; | 18 | } from "./ManagedEndpointRuntime.ts"; |
| 19 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; | ||
| 23 | 20 | ||
| 24 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => | 21 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => |
| 25 | new ServerSecretStore.SecretStoreError({ | 22 | new ServerSecretStore.SecretStoreError({ |
| ... | @@ -75,8 +72,38 @@ describe("consumeCloudReplayGuards", () => { | ... | @@ -75,8 +72,38 @@ describe("consumeCloudReplayGuards", () => { |
| 75 | ); | 72 | ); |
| 76 | }); | 73 | }); |
| 77 | 74 | ||
| 78 | describe("traceRelayBrokerHandler", () => { | 75 | describe("relay request tracing", () => { |
| 79 | it.effect("continues the incoming relay trace with the product tracer", () => | 76 | it.effect("does not accept an unauthenticated request trace parent", () => |
| 77 | Effect.gen(function* () { | ||
| 78 | const spans: Array<Tracer.Span> = []; | ||
| 79 | const productTracer = Tracer.make({ | ||
| 80 | span: (options) => { | ||
| 81 | const span = new Tracer.NativeSpan(options); | ||
| 82 | spans.push(span); | ||
| 83 | return span; | ||
| 84 | }, | ||
| 85 | }); | ||
| 86 | const request = HttpServerRequest.fromWeb( | ||
| 87 | new Request("https://environment.example.test/api/t3-cloud/mint-credential", { | ||
| 88 | headers: { | ||
| 89 | traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", | ||
| 90 | }, | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | |||
| 94 | yield* traceRelayRequest(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( | ||
| 95 | Effect.provideService(HttpServerRequest.HttpServerRequest, request), | ||
| 96 | Effect.provideService(RelayClientTracer, Option.some(productTracer)), | ||
| 97 | ); | ||
| 98 | |||
| 99 | expect(spans).toHaveLength(1); | ||
| 100 | const span = spans[0]!; | ||
| 101 | expect(span.traceId).not.toBe("0123456789abcdef0123456789abcdef"); | ||
| 102 | expect(Option.isNone(span.parent)).toBe(true); | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | |||
| 106 | it.effect("continues an authenticated relay trace with the product tracer", () => | ||
| 80 | Effect.gen(function* () { | 107 | Effect.gen(function* () { |
| 81 | const spans: Array<Tracer.Span> = []; | 108 | const spans: Array<Tracer.Span> = []; |
| 82 | const productTracer = Tracer.make({ | 109 | const productTracer = Tracer.make({ |
| ... | @@ -94,7 +121,9 @@ describe("traceRelayBrokerHandler", () => { | ... | @@ -94,7 +121,9 @@ describe("traceRelayBrokerHandler", () => { |
| 94 | }), | 121 | }), |
| 95 | ); | 122 | ); |
| 96 | 123 | ||
| 97 | yield* traceRelayBrokerHandler(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( | 124 | yield* traceAuthenticatedRelayRequest( |
| 125 | Effect.void.pipe(Effect.withSpan("relay.mint.handler")), | ||
| 126 | ).pipe( | ||
| 98 | Effect.provideService(HttpServerRequest.HttpServerRequest, request), | 127 | Effect.provideService(HttpServerRequest.HttpServerRequest, request), |
| 99 | Effect.provideService(RelayClientTracer, Option.some(productTracer)), | 128 | Effect.provideService(RelayClientTracer, Option.some(productTracer)), |
| 100 | ); | 129 | ); |
apps/server/src/cloud/http.ts+3-15| ... | @@ -48,7 +48,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -48,7 +48,7 @@ import * as Effect from "effect/Effect"; |
| 48 | import * as Option from "effect/Option"; | 48 | import * as Option from "effect/Option"; |
| 49 | import * as Schema from "effect/Schema"; | 49 | import * as Schema from "effect/Schema"; |
| 50 | import * as HttpEffect from "effect/unstable/http/HttpEffect"; | 50 | import * as HttpEffect from "effect/unstable/http/HttpEffect"; |
| 51 | import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/unstable/http"; | 51 | import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 52 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | 52 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 53 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | 53 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 54 | 54 | ||
| ... | @@ -77,6 +77,7 @@ import { relayUrlConfig } from "./publicConfig.ts"; | ... | @@ -77,6 +77,7 @@ import { relayUrlConfig } from "./publicConfig.ts"; |
| 77 | import * as CliState from "./CliState.ts"; | 77 | import * as CliState from "./CliState.ts"; |
| 78 | import * as CliTokenManager from "./CliTokenManager.ts"; | 78 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 79 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; | 79 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; |
| 80 | import { traceRelayRequest } from "./traceRelayRequest.ts"; | ||
| 80 | 81 | ||
| 81 | const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; | 82 | const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; |
| 82 | const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; | 83 | const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; |
| ... | @@ -111,19 +112,6 @@ const requireRelayUrl = relayUrlConfig.pipe( | ... | @@ -111,19 +112,6 @@ const requireRelayUrl = relayUrlConfig.pipe( |
| 111 | ), | 112 | ), |
| 112 | ); | 113 | ); |
| 113 | 114 | ||
| 114 | export const traceRelayBrokerHandler = <A, E, R>( | ||
| 115 | effect: Effect.Effect<A, E, R>, | ||
| 116 | ): Effect.Effect<A, E, R | HttpServerRequest.HttpServerRequest> => | ||
| 117 | HttpServerRequest.HttpServerRequest.pipe( | ||
| 118 | Effect.flatMap((request) => | ||
| 119 | Option.match(HttpTraceContext.fromHeaders(request.headers), { | ||
| 120 | onNone: () => effect, | ||
| 121 | onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)), | ||
| 122 | }), | ||
| 123 | ), | ||
| 124 | withRelayClientTracing, | ||
| 125 | ); | ||
| 126 | |||
| 127 | function bytesToString(bytes: Uint8Array): string { | 115 | function bytesToString(bytes: Uint8Array): string { |
| 128 | return new TextDecoder().decode(bytes); | 116 | return new TextDecoder().decode(bytes); |
| 129 | } | 117 | } |
| ... | @@ -953,7 +941,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group( | ... | @@ -953,7 +941,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group( |
| 953 | .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) | 941 | .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) |
| 954 | .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) | 942 | .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) |
| 955 | .handle("t3MintCredential", ({ payload }) => | 943 | .handle("t3MintCredential", ({ payload }) => |
| 956 | traceRelayBrokerHandler(cloudMintCredentialHandler(dependencies, payload)), | 944 | traceRelayRequest(cloudMintCredentialHandler(dependencies, payload)), |
| 957 | ); | 945 | ); |
| 958 | }), | 946 | }), |
| 959 | ); | 947 | ); |
apps/server/src/cloud/traceRelayRequest.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { HttpServerRequest, HttpTraceContext } from "effect/unstable/http"; | ||
| 5 | |||
| 6 | export const traceRelayRequest = <A, E, R>( | ||
| 7 | effect: Effect.Effect<A, E, R>, | ||
| 8 | ): Effect.Effect<A, E, R> => effect.pipe(withRelayClientTracing); | ||
| 9 | |||
| 10 | export const traceAuthenticatedRelayRequest = <A, E, R>( | ||
| 11 | effect: Effect.Effect<A, E, R>, | ||
| 12 | ): Effect.Effect<A, E, R | HttpServerRequest.HttpServerRequest> => | ||
| 13 | HttpServerRequest.HttpServerRequest.pipe( | ||
| 14 | Effect.flatMap((request) => | ||
| 15 | Option.match(HttpTraceContext.fromHeaders(request.headers), { | ||
| 16 | onNone: () => effect, | ||
| 17 | onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)), | ||
| 18 | }), | ||
| 19 | ), | ||
| 20 | withRelayClientTracing, | ||
| 21 | ); | ||
apps/server/src/git/GitManager.ts+16-5| ... | @@ -47,7 +47,11 @@ import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScript | ... | @@ -47,7 +47,11 @@ import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScript |
| 47 | import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; | 47 | import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; |
| 48 | import { ServerSettingsService } from "../serverSettings.ts"; | 48 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 49 | import type { GitManagerServiceError } from "@t3tools/contracts"; | 49 | import type { GitManagerServiceError } from "@t3tools/contracts"; |
| 50 | import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts"; | 50 | import { |
| 51 | GitVcsDriver, | ||
| 52 | type GitRemoteStatusOptions, | ||
| 53 | type GitStatusDetails, | ||
| 54 | } from "../vcs/GitVcsDriver.ts"; | ||
| 51 | import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; | 55 | import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 52 | import type { ChangeRequest } from "@t3tools/contracts"; | 56 | import type { ChangeRequest } from "@t3tools/contracts"; |
| 53 | 57 | ||
| ... | @@ -69,6 +73,7 @@ export interface GitManagerShape { | ... | @@ -69,6 +73,7 @@ export interface GitManagerShape { |
| 69 | ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>; | 73 | ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>; |
| 70 | readonly remoteStatus: ( | 74 | readonly remoteStatus: ( |
| 71 | input: VcsStatusInput, | 75 | input: VcsStatusInput, |
| 76 | options?: GitRemoteStatusOptions, | ||
| 72 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; | 77 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; |
| 73 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; | 78 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; |
| 74 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; | 79 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; |
| ... | @@ -745,9 +750,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -745,9 +750,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 745 | normalizeStatusCacheKey(cwd).pipe( | 750 | normalizeStatusCacheKey(cwd).pipe( |
| 746 | Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), | 751 | Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), |
| 747 | ); | 752 | ); |
| 748 | const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { | 753 | const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( |
| 754 | cwd: string, | ||
| 755 | options?: GitRemoteStatusOptions, | ||
| 756 | ) { | ||
| 749 | const details = yield* gitCore | 757 | const details = yield* gitCore |
| 750 | .statusDetailsRemote(cwd) | 758 | .statusDetailsRemote(cwd, options) |
| 751 | .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); | 759 | .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); |
| 752 | if (details === null || !details.isRepo) { | 760 | if (details === null || !details.isRepo) { |
| 753 | return null; | 761 | return null; |
| ... | @@ -778,7 +786,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -778,7 +786,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 778 | pr, | 786 | pr, |
| 779 | } satisfies VcsStatusRemoteResult; | 787 | } satisfies VcsStatusRemoteResult; |
| 780 | }); | 788 | }); |
| 781 | const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { | 789 | const remoteStatusResultCache = yield* Cache.makeWith((cwd: string) => readRemoteStatus(cwd), { |
| 782 | capacity: STATUS_RESULT_CACHE_CAPACITY, | 790 | capacity: STATUS_RESULT_CACHE_CAPACITY, |
| 783 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), | 791 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), |
| 784 | }); | 792 | }); |
| ... | @@ -1355,8 +1363,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -1355,8 +1363,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 1355 | return yield* Cache.get(localStatusResultCache, cacheKey); | 1363 | return yield* Cache.get(localStatusResultCache, cacheKey); |
| 1356 | }); | 1364 | }); |
| 1357 | const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( | 1365 | const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( |
| 1358 | function* (input) { | 1366 | function* (input, options) { |
| 1359 | const cacheKey = yield* normalizeStatusCacheKey(input.cwd); | 1367 | const cacheKey = yield* normalizeStatusCacheKey(input.cwd); |
| 1368 | if (options?.refreshUpstream === false) { | ||
| 1369 | return yield* readRemoteStatus(cacheKey, options); | ||
| 1370 | } | ||
| 1360 | return yield* Cache.get(remoteStatusResultCache, cacheKey); | 1371 | return yield* Cache.get(remoteStatusResultCache, cacheKey); |
| 1361 | }, | 1372 | }, |
| 1362 | ); | 1373 | ); |
apps/server/src/git/GitWorkflowService.ts+4-3| ... | @@ -29,7 +29,7 @@ import { | ... | @@ -29,7 +29,7 @@ import { |
| 29 | } from "@t3tools/contracts"; | 29 | } from "@t3tools/contracts"; |
| 30 | 30 | ||
| 31 | import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; | 31 | import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; |
| 32 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | 32 | import { GitVcsDriver, type GitRemoteStatusOptions } from "../vcs/GitVcsDriver.ts"; |
| 33 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; | 33 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; |
| 34 | 34 | ||
| 35 | export interface GitWorkflowServiceShape { | 35 | export interface GitWorkflowServiceShape { |
| ... | @@ -41,6 +41,7 @@ export interface GitWorkflowServiceShape { | ... | @@ -41,6 +41,7 @@ export interface GitWorkflowServiceShape { |
| 41 | ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>; | 41 | ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>; |
| 42 | readonly remoteStatus: ( | 42 | readonly remoteStatus: ( |
| 43 | input: VcsStatusInput, | 43 | input: VcsStatusInput, |
| 44 | options?: GitRemoteStatusOptions, | ||
| 44 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; | 45 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; |
| 45 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; | 46 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; |
| 46 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; | 47 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; |
| ... | @@ -259,10 +260,10 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { | ... | @@ -259,10 +260,10 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { |
| 259 | : Effect.succeed(nonRepositoryLocalStatus()), | 260 | : Effect.succeed(nonRepositoryLocalStatus()), |
| 260 | ), | 261 | ), |
| 261 | ), | 262 | ), |
| 262 | remoteStatus: (input) => | 263 | remoteStatus: (input, options) => |
| 263 | detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( | 264 | detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( |
| 264 | Effect.flatMap((isGitRepository) => | 265 | Effect.flatMap((isGitRepository) => |
| 265 | isGitRepository ? gitManager.remoteStatus(input) : Effect.succeed(null), | 266 | isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null), |
| 266 | ), | 267 | ), |
| 267 | ), | 268 | ), |
| 268 | invalidateLocalStatus: gitManager.invalidateLocalStatus, | 269 | invalidateLocalStatus: gitManager.invalidateLocalStatus, |
apps/server/src/http.ts+2-1| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | } from "./assets/AssetAccess.ts"; | 32 | } from "./assets/AssetAccess.ts"; |
| 33 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; | 33 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; |
| 34 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 34 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 35 | import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; | ||
| 35 | import { | 36 | import { |
| 36 | annotateEnvironmentRequest, | 37 | annotateEnvironmentRequest, |
| 37 | failEnvironmentScopeRequired, | 38 | failEnvironmentScopeRequired, |
| ... | @@ -100,7 +101,7 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( | ... | @@ -100,7 +101,7 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( |
| 100 | Effect.fn("environment.metadata.descriptor")(function* (args) { | 101 | Effect.fn("environment.metadata.descriptor")(function* (args) { |
| 101 | yield* annotateEnvironmentRequest(args.endpoint.name); | 102 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 102 | return yield* serverEnvironment.getDescriptor; | 103 | return yield* serverEnvironment.getDescriptor; |
| 103 | }), | 104 | }, traceRelayRequest), |
| 104 | ); | 105 | ); |
| 105 | }), | 106 | }), |
| 106 | ); | 107 | ); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1-1| ... | @@ -71,7 +71,7 @@ const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => | ... | @@ -71,7 +71,7 @@ const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => |
| 71 | 71 | ||
| 72 | async function waitFor( | 72 | async function waitFor( |
| 73 | predicate: () => boolean | Promise<boolean>, | 73 | predicate: () => boolean | Promise<boolean>, |
| 74 | timeoutMs = 2000, | 74 | timeoutMs = 10_000, |
| 75 | ): Promise<void> { | 75 | ): Promise<void> { |
| 76 | const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; | 76 | const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; |
| 77 | const poll = async (): Promise<void> => { | 77 | const poll = async (): Promise<void> => { |
apps/server/src/relay/AgentAwarenessRelay.test.ts+21| ... | @@ -97,6 +97,27 @@ function makeMemorySecretStore() { | ... | @@ -97,6 +97,27 @@ function makeMemorySecretStore() { |
| 97 | } | 97 | } |
| 98 | 98 | ||
| 99 | describe.sequential("signRelayAgentActivityPublishProof", () => { | 99 | describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 100 | it("distinguishes pending link credentials from disabled publication", () => { | ||
| 101 | expect( | ||
| 102 | AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ | ||
| 103 | relayConfigured: false, | ||
| 104 | publishEnabled: false, | ||
| 105 | }), | ||
| 106 | ).toBe("waiting-for-link"); | ||
| 107 | expect( | ||
| 108 | AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ | ||
| 109 | relayConfigured: true, | ||
| 110 | publishEnabled: false, | ||
| 111 | }), | ||
| 112 | ).toBe("disabled"); | ||
| 113 | expect( | ||
| 114 | AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ | ||
| 115 | relayConfigured: true, | ||
| 116 | publishEnabled: true, | ||
| 117 | }), | ||
| 118 | ).toBe("enabled"); | ||
| 119 | }); | ||
| 120 | |||
| 100 | it("derives the thread id from the aggregate id for thread events without payload thread ids", () => { | 121 | it("derives the thread id from the aggregate id for thread events without payload thread ids", () => { |
| 101 | const threadId = "thread-aggregate-1" as ThreadId; | 122 | const threadId = "thread-aggregate-1" as ThreadId; |
| 102 | const now = "2026-05-25T00:00:00.000Z"; | 123 | const now = "2026-05-25T00:00:00.000Z"; |
apps/server/src/relay/AgentAwarenessRelay.ts+53-19| ... | @@ -100,6 +100,16 @@ export function isAgentActivityPublishingEnabled(value: string | null): boolean | ... | @@ -100,6 +100,16 @@ export function isAgentActivityPublishingEnabled(value: string | null): boolean |
| 100 | return value === "true"; | 100 | return value === "true"; |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | export function resolveAgentActivityPublishingStartupState(input: { | ||
| 104 | readonly relayConfigured: boolean; | ||
| 105 | readonly publishEnabled: boolean; | ||
| 106 | }): "waiting-for-link" | "disabled" | "enabled" { | ||
| 107 | if (!input.relayConfigured) { | ||
| 108 | return "waiting-for-link"; | ||
| 109 | } | ||
| 110 | return input.publishEnabled ? "enabled" : "disabled"; | ||
| 111 | } | ||
| 112 | |||
| 103 | const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; | 113 | const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; |
| 104 | const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; | 114 | const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; |
| 105 | 115 | ||
| ... | @@ -304,7 +314,7 @@ const make = Effect.gen(function* () { | ... | @@ -304,7 +314,7 @@ const make = Effect.gen(function* () { |
| 304 | } | 314 | } |
| 305 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); | 315 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); |
| 306 | if (!relayConfig) { | 316 | if (!relayConfig) { |
| 307 | yield* Effect.logDebug("agent activity publish skipped; T3 Connect config missing", { | 317 | yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", { |
| 308 | threadId, | 318 | threadId, |
| 309 | }); | 319 | }); |
| 310 | return; | 320 | return; |
| ... | @@ -423,7 +433,7 @@ const make = Effect.gen(function* () { | ... | @@ -423,7 +433,7 @@ const make = Effect.gen(function* () { |
| 423 | } | 433 | } |
| 424 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); | 434 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); |
| 425 | if (!relayConfig) { | 435 | if (!relayConfig) { |
| 426 | yield* Effect.logDebug("agent activity snapshot skipped; T3 Connect config missing"); | 436 | yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); |
| 427 | return false; | 437 | return false; |
| 428 | } | 438 | } |
| 429 | const environmentId = yield* serverEnvironment.getEnvironmentId; | 439 | const environmentId = yield* serverEnvironment.getEnvironmentId; |
| ... | @@ -444,31 +454,55 @@ const make = Effect.gen(function* () { | ... | @@ -444,31 +454,55 @@ const make = Effect.gen(function* () { |
| 444 | return true; | 454 | return true; |
| 445 | }); | 455 | }); |
| 446 | 456 | ||
| 447 | const publishActiveThreadsOnceWhenConfigured = Effect.gen(function* () { | 457 | const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => |
| 448 | while (!(yield* Ref.get(activeSnapshotPublishedRef))) { | 458 | Effect.gen(function* () { |
| 449 | const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); | 459 | while (!(yield* Ref.get(activeSnapshotPublishedRef))) { |
| 450 | if (published) { | 460 | const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); |
| 451 | yield* Ref.set(activeSnapshotPublishedRef, true); | 461 | if (published) { |
| 452 | return; | 462 | yield* Ref.set(activeSnapshotPublishedRef, true); |
| 463 | if (logEnabledWhenReady) { | ||
| 464 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); | ||
| 465 | yield* Effect.logInfo("agent activity publishing enabled after link reconciliation", { | ||
| 466 | relayUrl: relayConfig?.url, | ||
| 467 | }); | ||
| 468 | } | ||
| 469 | return; | ||
| 470 | } | ||
| 471 | yield* Effect.sleep("5 seconds"); | ||
| 453 | } | 472 | } |
| 454 | yield* Effect.sleep("5 seconds"); | 473 | }); |
| 455 | } | ||
| 456 | }); | ||
| 457 | 474 | ||
| 458 | const worker = yield* makeDrainableWorker(publishThread); | 475 | const worker = yield* makeDrainableWorker(publishThread); |
| 459 | 476 | ||
| 460 | const start: AgentAwarenessRelayShape["start"] = Effect.fn("AgentAwarenessRelay.start")( | 477 | const start: AgentAwarenessRelayShape["start"] = Effect.fn("AgentAwarenessRelay.start")( |
| 461 | function* () { | 478 | function* () { |
| 462 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); | 479 | const [relayConfig, publishEnabled] = yield* Effect.all([ |
| 463 | if (!relayConfig) { | 480 | readRelayConfig.pipe(Effect.orElseSucceed(() => null)), |
| 464 | yield* Effect.logInfo("agent activity publishing standby; T3 Connect config missing"); | 481 | readPublishAgentActivityEnabled.pipe(Effect.orElseSucceed(() => false)), |
| 465 | } else { | 482 | ]); |
| 466 | yield* Effect.logInfo("agent activity publishing enabled", { | 483 | const startupState = resolveAgentActivityPublishingStartupState({ |
| 467 | relayUrl: relayConfig.url, | 484 | relayConfigured: relayConfig !== null, |
| 468 | }); | 485 | publishEnabled, |
| 486 | }); | ||
| 487 | switch (startupState) { | ||
| 488 | case "waiting-for-link": | ||
| 489 | yield* Effect.logInfo( | ||
| 490 | "agent activity publishing standby; waiting for T3 Connect link reconciliation", | ||
| 491 | ); | ||
| 492 | break; | ||
| 493 | case "disabled": | ||
| 494 | yield* Effect.logInfo("agent activity publishing disabled by T3 Connect configuration"); | ||
| 495 | break; | ||
| 496 | case "enabled": | ||
| 497 | yield* Effect.logInfo("agent activity publishing enabled", { | ||
| 498 | relayUrl: relayConfig?.url, | ||
| 499 | }); | ||
| 500 | break; | ||
| 469 | } | 501 | } |
| 470 | yield* Effect.forkScoped( | 502 | yield* Effect.forkScoped( |
| 471 | Effect.sleep("1 second").pipe(Effect.andThen(publishActiveThreadsOnceWhenConfigured)), | 503 | Effect.sleep("1 second").pipe( |
| 504 | Effect.andThen(publishActiveThreadsOnceWhenConfigured(startupState !== "enabled")), | ||
| 505 | ), | ||
| 472 | ); | 506 | ); |
| 473 | yield* Effect.forkScoped( | 507 | yield* Effect.forkScoped( |
| 474 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | 508 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { |
apps/server/src/server.ts+2-2| ... | @@ -126,7 +126,7 @@ const HttpServerLive = Layer.unwrap( | ... | @@ -126,7 +126,7 @@ const HttpServerLive = Layer.unwrap( |
| 126 | ); | 126 | ); |
| 127 | return BunHttpServer.layer({ | 127 | return BunHttpServer.layer({ |
| 128 | port: config.port, | 128 | port: config.port, |
| 129 | ...(config.host ? { hostname: config.host } : {}), | 129 | hostname: config.host ?? "127.0.0.1", |
| 130 | gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, | 130 | gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, |
| 131 | }); | 131 | }); |
| 132 | } else { | 132 | } else { |
| ... | @@ -135,7 +135,7 @@ const HttpServerLive = Layer.unwrap( | ... | @@ -135,7 +135,7 @@ const HttpServerLive = Layer.unwrap( |
| 135 | Effect.promise(() => import("node:http")), | 135 | Effect.promise(() => import("node:http")), |
| 136 | ]); | 136 | ]); |
| 137 | return NodeHttpServer.layer(NodeHttp.createServer, { | 137 | return NodeHttpServer.layer(NodeHttp.createServer, { |
| 138 | host: config.host, | 138 | host: config.host ?? "127.0.0.1", |
| 139 | port: config.port, | 139 | port: config.port, |
| 140 | gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, | 140 | gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, |
| 141 | }); | 141 | }); |
apps/server/src/terminal/Layers/Manager.test.ts+49| ... | @@ -319,6 +319,31 @@ it.layer( | ... | @@ -319,6 +319,31 @@ it.layer( |
| 319 | }), | 319 | }), |
| 320 | ); | 320 | ); |
| 321 | 321 | ||
| 322 | it.effect("keeps attach streams live when a terminal id is closed and reopened", () => | ||
| 323 | Effect.gen(function* () { | ||
| 324 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 325 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 326 | const unsubscribe = yield* manager.attachStream(openInput(), (event) => | ||
| 327 | Ref.update(attachEvents, (events) => [...events, event]), | ||
| 328 | ); | ||
| 329 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 330 | |||
| 331 | yield* manager.close({ | ||
| 332 | threadId: "thread-1", | ||
| 333 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 334 | deleteHistory: true, | ||
| 335 | }); | ||
| 336 | yield* manager.open(openInput()); | ||
| 337 | |||
| 338 | const events = yield* Ref.get(attachEvents); | ||
| 339 | expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]); | ||
| 340 | expect( | ||
| 341 | events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status), | ||
| 342 | ).toEqual(["running", "running"]); | ||
| 343 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 344 | }), | ||
| 345 | ); | ||
| 346 | |||
| 322 | it.effect("attaches to exited sessions without restarting them", () => | 347 | it.effect("attaches to exited sessions without restarting them", () => |
| 323 | Effect.gen(function* () { | 348 | Effect.gen(function* () { |
| 324 | const { manager, ptyAdapter, getEvents } = yield* createManager(); | 349 | const { manager, ptyAdapter, getEvents } = yield* createManager(); |
| ... | @@ -478,6 +503,30 @@ it.layer( | ... | @@ -478,6 +503,30 @@ it.layer( |
| 478 | }), | 503 | }), |
| 479 | ); | 504 | ); |
| 480 | 505 | ||
| 506 | it.effect("ignores delayed resize requests after a terminal closes", () => | ||
| 507 | Effect.gen(function* () { | ||
| 508 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 509 | yield* manager.open(openInput()); | ||
| 510 | const process = ptyAdapter.processes[0]; | ||
| 511 | expect(process).toBeDefined(); | ||
| 512 | if (!process) return; | ||
| 513 | |||
| 514 | yield* manager.close({ | ||
| 515 | threadId: "thread-1", | ||
| 516 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 517 | deleteHistory: true, | ||
| 518 | }); | ||
| 519 | yield* manager.resize({ | ||
| 520 | threadId: "thread-1", | ||
| 521 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 522 | cols: 120, | ||
| 523 | rows: 30, | ||
| 524 | }); | ||
| 525 | |||
| 526 | expect(process.resizeCalls).toEqual([]); | ||
| 527 | }), | ||
| 528 | ); | ||
| 529 | |||
| 481 | it.effect("resizes running terminal on open when a different size is requested", () => | 530 | it.effect("resizes running terminal on open when a different size is requested", () => |
| 482 | Effect.gen(function* () { | 531 | Effect.gen(function* () { |
| 483 | const { manager, ptyAdapter } = yield* createManager(); | 532 | const { manager, ptyAdapter } = yield* createManager(); |
apps/server/src/terminal/Layers/Manager.ts+16-12| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | type TerminalEvent, | 5 | type TerminalEvent, |
| 6 | type TerminalMetadataStreamEvent, | 6 | type TerminalMetadataStreamEvent, |
| 7 | type TerminalOpenInput, | 7 | type TerminalOpenInput, |
| 8 | type TerminalResizeInput, | ||
| 8 | type TerminalSessionSnapshot, | 9 | type TerminalSessionSnapshot, |
| 9 | type TerminalSessionStatus, | 10 | type TerminalSessionStatus, |
| 10 | type TerminalSummary, | 11 | type TerminalSummary, |
| ... | @@ -2325,22 +2326,25 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -2325,22 +2326,25 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 2325 | yield* Effect.sync(() => process.write(input.data)); | 2326 | yield* Effect.sync(() => process.write(input.data)); |
| 2326 | }); | 2327 | }); |
| 2327 | 2328 | ||
| 2328 | const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) { | 2329 | const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { |
| 2329 | const terminalId = input.terminalId; | 2330 | const session = yield* getSession(input.threadId, input.terminalId); |
| 2330 | const session = yield* requireSession(input.threadId, terminalId); | 2331 | // ResizeObserver traffic can already be in flight when the UI closes the session. |
| 2331 | const process = session.process; | 2332 | if (Option.isNone(session)) { |
| 2332 | if (!process || session.status !== "running") { | 2333 | return; |
| 2333 | return yield* new TerminalNotRunningError({ | ||
| 2334 | threadId: input.threadId, | ||
| 2335 | terminalId, | ||
| 2336 | }); | ||
| 2337 | } | 2334 | } |
| 2338 | session.cols = input.cols; | 2335 | const process = session.value.process; |
| 2339 | session.rows = input.rows; | 2336 | if (!process || session.value.status !== "running") { |
| 2340 | session.updatedAt = yield* nowIso; | 2337 | return; |
| 2338 | } | ||
| 2339 | session.value.cols = input.cols; | ||
| 2340 | session.value.rows = input.rows; | ||
| 2341 | session.value.updatedAt = yield* nowIso; | ||
| 2341 | yield* Effect.sync(() => process.resize(input.cols, input.rows)); | 2342 | yield* Effect.sync(() => process.resize(input.cols, input.rows)); |
| 2342 | }); | 2343 | }); |
| 2343 | 2344 | ||
| 2345 | const resize: TerminalManagerShape["resize"] = (input) => | ||
| 2346 | withThreadLock(input.threadId, resizeLocked(input)); | ||
| 2347 | |||
| 2344 | const clear: TerminalManagerShape["clear"] = (input) => | 2348 | const clear: TerminalManagerShape["clear"] = (input) => |
| 2345 | withThreadLock( | 2349 | withThreadLock( |
| 2346 | input.threadId, | 2350 | input.threadId, |
apps/server/src/vcs/GitVcsDriver.ts+5| ... | @@ -168,6 +168,10 @@ export interface GitSetBranchUpstreamInput { | ... | @@ -168,6 +168,10 @@ export interface GitSetBranchUpstreamInput { |
| 168 | remoteBranch: string; | 168 | remoteBranch: string; |
| 169 | } | 169 | } |
| 170 | 170 | ||
| 171 | export interface GitRemoteStatusOptions { | ||
| 172 | readonly refreshUpstream?: boolean; | ||
| 173 | } | ||
| 174 | |||
| 171 | export interface GitVcsDriverShape { | 175 | export interface GitVcsDriverShape { |
| 172 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; | 176 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; |
| 173 | readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>; | 177 | readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>; |
| ... | @@ -175,6 +179,7 @@ export interface GitVcsDriverShape { | ... | @@ -175,6 +179,7 @@ export interface GitVcsDriverShape { |
| 175 | readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | 179 | readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; |
| 176 | readonly statusDetailsRemote: ( | 180 | readonly statusDetailsRemote: ( |
| 177 | cwd: string, | 181 | cwd: string, |
| 182 | options?: GitRemoteStatusOptions, | ||
| 178 | ) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>; | 183 | ) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>; |
| 179 | readonly prepareCommitContext: ( | 184 | readonly prepareCommitContext: ( |
| 180 | cwd: string, | 185 | cwd: string, |
apps/server/src/vcs/GitVcsDriverCore.test.ts+29| ... | @@ -183,6 +183,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | ... | @@ -183,6 +183,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 183 | }), | 183 | }), |
| 184 | ); | 184 | ); |
| 185 | 185 | ||
| 186 | it.effect("can read cached remote divergence without fetching upstream", () => | ||
| 187 | Effect.gen(function* () { | ||
| 188 | const cwd = yield* makeTmpDir(); | ||
| 189 | const remote = yield* makeTmpDir("git-vcs-driver-remote-"); | ||
| 190 | const updater = yield* makeTmpDir("git-vcs-driver-updater-"); | ||
| 191 | const { initialBranch } = yield* initRepoWithCommit(cwd); | ||
| 192 | yield* git(remote, ["init", "--bare"]); | ||
| 193 | yield* git(cwd, ["remote", "add", "origin", remote]); | ||
| 194 | yield* git(cwd, ["push", "-u", "origin", initialBranch]); | ||
| 195 | |||
| 196 | yield* git(updater, ["clone", remote, "."]); | ||
| 197 | yield* git(updater, ["config", "user.email", "test@test.com"]); | ||
| 198 | yield* git(updater, ["config", "user.name", "Test"]); | ||
| 199 | yield* writeTextFile(updater, "remote.txt", "remote\n"); | ||
| 200 | yield* git(updater, ["add", "remote.txt"]); | ||
| 201 | yield* git(updater, ["commit", "-m", "remote commit"]); | ||
| 202 | yield* git(updater, ["push", "origin", initialBranch]); | ||
| 203 | |||
| 204 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 205 | const cachedStatus = yield* driver.statusDetailsRemote(cwd, { | ||
| 206 | refreshUpstream: false, | ||
| 207 | }); | ||
| 208 | const refreshedStatus = yield* driver.statusDetailsRemote(cwd); | ||
| 209 | |||
| 210 | assert.equal(cachedStatus.behindCount, 0); | ||
| 211 | assert.equal(refreshedStatus.behindCount, 1); | ||
| 212 | }), | ||
| 213 | ); | ||
| 214 | |||
| 186 | it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => | 215 | it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => |
| 187 | Effect.gen(function* () { | 216 | Effect.gen(function* () { |
| 188 | const cwd = yield* makeTmpDir(); | 217 | const cwd = yield* makeTmpDir(); |
apps/server/src/vcs/GitVcsDriverCore.ts+7-5| ... | @@ -1444,11 +1444,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1444,11 +1444,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1444 | 1444 | ||
| 1445 | const statusDetailsRemote: GitVcsDriver.GitVcsDriverShape["statusDetailsRemote"] = Effect.fn( | 1445 | const statusDetailsRemote: GitVcsDriver.GitVcsDriverShape["statusDetailsRemote"] = Effect.fn( |
| 1446 | "statusDetailsRemote", | 1446 | "statusDetailsRemote", |
| 1447 | )(function* (cwd) { | 1447 | )(function* (cwd, options) { |
| 1448 | yield* refreshStatusUpstreamIfStale(cwd).pipe( | 1448 | if (options?.refreshUpstream !== false) { |
| 1449 | Effect.catchIf(isMissingGitCwdError, () => Effect.void), | 1449 | yield* refreshStatusUpstreamIfStale(cwd).pipe( |
| 1450 | Effect.ignoreCause({ log: true }), | 1450 | Effect.catchIf(isMissingGitCwdError, () => Effect.void), |
| 1451 | ); | 1451 | Effect.ignoreCause({ log: true }), |
| 1452 | ); | ||
| 1453 | } | ||
| 1452 | return yield* readStatusDetailsRemote(cwd); | 1454 | return yield* readStatusDetailsRemote(cwd); |
| 1453 | }); | 1455 | }); |
| 1454 | 1456 |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+139-8| ... | @@ -43,6 +43,18 @@ const baseRemoteStatus: VcsStatusRemoteResult = { | ... | @@ -43,6 +43,18 @@ const baseRemoteStatus: VcsStatusRemoteResult = { |
| 43 | pr: null, | 43 | pr: null, |
| 44 | }; | 44 | }; |
| 45 | 45 | ||
| 46 | const remoteStatusWithPr: VcsStatusRemoteResult = { | ||
| 47 | ...baseRemoteStatus, | ||
| 48 | pr: { | ||
| 49 | number: 2978, | ||
| 50 | title: "[codex] Rewrite client connection architecture", | ||
| 51 | url: "https://github.com/pingdotgg/t3code/pull/2978", | ||
| 52 | baseRef: "main", | ||
| 53 | headRef: "codex/connection-state-audit", | ||
| 54 | state: "open", | ||
| 55 | }, | ||
| 56 | }; | ||
| 57 | |||
| 46 | const baseStatus: VcsStatusResult = { | 58 | const baseStatus: VcsStatusResult = { |
| 47 | ...baseLocalStatus, | 59 | ...baseLocalStatus, |
| 48 | ...baseRemoteStatus, | 60 | ...baseRemoteStatus, |
| ... | @@ -55,6 +67,7 @@ function makeTestLayer(state: { | ... | @@ -55,6 +67,7 @@ function makeTestLayer(state: { |
| 55 | remoteStatusCalls: number; | 67 | remoteStatusCalls: number; |
| 56 | localInvalidationCalls: number; | 68 | localInvalidationCalls: number; |
| 57 | remoteInvalidationCalls: number; | 69 | remoteInvalidationCalls: number; |
| 70 | remoteStatusRefreshUpstreamValues?: Array<boolean | undefined>; | ||
| 58 | }) { | 71 | }) { |
| 59 | return VcsStatusBroadcaster.layer.pipe( | 72 | return VcsStatusBroadcaster.layer.pipe( |
| 60 | Layer.provideMerge(NodeServices.layer), | 73 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -65,9 +78,10 @@ function makeTestLayer(state: { | ... | @@ -65,9 +78,10 @@ function makeTestLayer(state: { |
| 65 | state.localStatusCalls += 1; | 78 | state.localStatusCalls += 1; |
| 66 | return state.currentLocalStatus; | 79 | return state.currentLocalStatus; |
| 67 | }), | 80 | }), |
| 68 | remoteStatus: () => | 81 | remoteStatus: (_input, options) => |
| 69 | Effect.sync(() => { | 82 | Effect.sync(() => { |
| 70 | state.remoteStatusCalls += 1; | 83 | state.remoteStatusCalls += 1; |
| 84 | state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); | ||
| 71 | return state.currentRemoteStatus; | 85 | return state.currentRemoteStatus; |
| 72 | }), | 86 | }), |
| 73 | invalidateLocalStatus: () => | 87 | invalidateLocalStatus: () => |
| ... | @@ -352,29 +366,146 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -352,29 +366,146 @@ describe("VcsStatusBroadcaster", () => { |
| 352 | }).pipe(Effect.provide(makeTestLayer(state))); | 366 | }).pipe(Effect.provide(makeTestLayer(state))); |
| 353 | }); | 367 | }); |
| 354 | 368 | ||
| 355 | it.effect("does not start automatic remote refreshes when disabled", () => { | 369 | it.effect("loads remote status once when periodic refreshes are disabled", () => { |
| 356 | const state = { | 370 | const state = { |
| 357 | currentLocalStatus: baseLocalStatus, | 371 | currentLocalStatus: baseLocalStatus, |
| 358 | currentRemoteStatus: baseRemoteStatus, | 372 | currentRemoteStatus: remoteStatusWithPr, |
| 359 | localStatusCalls: 0, | 373 | localStatusCalls: 0, |
| 360 | remoteStatusCalls: 0, | 374 | remoteStatusCalls: 0, |
| 361 | localInvalidationCalls: 0, | 375 | localInvalidationCalls: 0, |
| 362 | remoteInvalidationCalls: 0, | 376 | remoteInvalidationCalls: 0, |
| 377 | remoteStatusRefreshUpstreamValues: [] as Array<boolean | undefined>, | ||
| 363 | }; | 378 | }; |
| 364 | 379 | ||
| 365 | return Effect.gen(function* () { | 380 | return Effect.gen(function* () { |
| 366 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; | 381 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 367 | const snapshot = yield* Stream.runHead( | 382 | const scope = yield* Scope.make(); |
| 383 | const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); | ||
| 384 | const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); | ||
| 385 | yield* Stream.runForEach( | ||
| 368 | broadcaster.streamStatus( | 386 | broadcaster.streamStatus( |
| 369 | { cwd: "/repo" }, | 387 | { cwd: "/repo" }, |
| 370 | { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, | 388 | { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, |
| 371 | ), | 389 | ), |
| 372 | ); | 390 | (event) => { |
| 391 | if (event._tag === "snapshot") { | ||
| 392 | return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); | ||
| 393 | } | ||
| 394 | if (event._tag === "remoteUpdated") { | ||
| 395 | return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); | ||
| 396 | } | ||
| 397 | return Effect.void; | ||
| 398 | }, | ||
| 399 | ).pipe(Effect.forkIn(scope)); | ||
| 400 | |||
| 401 | const snapshot = yield* Deferred.await(snapshotDeferred); | ||
| 402 | const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); | ||
| 403 | |||
| 404 | assert.deepStrictEqual(snapshot, { | ||
| 405 | _tag: "snapshot", | ||
| 406 | local: baseLocalStatus, | ||
| 407 | remote: null, | ||
| 408 | } satisfies VcsStatusStreamEvent); | ||
| 409 | assert.deepStrictEqual(remoteUpdated, { | ||
| 410 | _tag: "remoteUpdated", | ||
| 411 | remote: remoteStatusWithPr, | ||
| 412 | } satisfies VcsStatusStreamEvent); | ||
| 413 | assert.equal(state.remoteStatusCalls, 1); | ||
| 414 | assert.equal(state.remoteInvalidationCalls, 0); | ||
| 415 | assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false]); | ||
| 373 | 416 | ||
| 374 | assert.isTrue(Option.isSome(snapshot)); | 417 | yield* TestClock.adjust(Duration.minutes(2)); |
| 375 | assert.equal(state.remoteStatusCalls, 0); | 418 | assert.equal(state.remoteStatusCalls, 1); |
| 376 | assert.equal(state.remoteInvalidationCalls, 0); | 419 | assert.equal(state.remoteInvalidationCalls, 0); |
| 377 | }).pipe(Effect.provide(makeTestLayer(state))); | 420 | |
| 421 | yield* Scope.close(scope, Exit.void); | ||
| 422 | }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); | ||
| 423 | }); | ||
| 424 | |||
| 425 | it.effect("retries the initial remote load when periodic refreshes are disabled", () => { | ||
| 426 | const state = { | ||
| 427 | currentLocalStatus: baseLocalStatus, | ||
| 428 | localStatusCalls: 0, | ||
| 429 | remoteStatusCalls: 0, | ||
| 430 | localInvalidationCalls: 0, | ||
| 431 | remoteInvalidationCalls: 0, | ||
| 432 | remoteStatusRefreshUpstreamValues: [] as Array<boolean | undefined>, | ||
| 433 | }; | ||
| 434 | let firstRemoteAttemptDeferred: Deferred.Deferred<void> | null = null; | ||
| 435 | const testLayer = VcsStatusBroadcaster.layer.pipe( | ||
| 436 | Layer.provideMerge(NodeServices.layer), | ||
| 437 | Layer.provide( | ||
| 438 | Layer.mock(GitWorkflowService.GitWorkflowService)({ | ||
| 439 | localStatus: () => | ||
| 440 | Effect.sync(() => { | ||
| 441 | state.localStatusCalls += 1; | ||
| 442 | return state.currentLocalStatus; | ||
| 443 | }), | ||
| 444 | remoteStatus: (_input, options) => | ||
| 445 | Effect.suspend(() => { | ||
| 446 | state.remoteStatusCalls += 1; | ||
| 447 | state.remoteStatusRefreshUpstreamValues.push(options?.refreshUpstream); | ||
| 448 | if (state.remoteStatusCalls === 1) { | ||
| 449 | return Effect.fail( | ||
| 450 | new GitManagerError({ | ||
| 451 | operation: "VcsStatusBroadcaster.test", | ||
| 452 | detail: "initial remote status failed", | ||
| 453 | }), | ||
| 454 | ).pipe( | ||
| 455 | Effect.ensuring( | ||
| 456 | firstRemoteAttemptDeferred | ||
| 457 | ? Deferred.succeed(firstRemoteAttemptDeferred, undefined).pipe(Effect.ignore) | ||
| 458 | : Effect.void, | ||
| 459 | ), | ||
| 460 | ); | ||
| 461 | } | ||
| 462 | return Effect.succeed(remoteStatusWithPr); | ||
| 463 | }), | ||
| 464 | invalidateLocalStatus: () => | ||
| 465 | Effect.sync(() => { | ||
| 466 | state.localInvalidationCalls += 1; | ||
| 467 | }), | ||
| 468 | invalidateRemoteStatus: () => | ||
| 469 | Effect.sync(() => { | ||
| 470 | state.remoteInvalidationCalls += 1; | ||
| 471 | }), | ||
| 472 | }), | ||
| 473 | ), | ||
| 474 | ); | ||
| 475 | |||
| 476 | return Effect.gen(function* () { | ||
| 477 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; | ||
| 478 | const scope = yield* Scope.make(); | ||
| 479 | firstRemoteAttemptDeferred = yield* Deferred.make<void>(); | ||
| 480 | const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); | ||
| 481 | yield* Stream.runForEach( | ||
| 482 | broadcaster.streamStatus( | ||
| 483 | { cwd: "/repo" }, | ||
| 484 | { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, | ||
| 485 | ), | ||
| 486 | (event) => | ||
| 487 | event._tag === "remoteUpdated" | ||
| 488 | ? Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore) | ||
| 489 | : Effect.void, | ||
| 490 | ).pipe(Effect.forkIn(scope)); | ||
| 491 | |||
| 492 | yield* Deferred.await(firstRemoteAttemptDeferred); | ||
| 493 | yield* Effect.yieldNow; | ||
| 494 | assert.equal(state.remoteStatusCalls, 1); | ||
| 495 | |||
| 496 | yield* TestClock.adjust(Duration.seconds(30)); | ||
| 497 | const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); | ||
| 498 | |||
| 499 | assert.deepStrictEqual(remoteUpdated, { | ||
| 500 | _tag: "remoteUpdated", | ||
| 501 | remote: remoteStatusWithPr, | ||
| 502 | } satisfies VcsStatusStreamEvent); | ||
| 503 | assert.equal(state.remoteStatusCalls, 2); | ||
| 504 | assert.equal(state.remoteInvalidationCalls, 0); | ||
| 505 | assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]); | ||
| 506 | |||
| 507 | yield* Scope.close(scope, Exit.void); | ||
| 508 | }).pipe(Effect.provide(Layer.merge(testLayer, TestClock.layer()))); | ||
| 378 | }); | 509 | }); |
| 379 | 510 | ||
| 380 | it.effect("delays automatic refresh when a cached remote snapshot is available", () => { | 511 | it.effect("delays automatic refresh when a cached remote snapshot is available", () => { |
apps/server/src/vcs/VcsStatusBroadcaster.ts+12-4| ... | @@ -275,9 +275,12 @@ export const layer = Layer.effect( | ... | @@ -275,9 +275,12 @@ export const layer = Layer.effect( |
| 275 | 275 | ||
| 276 | const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( | 276 | const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( |
| 277 | cwd: string, | 277 | cwd: string, |
| 278 | options?: { readonly refreshUpstream?: boolean }, | ||
| 278 | ) { | 279 | ) { |
| 279 | yield* workflow.invalidateRemoteStatus(cwd); | 280 | if (options?.refreshUpstream !== false) { |
| 280 | const remote = yield* workflow.remoteStatus({ cwd }); | 281 | yield* workflow.invalidateRemoteStatus(cwd); |
| 282 | } | ||
| 283 | const remote = yield* workflow.remoteStatus({ cwd }, options); | ||
| 281 | return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); | 284 | return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); |
| 282 | }); | 285 | }); |
| 283 | 286 | ||
| ... | @@ -303,17 +306,22 @@ export const layer = Layer.effect( | ... | @@ -303,17 +306,22 @@ export const layer = Layer.effect( |
| 303 | ) => { | 306 | ) => { |
| 304 | return Effect.gen(function* () { | 307 | return Effect.gen(function* () { |
| 305 | const consecutiveFailuresRef = yield* Ref.make(0); | 308 | const consecutiveFailuresRef = yield* Ref.make(0); |
| 309 | const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); | ||
| 306 | const refreshRemoteStatusIfEnabled = Effect.gen(function* () { | 310 | const refreshRemoteStatusIfEnabled = Effect.gen(function* () { |
| 307 | const configuredInterval = yield* automaticRemoteRefreshInterval; | 311 | const configuredInterval = yield* automaticRemoteRefreshInterval; |
| 308 | const activeInterval = Duration.isZero(configuredInterval) | 312 | const activeInterval = Duration.isZero(configuredInterval) |
| 309 | ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL | 313 | ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL |
| 310 | : configuredInterval; | 314 | : configuredInterval; |
| 311 | if (Duration.isZero(configuredInterval)) { | 315 | const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); |
| 316 | if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { | ||
| 312 | return activeInterval; | 317 | return activeInterval; |
| 313 | } | 318 | } |
| 314 | 319 | ||
| 315 | const exit = yield* refreshRemoteStatus(cwd).pipe(Effect.exit); | 320 | const exit = yield* refreshRemoteStatus(cwd, { |
| 321 | refreshUpstream: !Duration.isZero(configuredInterval), | ||
| 322 | }).pipe(Effect.exit); | ||
| 316 | if (Exit.isSuccess(exit)) { | 323 | if (Exit.isSuccess(exit)) { |
| 324 | yield* Ref.set(needsInitialRefreshRef, false); | ||
| 317 | yield* Ref.set(consecutiveFailuresRef, 0); | 325 | yield* Ref.set(consecutiveFailuresRef, 0); |
| 318 | return activeInterval; | 326 | return activeInterval; |
| 319 | } | 327 | } |
apps/web/package.json+2-7| ... | @@ -8,9 +8,7 @@ | ... | @@ -8,9 +8,7 @@ |
| 8 | "build": "vp build", | 8 | "build": "vp build", |
| 9 | "preview": "vp preview", | 9 | "preview": "vp preview", |
| 10 | "typecheck": "tsgo --noEmit", | 10 | "typecheck": "tsgo --noEmit", |
| 11 | "test": "vp test run --passWithNoTests --project unit", | 11 | "test": "vp test run --passWithNoTests --project unit" |
| 12 | "test:browser": "vp test run --project browser", | ||
| 13 | "test:browser:install": "playwright install --with-deps chromium" | ||
| 14 | }, | 12 | }, |
| 15 | "dependencies": { | 13 | "dependencies": { |
| 16 | "@base-ui/react": "^1.4.1", | 14 | "@base-ui/react": "^1.4.1", |
| ... | @@ -32,7 +30,6 @@ | ... | @@ -32,7 +30,6 @@ |
| 32 | "@t3tools/contracts": "workspace:*", | 30 | "@t3tools/contracts": "workspace:*", |
| 33 | "@t3tools/shared": "workspace:*", | 31 | "@t3tools/shared": "workspace:*", |
| 34 | "@tanstack/react-pacer": "^0.19.4", | 32 | "@tanstack/react-pacer": "^0.19.4", |
| 35 | "@tanstack/react-query": "^5.90.0", | ||
| 36 | "@tanstack/react-router": "^1.160.2", | 33 | "@tanstack/react-router": "^1.160.2", |
| 37 | "@xterm/addon-fit": "^0.11.0", | 34 | "@xterm/addon-fit": "^0.11.0", |
| 38 | "@xterm/xterm": "^6.0.0", | 35 | "@xterm/xterm": "^6.0.0", |
| ... | @@ -64,10 +61,8 @@ | ... | @@ -64,10 +61,8 @@ |
| 64 | "@vitejs/plugin-react": "^6.0.0", | 61 | "@vitejs/plugin-react": "^6.0.0", |
| 65 | "babel-plugin-react-compiler": "1.0.0", | 62 | "babel-plugin-react-compiler": "1.0.0", |
| 66 | "msw": "2.12.11", | 63 | "msw": "2.12.11", |
| 67 | "playwright": "^1.58.2", | ||
| 68 | "tailwindcss": "^4.0.0", | 64 | "tailwindcss": "^4.0.0", |
| 69 | "vite": "catalog:", | 65 | "vite": "catalog:", |
| 70 | "vite-plus": "catalog:", | 66 | "vite-plus": "catalog:" |
| 71 | "vitest-browser-react": "^2.0.5" | ||
| 72 | } | 67 | } |
| 73 | } | 68 | } |
apps/web/src/assets/assetUrls.test.ts created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { resolveAssetUrl } from "./assetUrls"; | ||
| 4 | |||
| 5 | describe("resolveAssetUrl", () => { | ||
| 6 | it("resolves an environment-relative asset URL", () => { | ||
| 7 | expect( | ||
| 8 | resolveAssetUrl("https://environment.example/base/", "/api/assets/signed-token/favicon.png"), | ||
| 9 | ).toBe("https://environment.example/api/assets/signed-token/favicon.png"); | ||
| 10 | }); | ||
| 11 | |||
| 12 | it("rejects an invalid environment base URL", () => { | ||
| 13 | expect(resolveAssetUrl("not a URL", "/api/assets/signed-token/favicon.png")).toBeNull(); | ||
| 14 | }); | ||
| 15 | }); | ||
apps/web/src/assets/assetUrls.ts+40-81| ... | @@ -1,89 +1,48 @@ | ... | @@ -1,89 +1,48 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | ||
| 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | 3 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { useEffect, useMemo, useState } from "react"; | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | import { useMemo } from "react"; | ||
| 3 | 6 | ||
| 4 | import { readEnvironmentApi } from "~/environmentApi"; | 7 | import { assetEnvironment } from "~/state/assets"; |
| 5 | import { readEnvironmentConnection } from "~/environments/runtime"; | 8 | import { usePreparedConnection } from "~/state/session"; |
| 6 | 9 | ||
| 7 | const REFRESH_MARGIN_MS = 30_000; | 10 | export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 8 | 11 | ||
| 9 | interface CachedAssetUrl { | 12 | export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { |
| 10 | readonly url: string; | 13 | const preparedConnection = usePreparedConnection(environmentId); |
| 11 | readonly expiresAt: number; | 14 | const result = useAtomValue( |
| 12 | } | 15 | assetEnvironment.createUrl({ |
| 13 | 16 | environmentId, | |
| 14 | const assetUrlCache = new Map<string, CachedAssetUrl>(); | 17 | input: { resource }, |
| 15 | const assetUrlRequests = new Map<string, Promise<CachedAssetUrl>>(); | 18 | }), |
| 16 | 19 | ); | |
| 17 | function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string { | 20 | if (preparedConnection._tag === "None" || result._tag !== "Success") { |
| 18 | return `${environmentId}:${JSON.stringify(resource)}`; | 21 | return null; |
| 19 | } | ||
| 20 | |||
| 21 | export async function resolveAssetUrl( | ||
| 22 | environmentId: EnvironmentId, | ||
| 23 | resource: AssetResource, | ||
| 24 | ): Promise<CachedAssetUrl> { | ||
| 25 | const key = assetCacheKey(environmentId, resource); | ||
| 26 | const cached = assetUrlCache.get(key); | ||
| 27 | if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) { | ||
| 28 | return cached; | ||
| 29 | } | ||
| 30 | |||
| 31 | const inFlight = assetUrlRequests.get(key); | ||
| 32 | if (inFlight) { | ||
| 33 | return inFlight; | ||
| 34 | } | 22 | } |
| 35 | 23 | return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); | |
| 36 | const request = (async () => { | ||
| 37 | const api = readEnvironmentApi(environmentId); | ||
| 38 | const connection = readEnvironmentConnection(environmentId); | ||
| 39 | if (!api || !connection) { | ||
| 40 | throw new Error("Environment is not connected."); | ||
| 41 | } | ||
| 42 | const result = await api.assets.createUrl({ resource }); | ||
| 43 | const cachedResult = { | ||
| 44 | url: new URL(result.relativeUrl, connection.knownEnvironment.target.httpBaseUrl).toString(), | ||
| 45 | expiresAt: result.expiresAt, | ||
| 46 | }; | ||
| 47 | assetUrlCache.set(key, cachedResult); | ||
| 48 | return cachedResult; | ||
| 49 | })().finally(() => { | ||
| 50 | assetUrlRequests.delete(key); | ||
| 51 | }); | ||
| 52 | assetUrlRequests.set(key, request); | ||
| 53 | return request; | ||
| 54 | } | 24 | } |
| 55 | 25 | ||
| 56 | export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { | 26 | export function useAssetUrls( |
| 57 | const resourceJson = JSON.stringify(resource); | 27 | environmentId: EnvironmentId, |
| 58 | const stableResource = useMemo(() => JSON.parse(resourceJson) as AssetResource, [resourceJson]); | 28 | resources: ReadonlyArray<AssetResource>, |
| 59 | const key = assetCacheKey(environmentId, stableResource); | 29 | ): ReadonlyArray<string | null> { |
| 60 | const [url, setUrl] = useState<string | null>(() => assetUrlCache.get(key)?.url ?? null); | 30 | const preparedConnection = usePreparedConnection(environmentId); |
| 61 | 31 | const results = useAtomValue( | |
| 62 | useEffect(() => { | 32 | assetEnvironment.createUrls({ |
| 63 | let cancelled = false; | 33 | environmentId, |
| 64 | let refreshTimer: ReturnType<typeof setTimeout> | undefined; | 34 | resources, |
| 65 | 35 | }), | |
| 66 | const load = () => { | 36 | ); |
| 67 | void resolveAssetUrl(environmentId, stableResource) | 37 | return useMemo( |
| 68 | .then((result) => { | 38 | () => |
| 69 | if (cancelled) return; | 39 | preparedConnection._tag === "None" |
| 70 | setUrl(result.url); | 40 | ? resources.map(() => null) |
| 71 | refreshTimer = setTimeout( | 41 | : results.map((result) => |
| 72 | load, | 42 | AsyncResult.isSuccess(result) |
| 73 | Math.max(0, result.expiresAt - Date.now() - REFRESH_MARGIN_MS), | 43 | ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) |
| 74 | ); | 44 | : null, |
| 75 | }) | 45 | ), |
| 76 | .catch(() => { | 46 | [preparedConnection, resources, results], |
| 77 | if (!cancelled) setUrl(null); | 47 | ); |
| 78 | }); | ||
| 79 | }; | ||
| 80 | load(); | ||
| 81 | |||
| 82 | return () => { | ||
| 83 | cancelled = true; | ||
| 84 | if (refreshTimer) clearTimeout(refreshTimer); | ||
| 85 | }; | ||
| 86 | }, [environmentId, key, stableResource]); | ||
| 87 | |||
| 88 | return url; | ||
| 89 | } | 48 | } |
apps/web/src/browser/ElectronBrowserHost.tsx+3-3| ... | @@ -1,11 +1,11 @@ | ... | @@ -1,11 +1,11 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime"; | 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { useEffect, useMemo } from "react"; | 4 | import { useEffect, useMemo } from "react"; |
| 5 | 5 | ||
| 6 | import { isElectron } from "~/env"; | 6 | import { isElectron } from "~/env"; |
| 7 | import { useTheme } from "~/hooks/useTheme"; | 7 | import { useTheme } from "~/hooks/useTheme"; |
| 8 | import { usePreviewStateStore } from "~/previewStateStore"; | 8 | import { useActivePreviewSessions } from "~/previewStateStore"; |
| 9 | 9 | ||
| 10 | import { readPreviewAnnotationTheme } from "./annotationTheme"; | 10 | import { readPreviewAnnotationTheme } from "./annotationTheme"; |
| 11 | import { useBrowserPointerStore } from "./browserPointerStore"; | 11 | import { useBrowserPointerStore } from "./browserPointerStore"; |
| ... | @@ -13,7 +13,7 @@ import { HostedBrowserWebview } from "./HostedBrowserWebview"; | ... | @@ -13,7 +13,7 @@ import { HostedBrowserWebview } from "./HostedBrowserWebview"; |
| 13 | 13 | ||
| 14 | export function ElectronBrowserHost() { | 14 | export function ElectronBrowserHost() { |
| 15 | const { resolvedTheme } = useTheme(); | 15 | const { resolvedTheme } = useTheme(); |
| 16 | const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); | 16 | const previewByThreadKey = useActivePreviewSessions(); |
| 17 | const sessions = useMemo( | 17 | const sessions = useMemo( |
| 18 | () => | 18 | () => |
| 19 | Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { | 19 | Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { |
apps/web/src/browser/HostedBrowserWebview.tsx+2-2| ... | @@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef } from "react"; | ... | @@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef } from "react"; |
| 7 | import { previewBridge } from "~/components/preview/previewBridge"; | 7 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | 9 | ||
| 10 | import { useBrowserRecordingStore } from "./browserRecording"; | 10 | import { useActiveBrowserRecordingTabId } from "./browserRecording"; |
| 11 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | 11 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 12 | import { acquireDesktopTab } from "./desktopTabLifetime"; | 12 | import { acquireDesktopTab } from "./desktopTabLifetime"; |
| 13 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; | 13 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| ... | @@ -36,7 +36,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -36,7 +36,7 @@ export function HostedBrowserWebview(props: { |
| 36 | const initialSrcRef = useRef(initialUrl ?? "about:blank"); | 36 | const initialSrcRef = useRef(initialUrl ?? "about:blank"); |
| 37 | const webviewRef = useRef<ElectronWebview | null>(null); | 37 | const webviewRef = useRef<ElectronWebview | null>(null); |
| 38 | const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); | 38 | const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); |
| 39 | const recording = useBrowserRecordingStore((state) => state.activeTabId === tabId); | 39 | const recording = useActiveBrowserRecordingTabId() === tabId; |
| 40 | 40 | ||
| 41 | usePreviewBridge({ threadRef, tabId }); | 41 | usePreviewBridge({ threadRef, tabId }); |
| 42 | 42 |
apps/web/src/browser/browserRecording.ts+52-39| ... | @@ -2,9 +2,11 @@ import type { | ... | @@ -2,9 +2,11 @@ import type { |
| 2 | DesktopPreviewRecordingArtifact, | 2 | DesktopPreviewRecordingArtifact, |
| 3 | DesktopPreviewRecordingFrame, | 3 | DesktopPreviewRecordingFrame, |
| 4 | } from "@t3tools/contracts"; | 4 | } from "@t3tools/contracts"; |
| 5 | import { create } from "zustand"; | 5 | import { useAtomValue } from "@effect/atom-react"; |
| 6 | import { Atom } from "effect/unstable/reactivity"; | ||
| 6 | 7 | ||
| 7 | import { previewBridge } from "~/components/preview/previewBridge"; | 8 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 9 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | ||
| 8 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | 10 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 9 | 11 | ||
| 10 | interface ActiveRecording { | 12 | interface ActiveRecording { |
| ... | @@ -17,21 +19,14 @@ interface ActiveRecording { | ... | @@ -17,21 +19,14 @@ interface ActiveRecording { |
| 17 | readonly startedAt: string; | 19 | readonly startedAt: string; |
| 18 | } | 20 | } |
| 19 | 21 | ||
| 20 | interface BrowserRecordingState { | 22 | const activeBrowserRecordingTabIdAtom = Atom.make<string | null>(null).pipe( |
| 21 | activeTabId: string | null; | 23 | Atom.keepAlive, |
| 22 | startedAt: string | null; | 24 | Atom.withLabel("preview:active-browser-recording-tab"), |
| 23 | lastArtifact: DesktopPreviewRecordingArtifact | null; | 25 | ); |
| 24 | setActive: (tabId: string | null, startedAt: string | null) => void; | ||
| 25 | setArtifact: (artifact: DesktopPreviewRecordingArtifact) => void; | ||
| 26 | } | ||
| 27 | 26 | ||
| 28 | export const useBrowserRecordingStore = create<BrowserRecordingState>()((set) => ({ | 27 | export function useActiveBrowserRecordingTabId(): string | null { |
| 29 | activeTabId: null, | 28 | return useAtomValue(activeBrowserRecordingTabIdAtom); |
| 30 | startedAt: null, | 29 | } |
| 31 | lastArtifact: null, | ||
| 32 | setActive: (activeTabId, startedAt) => set({ activeTabId, startedAt }), | ||
| 33 | setArtifact: (lastArtifact) => set({ lastArtifact }), | ||
| 34 | })); | ||
| 35 | 30 | ||
| 36 | let active: ActiveRecording | null = null; | 31 | let active: ActiveRecording | null = null; |
| 37 | let unsubscribeFrames: (() => void) | null = null; | 32 | let unsubscribeFrames: (() => void) | null = null; |
| ... | @@ -56,9 +51,30 @@ const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { | ... | @@ -56,9 +51,30 @@ const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { |
| 56 | image.src = `data:image/jpeg;base64,${frame.data}`; | 51 | image.src = `data:image/jpeg;base64,${frame.data}`; |
| 57 | }; | 52 | }; |
| 58 | 53 | ||
| 59 | export async function startBrowserRecording(tabId: string): Promise<void> { | 54 | const stopMediaRecorder = async (recorder: MediaRecorder): Promise<void> => { |
| 55 | if (recorder.state === "inactive") return; | ||
| 56 | const stopped = new Promise<void>((resolve) => | ||
| 57 | recorder.addEventListener("stop", () => resolve(), { once: true }), | ||
| 58 | ); | ||
| 59 | recorder.stop(); | ||
| 60 | await stopped; | ||
| 61 | }; | ||
| 62 | |||
| 63 | const clearActiveRecording = (recording: ActiveRecording): void => { | ||
| 64 | if (active !== recording) return; | ||
| 65 | active = null; | ||
| 66 | unsubscribeFrames?.(); | ||
| 67 | unsubscribeFrames = null; | ||
| 68 | appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null); | ||
| 69 | }; | ||
| 70 | |||
| 71 | export async function startBrowserRecording(tabId: string): Promise<string> { | ||
| 60 | const bridge = previewBridge; | 72 | const bridge = previewBridge; |
| 61 | if (!bridge || active) return; | 73 | if (!bridge) throw new Error("Browser recording is unavailable."); |
| 74 | if (active) { | ||
| 75 | if (active.tabId === tabId) return active.startedAt; | ||
| 76 | throw new Error("Another preview tab is already being recorded."); | ||
| 77 | } | ||
| 62 | const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; | 78 | const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; |
| 63 | const canvas = document.createElement("canvas"); | 79 | const canvas = document.createElement("canvas"); |
| 64 | canvas.width = Math.max(1, rect?.width ?? 1280); | 80 | canvas.width = Math.max(1, rect?.width ?? 1280); |
| ... | @@ -75,15 +91,17 @@ export async function startBrowserRecording(tabId: string): Promise<void> { | ... | @@ -75,15 +91,17 @@ export async function startBrowserRecording(tabId: string): Promise<void> { |
| 75 | recorder.addEventListener("dataavailable", (event) => { | 91 | recorder.addEventListener("dataavailable", (event) => { |
| 76 | if (event.data.size > 0) chunks.push(event.data); | 92 | if (event.data.size > 0) chunks.push(event.data); |
| 77 | }); | 93 | }); |
| 78 | active = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; | 94 | const recording = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; |
| 95 | active = recording; | ||
| 79 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); | 96 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); |
| 80 | recorder.start(1_000); | 97 | recorder.start(1_000); |
| 81 | try { | 98 | try { |
| 82 | await bridge.recording.startScreencast(tabId); | 99 | await bridge.recording.startScreencast(tabId); |
| 83 | useBrowserRecordingStore.getState().setActive(tabId, startedAt); | 100 | appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); |
| 101 | return startedAt; | ||
| 84 | } catch (error) { | 102 | } catch (error) { |
| 85 | active = null; | 103 | await stopMediaRecorder(recorder); |
| 86 | recorder.stop(); | 104 | clearActiveRecording(recording); |
| 87 | throw error; | 105 | throw error; |
| 88 | } | 106 | } |
| 89 | } | 107 | } |
| ... | @@ -94,22 +112,17 @@ export async function stopBrowserRecording( | ... | @@ -94,22 +112,17 @@ export async function stopBrowserRecording( |
| 94 | const bridge = previewBridge; | 112 | const bridge = previewBridge; |
| 95 | const recording = active; | 113 | const recording = active; |
| 96 | if (!bridge || !recording || recording.tabId !== tabId) return null; | 114 | if (!bridge || !recording || recording.tabId !== tabId) return null; |
| 97 | await bridge.recording.stopScreencast(tabId); | 115 | try { |
| 98 | const stopped = new Promise<void>((resolve) => | 116 | await bridge.recording.stopScreencast(tabId); |
| 99 | recording.recorder.addEventListener("stop", () => resolve(), { once: true }), | 117 | await stopMediaRecorder(recording.recorder); |
| 100 | ); | 118 | const blob = new Blob(recording.chunks, { type: recording.mimeType }); |
| 101 | recording.recorder.stop(); | 119 | return await bridge.recording.save( |
| 102 | await stopped; | 120 | tabId, |
| 103 | const blob = new Blob(recording.chunks, { type: recording.mimeType }); | 121 | recording.mimeType, |
| 104 | const artifact = await bridge.recording.save( | 122 | new Uint8Array(await blob.arrayBuffer()), |
| 105 | tabId, | 123 | ); |
| 106 | recording.mimeType, | 124 | } finally { |
| 107 | new Uint8Array(await blob.arrayBuffer()), | 125 | await stopMediaRecorder(recording.recorder); |
| 108 | ); | 126 | clearActiveRecording(recording); |
| 109 | active = null; | 127 | } |
| 110 | unsubscribeFrames?.(); | ||
| 111 | unsubscribeFrames = null; | ||
| 112 | useBrowserRecordingStore.getState().setActive(null, null); | ||
| 113 | useBrowserRecordingStore.getState().setArtifact(artifact); | ||
| 114 | return artifact; | ||
| 115 | } | 128 | } |
apps/web/src/browser/browserTargetResolver.test.ts+7-15| ... | @@ -1,17 +1,15 @@ | ... | @@ -1,17 +1,15 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | const readEnvironmentConnection = vi.fn(); | 4 | const readPreparedConnection = vi.fn(); |
| 5 | 5 | ||
| 6 | vi.mock("~/environments/runtime", () => ({ readEnvironmentConnection })); | 6 | vi.mock("~/state/session", () => ({ readPreparedConnection })); |
| 7 | 7 | ||
| 8 | describe("browser target resolver", () => { | 8 | describe("browser target resolver", () => { |
| 9 | beforeEach(() => readEnvironmentConnection.mockReset()); | 9 | beforeEach(() => readPreparedConnection.mockReset()); |
| 10 | 10 | ||
| 11 | it("maps environment ports onto a private network host", async () => { | 11 | it("maps environment ports onto a private network host", async () => { |
| 12 | readEnvironmentConnection.mockReturnValue({ | 12 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); |
| 13 | knownEnvironment: { target: { httpBaseUrl: "http://192.168.1.25:3773" } }, | ||
| 14 | }); | ||
| 15 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | 13 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); |
| 16 | expect( | 14 | expect( |
| 17 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | 15 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { |
| ... | @@ -28,9 +26,7 @@ describe("browser target resolver", () => { | ... | @@ -28,9 +26,7 @@ describe("browser target resolver", () => { |
| 28 | }); | 26 | }); |
| 29 | 27 | ||
| 30 | it("refuses public relay hosts until the authenticated gateway exists", async () => { | 28 | it("refuses public relay hosts until the authenticated gateway exists", async () => { |
| 31 | readEnvironmentConnection.mockReturnValue({ | 29 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); |
| 32 | knownEnvironment: { target: { httpBaseUrl: "https://relay.example.com" } }, | ||
| 33 | }); | ||
| 34 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | 30 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); |
| 35 | expect(() => | 31 | expect(() => |
| 36 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | 32 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { |
| ... | @@ -41,9 +37,7 @@ describe("browser target resolver", () => { | ... | @@ -41,9 +37,7 @@ describe("browser target resolver", () => { |
| 41 | }); | 37 | }); |
| 42 | 38 | ||
| 43 | it("normalizes schemeless localhost server-picker values", async () => { | 39 | it("normalizes schemeless localhost server-picker values", async () => { |
| 44 | readEnvironmentConnection.mockReturnValue({ | 40 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://localhost:3773" }); |
| 45 | knownEnvironment: { target: { httpBaseUrl: "http://localhost:3773" } }, | ||
| 46 | }); | ||
| 47 | const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); | 41 | const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); |
| 48 | expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( | 42 | expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( |
| 49 | "http://localhost:5173/", | 43 | "http://localhost:5173/", |
| ... | @@ -61,9 +55,7 @@ describe("browser target resolver", () => { | ... | @@ -61,9 +55,7 @@ describe("browser target resolver", () => { |
| 61 | }); | 55 | }); |
| 62 | 56 | ||
| 63 | it("supports private IPv6 environment hosts", async () => { | 57 | it("supports private IPv6 environment hosts", async () => { |
| 64 | readEnvironmentConnection.mockReturnValue({ | 58 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); |
| 65 | knownEnvironment: { target: { httpBaseUrl: "http://[::1]:3773" } }, | ||
| 66 | }); | ||
| 67 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | 59 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); |
| 68 | expect( | 60 | expect( |
| 69 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | 61 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { |
apps/web/src/browser/browserTargetResolver.ts+3-3| ... | @@ -5,7 +5,7 @@ import type { | ... | @@ -5,7 +5,7 @@ import type { |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; | 6 | import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 7 | 7 | ||
| 8 | import { readEnvironmentConnection } from "~/environments/runtime"; | 8 | import { readPreparedConnection } from "~/state/session"; |
| 9 | 9 | ||
| 10 | const isPrivateNetworkHost = (host: string): boolean => { | 10 | const isPrivateNetworkHost = (host: string): boolean => { |
| 11 | const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); | 11 | const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); |
| ... | @@ -36,9 +36,9 @@ export function resolveBrowserNavigationTarget( | ... | @@ -36,9 +36,9 @@ export function resolveBrowserNavigationTarget( |
| 36 | environmentId, | 36 | environmentId, |
| 37 | }; | 37 | }; |
| 38 | } | 38 | } |
| 39 | const connection = readEnvironmentConnection(environmentId); | 39 | const connection = readPreparedConnection(environmentId); |
| 40 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); | 40 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); |
| 41 | const environmentUrl = new URL(connection.knownEnvironment.target.httpBaseUrl); | 41 | const environmentUrl = new URL(connection.httpBaseUrl); |
| 42 | if (!isPrivateNetworkHost(environmentUrl.hostname)) { | 42 | if (!isPrivateNetworkHost(environmentUrl.hostname)) { |
| 43 | throw new Error( | 43 | throw new Error( |
| 44 | "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", | 44 | "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", |
apps/web/src/browser/openFileInPreview.ts+84-22| ... | @@ -1,36 +1,98 @@ | ... | @@ -1,36 +1,98 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { |
| 2 | AssetCreateUrlResult, | ||
| 3 | AssetResource, | ||
| 4 | EnvironmentId, | ||
| 5 | PreviewOpenInput, | ||
| 6 | PreviewSessionSnapshot, | ||
| 7 | ScopedThreadRef, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { | ||
| 10 | type AtomCommandResult, | ||
| 11 | mapAtomCommandResult, | ||
| 12 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 13 | import * as Cause from "effect/Cause"; | ||
| 14 | import * as Data from "effect/Data"; | ||
| 15 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 2 | 16 | ||
| 3 | import { readEnvironmentApi } from "~/environmentApi"; | ||
| 4 | import { resolveAssetUrl } from "~/assets/assetUrls"; | 17 | import { resolveAssetUrl } from "~/assets/assetUrls"; |
| 5 | import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore"; | 18 | import { |
| 19 | applyPreviewServerSnapshot, | ||
| 20 | isPreviewSupportedInRuntime, | ||
| 21 | rememberPreviewUrl, | ||
| 22 | } from "~/previewStateStore"; | ||
| 6 | import { useRightPanelStore } from "~/rightPanelStore"; | 23 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 7 | 24 | ||
| 8 | export const isBrowserPreviewFile = (path: string): boolean => | 25 | export const isBrowserPreviewFile = (path: string): boolean => |
| 9 | /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); | 26 | /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); |
| 10 | 27 | ||
| 11 | export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise<void> { | 28 | export class BrowserPreviewUnavailableError extends Data.TaggedError( |
| 12 | const api = readEnvironmentApi(threadRef.environmentId); | 29 | "BrowserPreviewUnavailableError", |
| 13 | if (!api) { | 30 | )<{ |
| 14 | throw new Error("Environment is not connected."); | 31 | readonly message: string; |
| 15 | } | 32 | }> {} |
| 33 | |||
| 34 | export type OpenPreviewMutation<E = unknown> = (input: { | ||
| 35 | readonly environmentId: EnvironmentId; | ||
| 36 | readonly input: PreviewOpenInput; | ||
| 37 | }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>; | ||
| 16 | 38 | ||
| 17 | const snapshot = await api.preview.open({ threadId: threadRef.threadId, url }); | 39 | export async function openUrlInPreview<E>(input: { |
| 18 | usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); | 40 | readonly threadRef: ScopedThreadRef; |
| 19 | usePreviewStateStore.getState().rememberUrl(threadRef, url); | 41 | readonly url: string; |
| 20 | useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); | 42 | readonly openPreview: OpenPreviewMutation<E>; |
| 43 | }): Promise<AtomCommandResult<void, E>> { | ||
| 44 | const result = await input.openPreview({ | ||
| 45 | environmentId: input.threadRef.environmentId, | ||
| 46 | input: { threadId: input.threadRef.threadId, url: input.url }, | ||
| 47 | }); | ||
| 48 | return mapAtomCommandResult(result, (snapshot) => { | ||
| 49 | applyPreviewServerSnapshot(input.threadRef, snapshot); | ||
| 50 | rememberPreviewUrl(input.threadRef, input.url); | ||
| 51 | useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); | ||
| 52 | }); | ||
| 21 | } | 53 | } |
| 22 | 54 | ||
| 23 | export async function openFileInPreview( | 55 | export async function openFileInPreview<AssetError, PreviewError>(input: { |
| 24 | threadRef: ScopedThreadRef, | 56 | readonly threadRef: ScopedThreadRef; |
| 25 | filePath: string, | 57 | readonly filePath: string; |
| 26 | ): Promise<void> { | 58 | readonly httpBaseUrl: string; |
| 59 | readonly createAssetUrl: (input: { | ||
| 60 | readonly environmentId: EnvironmentId; | ||
| 61 | readonly input: { readonly resource: AssetResource }; | ||
| 62 | }) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>; | ||
| 63 | readonly openPreview: OpenPreviewMutation<PreviewError>; | ||
| 64 | }): Promise<AtomCommandResult<void, AssetError | PreviewError | BrowserPreviewUnavailableError>> { | ||
| 27 | if (!isPreviewSupportedInRuntime()) { | 65 | if (!isPreviewSupportedInRuntime()) { |
| 28 | throw new Error("The integrated browser is unavailable in this runtime."); | 66 | return AsyncResult.failure( |
| 67 | Cause.fail( | ||
| 68 | new BrowserPreviewUnavailableError({ | ||
| 69 | message: "The integrated browser is unavailable in this runtime.", | ||
| 70 | }), | ||
| 71 | ), | ||
| 72 | ); | ||
| 73 | } | ||
| 74 | const assetResult = await input.createAssetUrl({ | ||
| 75 | environmentId: input.threadRef.environmentId, | ||
| 76 | input: { | ||
| 77 | resource: { | ||
| 78 | _tag: "workspace-file", | ||
| 79 | threadId: input.threadRef.threadId, | ||
| 80 | path: input.filePath, | ||
| 81 | }, | ||
| 82 | }, | ||
| 83 | }); | ||
| 84 | if (assetResult._tag === "Failure") { | ||
| 85 | return AsyncResult.failure(assetResult.cause); | ||
| 86 | } | ||
| 87 | const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl); | ||
| 88 | if (assetUrl === null) { | ||
| 89 | return AsyncResult.failure( | ||
| 90 | Cause.die(new Error("The environment returned an invalid asset URL.")), | ||
| 91 | ); | ||
| 29 | } | 92 | } |
| 30 | const asset = await resolveAssetUrl(threadRef.environmentId, { | 93 | return openUrlInPreview({ |
| 31 | _tag: "workspace-file", | 94 | threadRef: input.threadRef, |
| 32 | threadId: threadRef.threadId, | 95 | url: assetUrl, |
| 33 | path: filePath, | 96 | openPreview: input.openPreview, |
| 34 | }); | 97 | }); |
| 35 | await openUrlInPreview(threadRef, asset.url); | ||
| 36 | } | 98 | } |
apps/web/src/clientPersistenceStorage.test.ts+13-45| ... | @@ -1,23 +1,6 @@ | ... | @@ -1,23 +1,6 @@ |
| 1 | import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; | 1 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; |
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | const testEnvironmentId = EnvironmentId.make("environment-1"); | ||
| 5 | |||
| 6 | const savedRegistryRecord: PersistedSavedEnvironmentRecord = { | ||
| 7 | environmentId: testEnvironmentId, | ||
| 8 | label: "Remote environment", | ||
| 9 | httpBaseUrl: "https://remote.example.com/", | ||
| 10 | wsBaseUrl: "wss://remote.example.com/", | ||
| 11 | createdAt: "2026-04-09T00:00:00.000Z", | ||
| 12 | lastConnectedAt: null, | ||
| 13 | desktopSsh: { | ||
| 14 | alias: "devbox", | ||
| 15 | hostname: "devbox.example.com", | ||
| 16 | username: "julius", | ||
| 17 | port: 22, | ||
| 18 | }, | ||
| 19 | }; | ||
| 20 | |||
| 21 | function createLocalStorageStub(): Storage { | 4 | function createLocalStorageStub(): Storage { |
| 22 | const store = new Map<string, string>(); | 5 | const store = new Map<string, string>(); |
| 23 | return { | 6 | return { |
| ... | @@ -55,32 +38,17 @@ afterEach(() => { | ... | @@ -55,32 +38,17 @@ afterEach(() => { |
| 55 | }); | 38 | }); |
| 56 | 39 | ||
| 57 | describe("clientPersistenceStorage", () => { | 40 | describe("clientPersistenceStorage", () => { |
| 58 | it("stores browser secrets inline with the saved environment record", async () => { | 41 | it("persists client settings in browser storage", async () => { |
| 59 | const testWindow = getTestWindow(); | 42 | getTestWindow(); |
| 60 | const { | 43 | const { readBrowserClientSettings, writeBrowserClientSettings } = |
| 61 | SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, | 44 | await import("./clientPersistenceStorage"); |
| 62 | readBrowserSavedEnvironmentRegistry, | 45 | const settings = { |
| 63 | readBrowserSavedEnvironmentSecret, | 46 | ...DEFAULT_CLIENT_SETTINGS, |
| 64 | writeBrowserSavedEnvironmentRegistry, | 47 | timestampFormat: "24-hour" as const, |
| 65 | writeBrowserSavedEnvironmentSecret, | 48 | }; |
| 66 | } = await import("./clientPersistenceStorage"); | 49 | |
| 67 | 50 | writeBrowserClientSettings(settings); | |
| 68 | writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); | 51 | |
| 69 | expect(writeBrowserSavedEnvironmentSecret(testEnvironmentId, "bearer-token")).toBe(true); | 52 | expect(readBrowserClientSettings()).toEqual(settings); |
| 70 | writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); | ||
| 71 | |||
| 72 | expect(readBrowserSavedEnvironmentRegistry()).toEqual([savedRegistryRecord]); | ||
| 73 | expect(readBrowserSavedEnvironmentSecret(testEnvironmentId)).toBe("bearer-token"); | ||
| 74 | expect( | ||
| 75 | JSON.parse(testWindow.localStorage.getItem(SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY)!), | ||
| 76 | ).toEqual({ | ||
| 77 | version: 1, | ||
| 78 | records: [ | ||
| 79 | { | ||
| 80 | ...savedRegistryRecord, | ||
| 81 | bearerToken: "bearer-token", | ||
| 82 | }, | ||
| 83 | ], | ||
| 84 | }); | ||
| 85 | }); | 53 | }); |
| 86 | }); | 54 | }); |
apps/web/src/clientPersistenceStorage.ts+1-189| ... | @@ -1,66 +1,13 @@ | ... | @@ -1,66 +1,13 @@ |
| 1 | import { | 1 | import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; |
| 2 | ClientSettingsSchema, | ||
| 3 | EnvironmentId, | ||
| 4 | type ClientSettings, | ||
| 5 | type EnvironmentId as EnvironmentIdValue, | ||
| 6 | type PersistedSavedEnvironmentRecord, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | 2 | ||
| 10 | import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; | 3 | import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; |
| 11 | 4 | ||
| 12 | export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; | 5 | export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; |
| 13 | export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; | ||
| 14 | |||
| 15 | const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ | ||
| 16 | environmentId: EnvironmentId, | ||
| 17 | label: Schema.String, | ||
| 18 | httpBaseUrl: Schema.String, | ||
| 19 | wsBaseUrl: Schema.String, | ||
| 20 | createdAt: Schema.String, | ||
| 21 | lastConnectedAt: Schema.NullOr(Schema.String), | ||
| 22 | desktopSsh: Schema.optionalKey( | ||
| 23 | Schema.Struct({ | ||
| 24 | alias: Schema.String, | ||
| 25 | hostname: Schema.String, | ||
| 26 | username: Schema.NullOr(Schema.String), | ||
| 27 | port: Schema.NullOr(Schema.Number), | ||
| 28 | }), | ||
| 29 | ), | ||
| 30 | relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })), | ||
| 31 | bearerToken: Schema.optionalKey(Schema.String), | ||
| 32 | }); | ||
| 33 | type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; | ||
| 34 | |||
| 35 | const BrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({ | ||
| 36 | version: Schema.optionalKey(Schema.Number), | ||
| 37 | records: Schema.optionalKey(Schema.Array(BrowserSavedEnvironmentRecordSchema)), | ||
| 38 | }); | ||
| 39 | type BrowserSavedEnvironmentRegistryDocument = | ||
| 40 | typeof BrowserSavedEnvironmentRegistryDocumentSchema.Type; | ||
| 41 | 6 | ||
| 42 | function hasWindow(): boolean { | 7 | function hasWindow(): boolean { |
| 43 | return typeof window !== "undefined"; | 8 | return typeof window !== "undefined"; |
| 44 | } | 9 | } |
| 45 | 10 | ||
| 46 | function toPersistedSavedEnvironmentRecord( | ||
| 47 | record: PersistedSavedEnvironmentRecord, | ||
| 48 | ): PersistedSavedEnvironmentRecord { | ||
| 49 | const nextRecord = { | ||
| 50 | environmentId: record.environmentId, | ||
| 51 | label: record.label, | ||
| 52 | httpBaseUrl: record.httpBaseUrl, | ||
| 53 | wsBaseUrl: record.wsBaseUrl, | ||
| 54 | createdAt: record.createdAt, | ||
| 55 | lastConnectedAt: record.lastConnectedAt, | ||
| 56 | }; | ||
| 57 | return { | ||
| 58 | ...nextRecord, | ||
| 59 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 60 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | export function readBrowserClientSettings(): ClientSettings | null { | 11 | export function readBrowserClientSettings(): ClientSettings | null { |
| 65 | if (!hasWindow()) { | 12 | if (!hasWindow()) { |
| 66 | return null; | 13 | return null; |
| ... | @@ -80,138 +27,3 @@ export function writeBrowserClientSettings(settings: ClientSettings): void { | ... | @@ -80,138 +27,3 @@ export function writeBrowserClientSettings(settings: ClientSettings): void { |
| 80 | 27 | ||
| 81 | setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); | 28 | setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); |
| 82 | } | 29 | } |
| 83 | |||
| 84 | function readBrowserSavedEnvironmentRegistryDocument(): BrowserSavedEnvironmentRegistryDocument { | ||
| 85 | if (!hasWindow()) { | ||
| 86 | return {}; | ||
| 87 | } | ||
| 88 | |||
| 89 | try { | ||
| 90 | const parsed = getLocalStorageItem( | ||
| 91 | SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, | ||
| 92 | BrowserSavedEnvironmentRegistryDocumentSchema, | ||
| 93 | ); | ||
| 94 | return parsed ?? {}; | ||
| 95 | } catch { | ||
| 96 | return {}; | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | function writeBrowserSavedEnvironmentRegistryDocument( | ||
| 101 | document: BrowserSavedEnvironmentRegistryDocument, | ||
| 102 | ): void { | ||
| 103 | if (!hasWindow()) { | ||
| 104 | return; | ||
| 105 | } | ||
| 106 | |||
| 107 | setLocalStorageItem( | ||
| 108 | SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, | ||
| 109 | document, | ||
| 110 | BrowserSavedEnvironmentRegistryDocumentSchema, | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | function readBrowserSavedEnvironmentRecordsWithSecrets(): ReadonlyArray<BrowserSavedEnvironmentRecord> { | ||
| 115 | return readBrowserSavedEnvironmentRegistryDocument().records ?? []; | ||
| 116 | } | ||
| 117 | |||
| 118 | function writeBrowserSavedEnvironmentRecords( | ||
| 119 | records: ReadonlyArray<BrowserSavedEnvironmentRecord>, | ||
| 120 | ): void { | ||
| 121 | writeBrowserSavedEnvironmentRegistryDocument({ | ||
| 122 | version: 1, | ||
| 123 | records, | ||
| 124 | }); | ||
| 125 | } | ||
| 126 | |||
| 127 | export function readBrowserSavedEnvironmentRegistry(): ReadonlyArray<PersistedSavedEnvironmentRecord> { | ||
| 128 | return readBrowserSavedEnvironmentRecordsWithSecrets().map((record) => | ||
| 129 | toPersistedSavedEnvironmentRecord(record), | ||
| 130 | ); | ||
| 131 | } | ||
| 132 | |||
| 133 | export function writeBrowserSavedEnvironmentRegistry( | ||
| 134 | records: ReadonlyArray<PersistedSavedEnvironmentRecord>, | ||
| 135 | ): void { | ||
| 136 | const existing = new Map( | ||
| 137 | readBrowserSavedEnvironmentRecordsWithSecrets().map( | ||
| 138 | (record) => [record.environmentId, record] as const, | ||
| 139 | ), | ||
| 140 | ); | ||
| 141 | writeBrowserSavedEnvironmentRecords( | ||
| 142 | records.map((record) => { | ||
| 143 | const bearerToken = existing.get(record.environmentId)?.bearerToken; | ||
| 144 | return bearerToken | ||
| 145 | ? { | ||
| 146 | environmentId: record.environmentId, | ||
| 147 | label: record.label, | ||
| 148 | httpBaseUrl: record.httpBaseUrl, | ||
| 149 | wsBaseUrl: record.wsBaseUrl, | ||
| 150 | createdAt: record.createdAt, | ||
| 151 | lastConnectedAt: record.lastConnectedAt, | ||
| 152 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 153 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 154 | bearerToken, | ||
| 155 | } | ||
| 156 | : toPersistedSavedEnvironmentRecord(record); | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | |||
| 161 | export function readBrowserSavedEnvironmentSecret( | ||
| 162 | environmentId: EnvironmentIdValue, | ||
| 163 | ): string | null { | ||
| 164 | return ( | ||
| 165 | readBrowserSavedEnvironmentRecordsWithSecrets().find( | ||
| 166 | (record) => record.environmentId === environmentId, | ||
| 167 | )?.bearerToken ?? null | ||
| 168 | ); | ||
| 169 | } | ||
| 170 | |||
| 171 | export function writeBrowserSavedEnvironmentSecret( | ||
| 172 | environmentId: EnvironmentIdValue, | ||
| 173 | secret: string, | ||
| 174 | ): boolean { | ||
| 175 | const document = readBrowserSavedEnvironmentRegistryDocument(); | ||
| 176 | const records = document.records ?? []; | ||
| 177 | let found = false; | ||
| 178 | writeBrowserSavedEnvironmentRegistryDocument({ | ||
| 179 | version: document.version ?? 1, | ||
| 180 | // The persistence update is copy-on-write so storage subscribers observe a new document. | ||
| 181 | // oxlint-disable-next-line oxc/no-map-spread | ||
| 182 | records: records.map((record) => { | ||
| 183 | if (record.environmentId !== environmentId) { | ||
| 184 | return record; | ||
| 185 | } | ||
| 186 | found = true; | ||
| 187 | const nextRecord: BrowserSavedEnvironmentRecord = { | ||
| 188 | environmentId: record.environmentId, | ||
| 189 | label: record.label, | ||
| 190 | httpBaseUrl: record.httpBaseUrl, | ||
| 191 | wsBaseUrl: record.wsBaseUrl, | ||
| 192 | createdAt: record.createdAt, | ||
| 193 | lastConnectedAt: record.lastConnectedAt, | ||
| 194 | bearerToken: secret, | ||
| 195 | }; | ||
| 196 | return { | ||
| 197 | ...nextRecord, | ||
| 198 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 199 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 200 | }; | ||
| 201 | }), | ||
| 202 | }); | ||
| 203 | return found; | ||
| 204 | } | ||
| 205 | |||
| 206 | export function removeBrowserSavedEnvironmentSecret(environmentId: EnvironmentIdValue): void { | ||
| 207 | const document = readBrowserSavedEnvironmentRegistryDocument(); | ||
| 208 | writeBrowserSavedEnvironmentRegistryDocument({ | ||
| 209 | version: document.version ?? 1, | ||
| 210 | records: (document.records ?? []).map((record) => { | ||
| 211 | if (record.environmentId !== environmentId) { | ||
| 212 | return record; | ||
| 213 | } | ||
| 214 | return toPersistedSavedEnvironmentRecord(record); | ||
| 215 | }), | ||
| 216 | }); | ||
| 217 | } |
apps/web/src/cloud/dpop.test.ts+23-20| ... | @@ -1,32 +1,35 @@ | ... | @@ -1,32 +1,35 @@ |
| 1 | import { verifyDpopProof } from "@t3tools/shared/dpop"; | 1 | import { verifyDpopProof } from "@t3tools/shared/dpop"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | 4 | import { decodeJwt } from "jose"; |
| 5 | import { vi } from "vite-plus/test"; | ||
| 4 | 6 | ||
| 5 | import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; | 7 | import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; |
| 6 | 8 | ||
| 7 | describe("browser DPoP proofs", () => { | 9 | describe("browser DPoP proofs", () => { |
| 8 | it("signs relay resource proofs with an access-token hash", async () => { | 10 | it.effect("signs relay resource proofs with an access-token hash", () => |
| 9 | vi.stubGlobal("indexedDB", undefined); | 11 | Effect.gen(function* () { |
| 10 | const issuedAt = Math.floor(Date.now() / 1_000); | 12 | vi.stubGlobal("indexedDB", undefined); |
| 11 | const proofKey = await Effect.runPromise(generateBrowserDpopKey); | 13 | const proofKey = yield* generateBrowserDpopKey; |
| 12 | const proof = await Effect.runPromise( | 14 | const proof = yield* createBrowserDpopProof({ |
| 13 | createBrowserDpopProof({ | ||
| 14 | method: "POST", | 15 | method: "POST", |
| 15 | url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true", | 16 | url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true", |
| 16 | accessToken: "relay-access-token", | 17 | accessToken: "relay-access-token", |
| 17 | proofKey, | 18 | proofKey, |
| 18 | }).pipe(Effect.provide(browserCryptoLayer)), | 19 | }).pipe(Effect.provide(browserCryptoLayer)); |
| 19 | ); | 20 | const issuedAt = decodeJwt(proof.proof).iat; |
| 21 | expect(issuedAt).toBeTypeOf("number"); | ||
| 20 | 22 | ||
| 21 | expect( | 23 | expect( |
| 22 | verifyDpopProof({ | 24 | verifyDpopProof({ |
| 23 | proof: proof.proof, | 25 | proof: proof.proof, |
| 24 | method: "POST", | 26 | method: "POST", |
| 25 | url: "https://relay.example.test/v1/environments/env-1/connect", | 27 | url: "https://relay.example.test/v1/environments/env-1/connect", |
| 26 | expectedThumbprint: proof.thumbprint, | 28 | expectedThumbprint: proof.thumbprint, |
| 27 | expectedAccessToken: "relay-access-token", | 29 | expectedAccessToken: "relay-access-token", |
| 28 | nowEpochSeconds: issuedAt, | 30 | nowEpochSeconds: issuedAt!, |
| 29 | }), | 31 | }), |
| 30 | ).toMatchObject({ ok: true }); | 32 | ).toMatchObject({ ok: true }); |
| 31 | }); | 33 | }), |
| 34 | ); | ||
| 32 | }); | 35 | }); |
apps/web/src/cloud/linkEnvironment.test.ts+190-778| ... | @@ -1,911 +1,323 @@ | ... | @@ -1,911 +1,323 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { |
| 2 | EnvironmentId, | ||
| 3 | type RelayClientInstallProgressEvent, | ||
| 4 | WS_METHODS, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { RelayWebClientId } from "@t3tools/contracts/relay"; | 6 | import { RelayWebClientId } from "@t3tools/contracts/relay"; |
| 3 | import { afterEach, beforeEach, vi } from "vite-plus/test"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Option from "effect/Option"; | ||
| 11 | import * as Stream from "effect/Stream"; | ||
| 12 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 7 | import { HttpClient } from "effect/unstable/http"; | 13 | import { HttpClient } from "effect/unstable/http"; |
| 14 | import { afterEach, beforeEach, vi } from "vite-plus/test"; | ||
| 15 | import { | ||
| 16 | AVAILABLE_CONNECTION_STATE, | ||
| 17 | type EnvironmentRegistryService, | ||
| 18 | EnvironmentSupervisor, | ||
| 19 | type EnvironmentSupervisorService, | ||
| 20 | type PreparedConnection, | ||
| 21 | PrimaryConnectionTarget, | ||
| 22 | } from "@t3tools/client-runtime/connection"; | ||
| 23 | import { type RpcSession } from "@t3tools/client-runtime/rpc"; | ||
| 24 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; | ||
| 8 | import { | 25 | import { |
| 9 | managedRelayClientLayer, | 26 | managedRelayClientLayer, |
| 10 | ManagedRelayClient, | 27 | ManagedRelayClient, |
| 11 | ManagedRelayDpopSigner, | 28 | ManagedRelayDpopSigner, |
| 12 | remoteHttpClientLayer, | 29 | } from "@t3tools/client-runtime/relay"; |
| 13 | } from "@t3tools/client-runtime"; | 30 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; |
| 14 | 31 | ||
| 15 | import type { SavedEnvironmentRecord } from "../environments/runtime"; | ||
| 16 | import { | 32 | import { |
| 17 | connectManagedCloudEnvironment, | 33 | collectCloudLinkTargets, |
| 18 | linkEnvironmentToCloud, | ||
| 19 | linkPrimaryEnvironmentToCloud, | 34 | linkPrimaryEnvironmentToCloud, |
| 20 | listManagedCloudEnvironments, | 35 | listManagedCloudEnvironments, |
| 21 | normalizeRelayBaseUrl, | 36 | normalizeRelayBaseUrl, |
| 22 | readPrimaryCloudLinkState, | 37 | readPrimaryCloudLinkState, |
| 38 | type CloudLinkTarget, | ||
| 23 | unlinkPrimaryEnvironmentFromCloud, | 39 | unlinkPrimaryEnvironmentFromCloud, |
| 24 | } from "./linkEnvironment"; | 40 | } from "./linkEnvironment"; |
| 25 | import { | ||
| 26 | readPrimaryEnvironmentDescriptor, | ||
| 27 | readPrimaryEnvironmentTarget, | ||
| 28 | resolvePrimaryEnvironmentHttpUrl, | ||
| 29 | } from "../environments/primary"; | ||
| 30 | 41 | ||
| 31 | const getSavedEnvironmentSecretMock = vi.fn(); | 42 | const TARGET: CloudLinkTarget = { |
| 32 | const relayClientInstallDialogHarness = vi.hoisted(() => ({ | 43 | environmentId: "environment-1", |
| 44 | label: "Desktop", | ||
| 45 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 46 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 47 | }; | ||
| 48 | |||
| 49 | const relayClientInstallDialog = vi.hoisted(() => ({ | ||
| 33 | requestConfirmation: vi.fn(), | 50 | requestConfirmation: vi.fn(), |
| 34 | reportProgress: vi.fn(), | 51 | reportProgress: vi.fn(), |
| 35 | finish: vi.fn(), | 52 | finish: vi.fn(), |
| 36 | })); | 53 | })); |
| 37 | const getRelayClientStatusMock = vi.fn(); | ||
| 38 | const installRelayClientMock = vi.fn(); | ||
| 39 | const environmentConnectionMock = { | ||
| 40 | client: { | ||
| 41 | cloud: { | ||
| 42 | getRelayClientStatus: getRelayClientStatusMock, | ||
| 43 | installRelayClient: installRelayClientMock, | ||
| 44 | }, | ||
| 45 | }, | ||
| 46 | }; | ||
| 47 | 54 | ||
| 48 | const createProofMock = vi.fn( | 55 | vi.mock("./relayClientInstallDialog", () => ({ |
| 49 | (_input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => | 56 | requestRelayClientInstallConfirmation: relayClientInstallDialog.requestConfirmation, |
| 50 | Effect.succeed("web-dpop-proof"), | 57 | reportRelayClientInstallProgress: relayClientInstallDialog.reportProgress, |
| 51 | ); | 58 | finishRelayClientInstall: relayClientInstallDialog.finish, |
| 52 | const testDpopSignerLayer = Layer.succeed( | 59 | })); |
| 60 | |||
| 61 | const createProof = vi.fn(() => Effect.succeed("dpop-proof")); | ||
| 62 | const dpopSignerLayer = Layer.succeed( | ||
| 53 | ManagedRelayDpopSigner, | 63 | ManagedRelayDpopSigner, |
| 54 | ManagedRelayDpopSigner.of({ | 64 | ManagedRelayDpopSigner.of({ |
| 55 | thumbprint: Effect.succeed("web-thumbprint"), | 65 | thumbprint: Effect.succeed("thumbprint"), |
| 56 | createProof: (input) => createProofMock(input), | 66 | createProof, |
| 57 | }), | 67 | }), |
| 58 | ); | 68 | ); |
| 59 | 69 | ||
| 60 | function cloudClientLayer() { | 70 | function relayLayer() { |
| 61 | const httpClientLayer = remoteHttpClientLayer(globalThis.fetch); | 71 | const http = remoteHttpClientLayer(globalThis.fetch); |
| 62 | return Layer.mergeAll( | 72 | return Layer.mergeAll( |
| 63 | httpClientLayer, | 73 | http, |
| 64 | managedRelayClientLayer({ | 74 | managedRelayClientLayer({ |
| 65 | relayUrl: "https://relay.example.test", | 75 | relayUrl: "https://relay.example.test", |
| 66 | clientId: RelayWebClientId, | 76 | clientId: RelayWebClientId, |
| 67 | }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), | 77 | }).pipe(Layer.provideMerge(dpopSignerLayer), Layer.provide(http)), |
| 68 | ); | 78 | ); |
| 69 | } | 79 | } |
| 70 | 80 | ||
| 71 | const withCloudServices = <A, E>( | 81 | function registryLayer(options?: { |
| 72 | effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>, | 82 | readonly status?: { readonly status: "available"; readonly version: string }; |
| 73 | ) => effect.pipe(Effect.provide(cloudClientLayer())); | 83 | readonly installEvents?: ReadonlyArray<RelayClientInstallProgressEvent>; |
| 74 | 84 | }) { | |
| 75 | vi.mock("../localApi", () => ({ | 85 | return Layer.effect( |
| 76 | ensureLocalApi: () => ({ | 86 | EnvironmentRegistry, |
| 77 | persistence: { | 87 | Effect.gen(function* () { |
| 78 | getSavedEnvironmentSecret: getSavedEnvironmentSecretMock, | 88 | const client = { |
| 79 | }, | 89 | [WS_METHODS.cloudGetRelayClientStatus]: () => |
| 80 | }), | 90 | Effect.succeed(options?.status ?? { status: "available", version: "2026.6.0" }), |
| 81 | })); | 91 | [WS_METHODS.cloudInstallRelayClient]: () => |
| 82 | 92 | Stream.fromIterable(options?.installEvents ?? []), | |
| 83 | vi.mock("./relayClientInstallDialog", () => ({ | 93 | } as unknown as RpcSession["client"]; |
| 84 | requestRelayClientInstallConfirmation: relayClientInstallDialogHarness.requestConfirmation, | 94 | const session: RpcSession = { |
| 85 | reportRelayClientInstallProgress: relayClientInstallDialogHarness.reportProgress, | 95 | client, |
| 86 | finishRelayClientInstall: relayClientInstallDialogHarness.finish, | 96 | initialConfig: Effect.never, |
| 87 | })); | 97 | ready: Effect.void, |
| 88 | 98 | probe: Effect.void, | |
| 89 | vi.mock("../environments/primary", () => ({ | 99 | closed: Effect.never, |
| 90 | readPrimaryEnvironmentDescriptor: vi.fn(() => null), | 100 | }; |
| 91 | readPrimaryEnvironmentTarget: vi.fn(() => null), | 101 | const target = new PrimaryConnectionTarget({ |
| 92 | resolvePrimaryEnvironmentHttpUrl: vi.fn((path: string) => `http://127.0.0.1:3000${path}`), | 102 | environmentId: EnvironmentId.make(TARGET.environmentId), |
| 93 | })); | 103 | label: TARGET.label, |
| 94 | 104 | httpBaseUrl: TARGET.httpBaseUrl, | |
| 95 | vi.mock("../environments/runtime", () => ({ | 105 | wsBaseUrl: TARGET.wsBaseUrl, |
| 96 | getPrimaryEnvironmentConnection: () => environmentConnectionMock, | 106 | }); |
| 97 | readEnvironmentConnection: () => environmentConnectionMock, | 107 | const supervisor = EnvironmentSupervisor.of({ |
| 98 | })); | 108 | target, |
| 99 | 109 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), | |
| 100 | const savedEnvironment: SavedEnvironmentRecord = { | 110 | session: yield* SubscriptionRef.make(Option.some(session)), |
| 101 | environmentId: EnvironmentId.make("env-1"), | 111 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), |
| 102 | label: "Desktop", | 112 | connect: Effect.void, |
| 103 | httpBaseUrl: "http://127.0.0.1:3000", | 113 | disconnect: Effect.void, |
| 104 | wsBaseUrl: "ws://127.0.0.1:3000", | 114 | retryNow: Effect.void, |
| 105 | createdAt: "2026-05-25T00:00:00.000Z", | 115 | } satisfies EnvironmentSupervisorService); |
| 106 | lastConnectedAt: null, | 116 | const registry = { |
| 107 | }; | 117 | run: <A, E, R>(_environmentId: EnvironmentId, effect: Effect.Effect<A, E, R>) => |
| 108 | 118 | Effect.provideService(effect, EnvironmentSupervisor, supervisor), | |
| 109 | function validProof() { | 119 | runStream: <A, E, R>(_environmentId: EnvironmentId, stream: Stream.Stream<A, E, R>) => |
| 110 | return "signed-environment-link-jwt"; | 120 | Stream.provideService(stream, EnvironmentSupervisor, supervisor), |
| 121 | } as unknown as EnvironmentRegistryService; | ||
| 122 | return EnvironmentRegistry.of(registry); | ||
| 123 | }), | ||
| 124 | ); | ||
| 111 | } | 125 | } |
| 112 | 126 | ||
| 113 | function validChallenge() { | 127 | function services(options?: Parameters<typeof registryLayer>[0]) { |
| 114 | return { | 128 | return Layer.mergeAll(relayLayer(), registryLayer(options)); |
| 115 | challenge: "link-challenge", | ||
| 116 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 117 | }; | ||
| 118 | } | 129 | } |
| 119 | 130 | ||
| 120 | function availableRelayClient() { | 131 | function withServices<A, E>( |
| 121 | return { | 132 | effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | EnvironmentRegistry>, |
| 122 | status: "available", | 133 | options?: Parameters<typeof registryLayer>[0], |
| 123 | executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", | 134 | ) { |
| 124 | source: "managed", | 135 | return effect.pipe(Effect.provide(services(options))); |
| 125 | version: "2026.5.2", | ||
| 126 | }; | ||
| 127 | } | 136 | } |
| 128 | 137 | ||
| 129 | function requestBodyText(body: BodyInit | null | undefined): string { | 138 | function bodyText(body: BodyInit | null | undefined): string { |
| 130 | return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); | 139 | return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); |
| 131 | } | 140 | } |
| 132 | 141 | ||
| 133 | describe("web cloud link environment client", () => { | 142 | beforeEach(() => { |
| 134 | afterEach(() => { | 143 | vi.clearAllMocks(); |
| 135 | if ("window" in globalThis) { | 144 | vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); |
| 136 | Reflect.deleteProperty(window, "desktopBridge"); | 145 | relayClientInstallDialog.requestConfirmation.mockResolvedValue(true); |
| 137 | } | 146 | }); |
| 138 | vi.unstubAllGlobals(); | ||
| 139 | }); | ||
| 140 | 147 | ||
| 141 | beforeEach(() => { | 148 | afterEach(() => { |
| 142 | vi.restoreAllMocks(); | 149 | vi.unstubAllGlobals(); |
| 143 | vi.clearAllMocks(); | 150 | vi.unstubAllEnvs(); |
| 144 | createProofMock.mockClear(); | 151 | vi.restoreAllMocks(); |
| 145 | vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); | 152 | }); |
| 146 | getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer"); | ||
| 147 | relayClientInstallDialogHarness.requestConfirmation.mockResolvedValue(true); | ||
| 148 | getRelayClientStatusMock.mockResolvedValue(availableRelayClient()); | ||
| 149 | installRelayClientMock.mockResolvedValue(availableRelayClient()); | ||
| 150 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null); | ||
| 151 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue(null); | ||
| 152 | vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( | ||
| 153 | (path: string) => `http://127.0.0.1:3000${path}`, | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | 153 | ||
| 157 | it("normalizes configured relay base URLs before building relay requests", () => { | 154 | describe("web cloud link environment client", () => { |
| 155 | it("normalizes relay URLs and de-duplicates cloud link targets", () => { | ||
| 158 | expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( | 156 | expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( |
| 159 | "https://relay.example.test", | 157 | "https://relay.example.test", |
| 160 | ); | 158 | ); |
| 161 | expect(normalizeRelayBaseUrl(" ")).toBeNull(); | 159 | expect(normalizeRelayBaseUrl(" ")).toBeNull(); |
| 160 | expect( | ||
| 161 | collectCloudLinkTargets({ | ||
| 162 | primary: TARGET, | ||
| 163 | saved: [TARGET, { ...TARGET, environmentId: "environment-2" }], | ||
| 164 | }).map((target) => target.environmentId), | ||
| 165 | ).toEqual(["environment-1", "environment-2"]); | ||
| 162 | }); | 166 | }); |
| 163 | 167 | ||
| 164 | it.effect( | 168 | it.effect("lists relay-managed environments through the typed relay client", () => |
| 165 | "installs the relay client over environment RPC before requesting a cloud challenge", | ||
| 166 | () => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | getRelayClientStatusMock.mockResolvedValue({ | ||
| 169 | status: "missing", | ||
| 170 | version: "2026.5.2", | ||
| 171 | }); | ||
| 172 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 173 | environmentId: EnvironmentId.make("env-1"), | ||
| 174 | label: "Desktop", | ||
| 175 | platform: { os: "darwin", arch: "arm64" }, | ||
| 176 | serverVersion: "0.0.0-test", | ||
| 177 | capabilities: { repositoryIdentity: true }, | ||
| 178 | }); | ||
| 179 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 180 | source: "desktop-managed", | ||
| 181 | target: { | ||
| 182 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 183 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 184 | }, | ||
| 185 | }); | ||
| 186 | const fetchMock = vi | ||
| 187 | .fn() | ||
| 188 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 189 | .mockResolvedValueOnce(Response.json({ malformed: true })); | ||
| 190 | vi.stubGlobal("fetch", fetchMock); | ||
| 191 | installRelayClientMock.mockImplementationOnce(async (onProgress) => { | ||
| 192 | onProgress({ type: "progress", stage: "downloading" }); | ||
| 193 | return availableRelayClient(); | ||
| 194 | }); | ||
| 195 | |||
| 196 | yield* withCloudServices( | ||
| 197 | linkPrimaryEnvironmentToCloud({ | ||
| 198 | clerkToken: "clerk-token", | ||
| 199 | }), | ||
| 200 | ).pipe(Effect.flip); | ||
| 201 | |||
| 202 | expect(relayClientInstallDialogHarness.requestConfirmation).toHaveBeenCalledWith( | ||
| 203 | "2026.5.2", | ||
| 204 | ); | ||
| 205 | expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); | ||
| 206 | expect(installRelayClientMock).toHaveBeenCalledOnce(); | ||
| 207 | expect(relayClientInstallDialogHarness.reportProgress).toHaveBeenCalledWith({ | ||
| 208 | type: "progress", | ||
| 209 | stage: "downloading", | ||
| 210 | }); | ||
| 211 | expect(relayClientInstallDialogHarness.finish).toHaveBeenCalledOnce(); | ||
| 212 | expect(installRelayClientMock.mock.invocationCallOrder[0]).toBeLessThan( | ||
| 213 | fetchMock.mock.invocationCallOrder[0]!, | ||
| 214 | ); | ||
| 215 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | ||
| 216 | "https://relay.example.test/v1/client/environment-link-challenges", | ||
| 217 | ); | ||
| 218 | }), | ||
| 219 | ); | ||
| 220 | |||
| 221 | it.effect("lists relay-managed environments for hosted and served web clients", () => | ||
| 222 | Effect.gen(function* () { | 169 | Effect.gen(function* () { |
| 223 | const fetchMock = vi.fn().mockResolvedValueOnce( | 170 | const fetchMock = vi.fn().mockResolvedValue( |
| 224 | Response.json({ | 171 | Response.json({ |
| 225 | environments: [ | 172 | environments: [ |
| 226 | { | 173 | { |
| 227 | environmentId: "env-1", | 174 | environmentId: "environment-1", |
| 228 | label: "Managed desktop", | 175 | label: "Desktop", |
| 229 | endpoint: { | 176 | endpoint: { |
| 230 | httpBaseUrl: "https://managed.example.test", | 177 | httpBaseUrl: "https://desktop.example.test", |
| 231 | wsBaseUrl: "wss://managed.example.test", | 178 | wsBaseUrl: "wss://desktop.example.test", |
| 232 | providerKind: "cloudflare_tunnel", | 179 | providerKind: "cloudflare_tunnel", |
| 233 | }, | 180 | }, |
| 234 | linkedAt: "2026-05-25T00:00:00.000Z", | 181 | linkedAt: "2026-06-06T00:00:00.000Z", |
| 235 | }, | 182 | }, |
| 236 | ], | 183 | ], |
| 237 | }), | 184 | }), |
| 238 | ); | 185 | ); |
| 239 | vi.stubGlobal("fetch", fetchMock); | 186 | vi.stubGlobal("fetch", fetchMock); |
| 240 | 187 | ||
| 241 | const environments = yield* withCloudServices( | 188 | const environments = yield* withServices( |
| 242 | listManagedCloudEnvironments({ clerkToken: "clerk-token" }), | 189 | listManagedCloudEnvironments({ clerkToken: "clerk-token" }), |
| 243 | ); | 190 | ); |
| 191 | |||
| 244 | expect(environments).toHaveLength(1); | 192 | expect(environments).toHaveLength(1); |
| 245 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | ||
| 246 | "https://relay.example.test/v1/environments", | ||
| 247 | ); | ||
| 248 | expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); | 193 | expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); |
| 249 | expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("connects web clients to managed environments with a tunnel-only DPoP token", () => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const environment = { | ||
| 256 | environmentId: EnvironmentId.make("env-1"), | ||
| 257 | label: "Managed desktop", | ||
| 258 | endpoint: { | ||
| 259 | httpBaseUrl: "https://managed.example.test", | ||
| 260 | wsBaseUrl: "wss://managed.example.test", | ||
| 261 | providerKind: "cloudflare_tunnel" as const, | ||
| 262 | }, | ||
| 263 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 264 | }; | ||
| 265 | const fetchMock = vi | ||
| 266 | .fn() | ||
| 267 | .mockResolvedValueOnce( | ||
| 268 | Response.json({ | ||
| 269 | access_token: "relay-access-token", | ||
| 270 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 271 | token_type: "DPoP", | ||
| 272 | expires_in: 300, | ||
| 273 | scope: "environment:connect", | ||
| 274 | }), | ||
| 275 | ) | ||
| 276 | .mockResolvedValueOnce( | ||
| 277 | Response.json({ | ||
| 278 | environmentId: "env-1", | ||
| 279 | endpoint: environment.endpoint, | ||
| 280 | credential: "environment-bootstrap", | ||
| 281 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 282 | }), | ||
| 283 | ) | ||
| 284 | .mockResolvedValueOnce( | ||
| 285 | Response.json({ | ||
| 286 | environmentId: "env-1", | ||
| 287 | label: "Managed desktop", | ||
| 288 | platform: { os: "darwin", arch: "arm64" }, | ||
| 289 | serverVersion: "0.0.0-test", | ||
| 290 | capabilities: { repositoryIdentity: true }, | ||
| 291 | }), | ||
| 292 | ) | ||
| 293 | .mockResolvedValueOnce( | ||
| 294 | Response.json({ | ||
| 295 | access_token: "environment-access-token", | ||
| 296 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 297 | token_type: "DPoP", | ||
| 298 | expires_in: 3600, | ||
| 299 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 300 | }), | ||
| 301 | ); | ||
| 302 | vi.stubGlobal("fetch", fetchMock); | ||
| 303 | |||
| 304 | const connection = yield* withCloudServices( | ||
| 305 | connectManagedCloudEnvironment({ clerkToken: "clerk-token", environment }), | ||
| 306 | ); | ||
| 307 | expect(connection).toMatchObject({ | ||
| 308 | environmentId: "env-1", | ||
| 309 | accessToken: "environment-access-token", | ||
| 310 | }); | ||
| 311 | |||
| 312 | const tokenBody = requestBodyText(fetchMock.mock.calls[0]?.[1]?.body); | ||
| 313 | expect(new URLSearchParams(tokenBody).get("client_id")).toBe("t3-web"); | ||
| 314 | expect(new URLSearchParams(tokenBody).get("scope")).toBe("environment:connect"); | ||
| 315 | expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("DPoP relay-access-token"); | ||
| 316 | expect(fetchMock.mock.calls[1]?.[1]?.headers.dpop).toBe("web-dpop-proof"); | ||
| 317 | expect(createProofMock).toHaveBeenCalledWith({ | ||
| 318 | method: "POST", | ||
| 319 | url: "https://managed.example.test/oauth/token", | ||
| 320 | }); | ||
| 321 | const traceparents = fetchMock.mock.calls.map( | ||
| 322 | (call) => call[1]?.headers.traceparent as string | undefined, | ||
| 323 | ); | ||
| 324 | expect(traceparents.every((traceparent) => typeof traceparent === "string")).toBe(true); | ||
| 325 | expect(new Set(traceparents.map((traceparent) => traceparent?.split("-")[1])).size).toBe(1); | ||
| 326 | expect(connection.relayTraceHeaders.traceparent?.split("-")[1]).toBe( | ||
| 327 | traceparents[0]?.split("-")[1], | ||
| 328 | ); | ||
| 329 | }), | ||
| 330 | ); | ||
| 331 | |||
| 332 | it.effect("rejects a stored managed connection for another relay origin", () => | ||
| 333 | Effect.gen(function* () { | ||
| 334 | const environment = { | ||
| 335 | environmentId: EnvironmentId.make("env-1"), | ||
| 336 | label: "Managed desktop", | ||
| 337 | endpoint: { | ||
| 338 | httpBaseUrl: "https://managed.example.test", | ||
| 339 | wsBaseUrl: "wss://managed.example.test", | ||
| 340 | providerKind: "cloudflare_tunnel" as const, | ||
| 341 | }, | ||
| 342 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 343 | }; | ||
| 344 | |||
| 345 | const error = yield* withCloudServices( | ||
| 346 | connectManagedCloudEnvironment({ | ||
| 347 | clerkToken: "clerk-token", | ||
| 348 | environment, | ||
| 349 | relayUrl: "https://old-relay.example.test", | ||
| 350 | }), | ||
| 351 | ).pipe(Effect.flip); | ||
| 352 | expect(error).toMatchObject({ | ||
| 353 | message: "The saved environment is linked through a different configured relay.", | ||
| 354 | }); | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | it.effect("rejects malformed local environment link proofs", () => | ||
| 359 | Effect.gen(function* () { | ||
| 360 | vi.stubGlobal( | ||
| 361 | "fetch", | ||
| 362 | vi | ||
| 363 | .fn() | ||
| 364 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 365 | .mockResolvedValueOnce( | ||
| 366 | Response.json({ | ||
| 367 | payload: { | ||
| 368 | environmentId: "env-1", | ||
| 369 | }, | ||
| 370 | signature: "signature-1", | ||
| 371 | }), | ||
| 372 | ), | ||
| 373 | ); | ||
| 374 | |||
| 375 | const error = yield* withCloudServices( | ||
| 376 | linkEnvironmentToCloud({ | ||
| 377 | environment: savedEnvironment, | ||
| 378 | clerkToken: "clerk-token", | ||
| 379 | }), | ||
| 380 | ).pipe(Effect.flip); | ||
| 381 | expect(error).toMatchObject({ | ||
| 382 | _tag: "CloudEnvironmentLinkError", | ||
| 383 | message: "Could not obtain environment link proof.", | ||
| 384 | }); | ||
| 385 | }), | 194 | }), |
| 386 | ); | 195 | ); |
| 387 | 196 | ||
| 388 | it.effect("preserves typed local environment failures while obtaining a link proof", () => | 197 | it.effect("reads primary cloud link state from the explicit target", () => |
| 389 | Effect.gen(function* () { | 198 | Effect.gen(function* () { |
| 390 | vi.stubGlobal( | 199 | const fetchMock = vi.fn().mockResolvedValue( |
| 391 | "fetch", | ||
| 392 | vi | ||
| 393 | .fn() | ||
| 394 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 395 | .mockResolvedValueOnce( | ||
| 396 | Response.json( | ||
| 397 | { | ||
| 398 | _tag: "EnvironmentHttpUnauthorizedError", | ||
| 399 | message: "Invalid environment bearer session.", | ||
| 400 | }, | ||
| 401 | { status: 401 }, | ||
| 402 | ), | ||
| 403 | ), | ||
| 404 | ); | ||
| 405 | |||
| 406 | const error = yield* withCloudServices( | ||
| 407 | linkEnvironmentToCloud({ | ||
| 408 | environment: savedEnvironment, | ||
| 409 | clerkToken: "clerk-token", | ||
| 410 | }), | ||
| 411 | ).pipe(Effect.flip); | ||
| 412 | expect(error._tag).toBe("CloudEnvironmentLinkError"); | ||
| 413 | expect(error.message).toBe( | ||
| 414 | "Could not obtain environment link proof: Invalid environment bearer session.", | ||
| 415 | ); | ||
| 416 | }), | ||
| 417 | ); | ||
| 418 | |||
| 419 | it.effect("rejects malformed relay environment link responses", () => | ||
| 420 | Effect.gen(function* () { | ||
| 421 | vi.stubGlobal( | ||
| 422 | "fetch", | ||
| 423 | vi | ||
| 424 | .fn() | ||
| 425 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 426 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 427 | .mockResolvedValueOnce( | ||
| 428 | Response.json({ | ||
| 429 | ok: true, | ||
| 430 | environmentId: "env-1", | ||
| 431 | endpoint: { | ||
| 432 | httpBaseUrl: "https://desktop.example.test", | ||
| 433 | wsBaseUrl: "wss://desktop.example.test", | ||
| 434 | providerKind: "cloudflare_tunnel", | ||
| 435 | }, | ||
| 436 | endpointRuntime: null, | ||
| 437 | relayIssuer: "https://issuer.example.test", | ||
| 438 | cloudUserId: "user_123", | ||
| 439 | environmentCredential: "", | ||
| 440 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 441 | }), | ||
| 442 | ), | ||
| 443 | ); | ||
| 444 | |||
| 445 | const error = yield* withCloudServices( | ||
| 446 | linkEnvironmentToCloud({ | ||
| 447 | environment: savedEnvironment, | ||
| 448 | clerkToken: "clerk-token", | ||
| 449 | }), | ||
| 450 | ).pipe(Effect.flip); | ||
| 451 | expect(error).toMatchObject({ | ||
| 452 | _tag: "CloudEnvironmentLinkError", | ||
| 453 | message: "https://relay.example.test/v1/client/environment-links failed", | ||
| 454 | }); | ||
| 455 | }), | ||
| 456 | ); | ||
| 457 | |||
| 458 | it.effect( | ||
| 459 | "links the primary local environment through the relay using the owner cookie session", | ||
| 460 | () => | ||
| 461 | Effect.gen(function* () { | ||
| 462 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 463 | environmentId: EnvironmentId.make("env-1"), | ||
| 464 | label: "Desktop", | ||
| 465 | platform: { os: "darwin", arch: "arm64" }, | ||
| 466 | serverVersion: "0.0.0-test", | ||
| 467 | capabilities: { repositoryIdentity: true }, | ||
| 468 | }); | ||
| 469 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 470 | source: "desktop-managed", | ||
| 471 | target: { | ||
| 472 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 473 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 474 | }, | ||
| 475 | }); | ||
| 476 | vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( | ||
| 477 | (path: string) => `http://127.0.0.1:3000${path}`, | ||
| 478 | ); | ||
| 479 | |||
| 480 | const fetchMock = vi | ||
| 481 | .fn() | ||
| 482 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 483 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 484 | .mockResolvedValueOnce( | ||
| 485 | Response.json({ | ||
| 486 | ok: true, | ||
| 487 | environmentId: "env-1", | ||
| 488 | endpoint: { | ||
| 489 | httpBaseUrl: "https://desktop.example.test", | ||
| 490 | wsBaseUrl: "wss://desktop.example.test", | ||
| 491 | providerKind: "cloudflare_tunnel", | ||
| 492 | }, | ||
| 493 | endpointRuntime: { | ||
| 494 | providerKind: "cloudflare_tunnel", | ||
| 495 | connectorToken: "connector-token", | ||
| 496 | tunnelId: "tunnel-id", | ||
| 497 | tunnelName: "tunnel-name", | ||
| 498 | }, | ||
| 499 | relayIssuer: "https://issuer.example.test", | ||
| 500 | cloudUserId: "user_123", | ||
| 501 | environmentCredential: "t3env_test_credential", | ||
| 502 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 503 | }), | ||
| 504 | ) | ||
| 505 | .mockResolvedValueOnce( | ||
| 506 | Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), | ||
| 507 | ); | ||
| 508 | vi.stubGlobal("fetch", fetchMock); | ||
| 509 | |||
| 510 | yield* withCloudServices( | ||
| 511 | linkPrimaryEnvironmentToCloud({ | ||
| 512 | clerkToken: "clerk-token", | ||
| 513 | }), | ||
| 514 | ); | ||
| 515 | |||
| 516 | expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); | ||
| 517 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | ||
| 518 | "https://relay.example.test/v1/client/environment-link-challenges", | ||
| 519 | ); | ||
| 520 | expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); | ||
| 521 | expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); | ||
| 522 | expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); | ||
| 523 | |||
| 524 | expect(String(fetchMock.mock.calls[1]?.[0])).toBe( | ||
| 525 | "http://127.0.0.1:3000/api/connect/link-proof", | ||
| 526 | ); | ||
| 527 | expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ | ||
| 528 | method: "POST", | ||
| 529 | credentials: "include", | ||
| 530 | headers: expect.objectContaining({ | ||
| 531 | "content-type": "application/json", | ||
| 532 | }), | ||
| 533 | }); | ||
| 534 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 535 | expect(JSON.parse(requestBodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ | ||
| 536 | challenge: "link-challenge", | ||
| 537 | endpoint: { | ||
| 538 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 539 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 540 | providerKind: "cloudflare_tunnel", | ||
| 541 | }, | ||
| 542 | origin: { | ||
| 543 | localHttpHost: "127.0.0.1", | ||
| 544 | localHttpPort: 3000, | ||
| 545 | }, | ||
| 546 | }); | ||
| 547 | |||
| 548 | expect(String(fetchMock.mock.calls[2]?.[0])).toBe( | ||
| 549 | "https://relay.example.test/v1/client/environment-links", | ||
| 550 | ); | ||
| 551 | expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); | ||
| 552 | expect(fetchMock.mock.calls[2]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); | ||
| 553 | expect(fetchMock.mock.calls[2]?.[1]?.credentials).not.toBe("include"); | ||
| 554 | expect(fetchMock.mock.calls[2]?.[1]?.headers["content-type"]).toBe("application/json"); | ||
| 555 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 556 | expect(JSON.parse(requestBodyText(fetchMock.mock.calls[2]?.[1]?.body))).toMatchObject({ | ||
| 557 | proof: validProof(), | ||
| 558 | notificationsEnabled: true, | ||
| 559 | liveActivitiesEnabled: true, | ||
| 560 | managedTunnelsEnabled: true, | ||
| 561 | }); | ||
| 562 | |||
| 563 | expect(String(fetchMock.mock.calls[3]?.[0])).toBe( | ||
| 564 | "http://127.0.0.1:3000/api/connect/relay-config", | ||
| 565 | ); | ||
| 566 | expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({ | ||
| 567 | method: "POST", | ||
| 568 | credentials: "include", | ||
| 569 | headers: expect.objectContaining({ | ||
| 570 | "content-type": "application/json", | ||
| 571 | }), | ||
| 572 | }); | ||
| 573 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 574 | expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ | ||
| 575 | relayUrl: "https://relay.example.test", | ||
| 576 | relayIssuer: "https://issuer.example.test", | ||
| 577 | cloudUserId: "user_123", | ||
| 578 | environmentCredential: "t3env_test_credential", | ||
| 579 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 580 | endpointRuntime: { | ||
| 581 | providerKind: "cloudflare_tunnel", | ||
| 582 | connectorToken: "connector-token", | ||
| 583 | tunnelId: "tunnel-id", | ||
| 584 | tunnelName: "tunnel-name", | ||
| 585 | }, | ||
| 586 | }); | ||
| 587 | }), | ||
| 588 | ); | ||
| 589 | |||
| 590 | it.effect("reads the primary local cloud link state with the owner cookie session", () => | ||
| 591 | Effect.gen(function* () { | ||
| 592 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 593 | environmentId: EnvironmentId.make("env-1"), | ||
| 594 | label: "Desktop", | ||
| 595 | platform: { os: "darwin", arch: "arm64" }, | ||
| 596 | serverVersion: "0.0.0-test", | ||
| 597 | capabilities: { repositoryIdentity: true }, | ||
| 598 | }); | ||
| 599 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 600 | source: "desktop-managed", | ||
| 601 | target: { | ||
| 602 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 603 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 604 | }, | ||
| 605 | }); | ||
| 606 | const fetchMock = vi.fn().mockResolvedValueOnce( | ||
| 607 | Response.json({ | 200 | Response.json({ |
| 608 | linked: true, | 201 | linked: true, |
| 609 | cloudUserId: "user_123", | 202 | cloudUserId: "user-1", |
| 610 | relayUrl: "https://relay.example.test", | 203 | relayUrl: "https://relay.example.test", |
| 611 | relayIssuer: "https://issuer.example.test", | 204 | relayIssuer: "https://relay.example.test", |
| 612 | publishAgentActivity: false, | 205 | publishAgentActivity: false, |
| 613 | }), | 206 | }), |
| 614 | ); | 207 | ); |
| 615 | vi.stubGlobal("fetch", fetchMock); | 208 | vi.stubGlobal("fetch", fetchMock); |
| 616 | 209 | ||
| 617 | const state = yield* withCloudServices(readPrimaryCloudLinkState()); | 210 | const state = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); |
| 618 | expect(state).toEqual({ | ||
| 619 | linked: true, | ||
| 620 | cloudUserId: "user_123", | ||
| 621 | relayUrl: "https://relay.example.test", | ||
| 622 | relayIssuer: "https://issuer.example.test", | ||
| 623 | publishAgentActivity: false, | ||
| 624 | }); | ||
| 625 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | ||
| 626 | "http://127.0.0.1:3000/api/connect/link-state", | ||
| 627 | ); | ||
| 628 | expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ | ||
| 629 | method: "GET", | ||
| 630 | credentials: "include", | ||
| 631 | }); | ||
| 632 | }), | ||
| 633 | ); | ||
| 634 | |||
| 635 | it.effect("clears local relay credentials before revoking the primary cloud link", () => | ||
| 636 | Effect.gen(function* () { | ||
| 637 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 638 | environmentId: EnvironmentId.make("env-1"), | ||
| 639 | label: "Desktop", | ||
| 640 | platform: { os: "darwin", arch: "arm64" }, | ||
| 641 | serverVersion: "0.0.0-test", | ||
| 642 | capabilities: { repositoryIdentity: true }, | ||
| 643 | }); | ||
| 644 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 645 | source: "desktop-managed", | ||
| 646 | target: { | ||
| 647 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 648 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 649 | }, | ||
| 650 | }); | ||
| 651 | const fetchMock = vi | ||
| 652 | .fn() | ||
| 653 | .mockResolvedValueOnce( | ||
| 654 | Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), | ||
| 655 | ) | ||
| 656 | .mockResolvedValueOnce(Response.json({ ok: true })); | ||
| 657 | vi.stubGlobal("fetch", fetchMock); | ||
| 658 | 211 | ||
| 659 | yield* withCloudServices( | 212 | expect(Option.fromNullishOr(state)).toEqual( |
| 660 | unlinkPrimaryEnvironmentFromCloud({ | 213 | Option.some({ |
| 661 | clerkToken: "clerk-token", | 214 | linked: true, |
| 215 | cloudUserId: "user-1", | ||
| 216 | relayUrl: "https://relay.example.test", | ||
| 217 | relayIssuer: "https://relay.example.test", | ||
| 218 | publishAgentActivity: false, | ||
| 662 | }), | 219 | }), |
| 663 | ); | 220 | ); |
| 664 | 221 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | |
| 665 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); | 222 | "http://127.0.0.1:3000/api/connect/link-state", |
| 666 | expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ | ||
| 667 | method: "POST", | ||
| 668 | credentials: "include", | ||
| 669 | }); | ||
| 670 | expect(String(fetchMock.mock.calls[1]?.[0])).toBe( | ||
| 671 | "https://relay.example.test/v1/client/environment-links/env-1", | ||
| 672 | ); | 223 | ); |
| 673 | expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE"); | ||
| 674 | expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); | ||
| 675 | }), | 224 | }), |
| 676 | ); | 225 | ); |
| 677 | 226 | ||
| 678 | it.effect("still clears local relay credentials when relay revocation fails", () => | 227 | it.effect("links an available primary environment without invoking installation", () => |
| 679 | Effect.gen(function* () { | 228 | Effect.gen(function* () { |
| 680 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 681 | environmentId: EnvironmentId.make("env-1"), | ||
| 682 | label: "Desktop", | ||
| 683 | platform: { os: "darwin", arch: "arm64" }, | ||
| 684 | serverVersion: "0.0.0-test", | ||
| 685 | capabilities: { repositoryIdentity: true }, | ||
| 686 | }); | ||
| 687 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 688 | source: "desktop-managed", | ||
| 689 | target: { | ||
| 690 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 691 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 692 | }, | ||
| 693 | }); | ||
| 694 | const fetchMock = vi | 229 | const fetchMock = vi |
| 695 | .fn() | 230 | .fn() |
| 696 | .mockResolvedValueOnce( | 231 | .mockResolvedValueOnce( |
| 697 | Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), | 232 | Response.json({ |
| 233 | challenge: "challenge", | ||
| 234 | expiresAt: "2026-06-06T00:05:00.000Z", | ||
| 235 | }), | ||
| 698 | ) | 236 | ) |
| 699 | .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })); | 237 | .mockResolvedValueOnce(Response.json("signed-proof")) |
| 700 | vi.stubGlobal("fetch", fetchMock); | ||
| 701 | |||
| 702 | yield* withCloudServices( | ||
| 703 | unlinkPrimaryEnvironmentFromCloud({ | ||
| 704 | clerkToken: "clerk-token", | ||
| 705 | }), | ||
| 706 | ); | ||
| 707 | |||
| 708 | expect(fetchMock).toHaveBeenCalledTimes(2); | ||
| 709 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); | ||
| 710 | expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ | ||
| 711 | method: "POST", | ||
| 712 | credentials: "include", | ||
| 713 | }); | ||
| 714 | }), | ||
| 715 | ); | ||
| 716 | |||
| 717 | it.effect("rejects primary environment linking when the local environment is not ready", () => | ||
| 718 | Effect.gen(function* () { | ||
| 719 | vi.stubGlobal("fetch", vi.fn()); | ||
| 720 | |||
| 721 | const error = yield* withCloudServices( | ||
| 722 | linkPrimaryEnvironmentToCloud({ | ||
| 723 | clerkToken: "clerk-token", | ||
| 724 | }), | ||
| 725 | ).pipe(Effect.flip); | ||
| 726 | expect(error).toMatchObject({ | ||
| 727 | _tag: "CloudEnvironmentLinkError", | ||
| 728 | message: "Local environment is not ready yet.", | ||
| 729 | }); | ||
| 730 | expect(fetch).not.toHaveBeenCalled(); | ||
| 731 | }), | ||
| 732 | ); | ||
| 733 | |||
| 734 | it.effect("preserves relay transport failures while linking environments", () => | ||
| 735 | Effect.gen(function* () { | ||
| 736 | vi.stubGlobal( | ||
| 737 | "fetch", | ||
| 738 | vi | ||
| 739 | .fn() | ||
| 740 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 741 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 742 | .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })), | ||
| 743 | ); | ||
| 744 | |||
| 745 | const error = yield* withCloudServices( | ||
| 746 | linkEnvironmentToCloud({ | ||
| 747 | environment: savedEnvironment, | ||
| 748 | clerkToken: "clerk-token", | ||
| 749 | }), | ||
| 750 | ).pipe(Effect.flip); | ||
| 751 | expect(error).toMatchObject({ | ||
| 752 | _tag: "CloudEnvironmentLinkError", | ||
| 753 | message: "https://relay.example.test/v1/client/environment-links failed", | ||
| 754 | }); | ||
| 755 | }), | ||
| 756 | ); | ||
| 757 | |||
| 758 | it.effect("preserves typed relay error bodies while linking environments", () => | ||
| 759 | Effect.gen(function* () { | ||
| 760 | vi.stubGlobal( | ||
| 761 | "fetch", | ||
| 762 | vi | ||
| 763 | .fn() | ||
| 764 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 765 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 766 | .mockResolvedValueOnce( | ||
| 767 | Response.json( | ||
| 768 | { | ||
| 769 | _tag: "RelayEnvironmentLinkProofInvalidError", | ||
| 770 | code: "environment_link_proof_invalid", | ||
| 771 | reason: "origin_not_allowed", | ||
| 772 | traceId: "trace-test", | ||
| 773 | }, | ||
| 774 | { status: 400 }, | ||
| 775 | ), | ||
| 776 | ), | ||
| 777 | ); | ||
| 778 | |||
| 779 | const error = yield* withCloudServices( | ||
| 780 | linkEnvironmentToCloud({ | ||
| 781 | environment: savedEnvironment, | ||
| 782 | clerkToken: "clerk-token", | ||
| 783 | }), | ||
| 784 | ).pipe(Effect.flip); | ||
| 785 | expect(error).toMatchObject({ | ||
| 786 | _tag: "CloudEnvironmentLinkError", | ||
| 787 | message: | ||
| 788 | "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", | ||
| 789 | }); | ||
| 790 | }), | ||
| 791 | ); | ||
| 792 | |||
| 793 | it.effect("rejects relay credentials for a different environment", () => | ||
| 794 | Effect.gen(function* () { | ||
| 795 | const fetchMock = vi | ||
| 796 | .fn() | ||
| 797 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 798 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 799 | .mockResolvedValueOnce( | 238 | .mockResolvedValueOnce( |
| 800 | Response.json({ | 239 | Response.json({ |
| 801 | ok: true, | 240 | ok: true, |
| 802 | environmentId: "env-2", | 241 | environmentId: TARGET.environmentId, |
| 803 | endpoint: { | 242 | endpoint: { |
| 804 | httpBaseUrl: "https://desktop.example.test", | 243 | httpBaseUrl: "https://desktop.example.test", |
| 805 | wsBaseUrl: "wss://desktop.example.test", | 244 | wsBaseUrl: "wss://desktop.example.test", |
| 806 | providerKind: "cloudflare_tunnel", | 245 | providerKind: "cloudflare_tunnel", |
| 807 | }, | 246 | }, |
| 808 | endpointRuntime: null, | 247 | endpointRuntime: null, |
| 809 | relayIssuer: "https://issuer.example.test", | 248 | relayIssuer: "https://relay.example.test", |
| 810 | cloudUserId: "user_123", | 249 | cloudUserId: "user-1", |
| 811 | environmentCredential: "t3env_test_credential", | 250 | environmentCredential: "environment-credential", |
| 812 | cloudMintPublicKey: "cloud-mint-public-key", | 251 | cloudMintPublicKey: "public-key", |
| 813 | }), | 252 | }), |
| 253 | ) | ||
| 254 | .mockResolvedValueOnce( | ||
| 255 | Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), | ||
| 814 | ); | 256 | ); |
| 815 | vi.stubGlobal("fetch", fetchMock); | 257 | vi.stubGlobal("fetch", fetchMock); |
| 816 | 258 | ||
| 817 | const error = yield* withCloudServices( | 259 | yield* withServices( |
| 818 | linkEnvironmentToCloud({ | 260 | linkPrimaryEnvironmentToCloud({ |
| 819 | environment: savedEnvironment, | 261 | target: TARGET, |
| 820 | clerkToken: "clerk-token", | 262 | clerkToken: "clerk-token", |
| 821 | }), | 263 | }), |
| 822 | ).pipe(Effect.flip); | 264 | ); |
| 823 | expect(error).toMatchObject({ | 265 | |
| 824 | _tag: "CloudEnvironmentLinkError", | 266 | expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); |
| 825 | message: "Relay returned credentials for a different environment.", | 267 | expect(String(fetchMock.mock.calls[1]?.[0])).toBe( |
| 268 | "http://127.0.0.1:3000/api/connect/link-proof", | ||
| 269 | ); | ||
| 270 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 271 | expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ | ||
| 272 | challenge: "challenge", | ||
| 273 | endpoint: { | ||
| 274 | httpBaseUrl: TARGET.httpBaseUrl, | ||
| 275 | wsBaseUrl: TARGET.wsBaseUrl, | ||
| 276 | }, | ||
| 826 | }); | 277 | }); |
| 827 | expect(fetchMock).toHaveBeenCalledTimes(3); | ||
| 828 | }), | 278 | }), |
| 829 | ); | 279 | ); |
| 830 | 280 | ||
| 831 | it.effect("rejects relay credentials for a different managed endpoint provider", () => | 281 | it.effect("installs a missing relay client before linking", () => |
| 832 | Effect.gen(function* () { | 282 | Effect.gen(function* () { |
| 833 | const fetchMock = vi | 283 | vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); |
| 834 | .fn() | ||
| 835 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 836 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 837 | .mockResolvedValueOnce( | ||
| 838 | Response.json({ | ||
| 839 | ok: true, | ||
| 840 | environmentId: "env-1", | ||
| 841 | endpoint: { | ||
| 842 | httpBaseUrl: "https://desktop.example.test", | ||
| 843 | wsBaseUrl: "wss://desktop.example.test", | ||
| 844 | providerKind: "manual", | ||
| 845 | }, | ||
| 846 | endpointRuntime: null, | ||
| 847 | relayIssuer: "https://issuer.example.test", | ||
| 848 | cloudUserId: "user_123", | ||
| 849 | environmentCredential: "t3env_test_credential", | ||
| 850 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 851 | }), | ||
| 852 | ); | ||
| 853 | vi.stubGlobal("fetch", fetchMock); | ||
| 854 | 284 | ||
| 855 | const error = yield* withCloudServices( | 285 | yield* withServices( |
| 856 | linkEnvironmentToCloud({ | 286 | linkPrimaryEnvironmentToCloud({ |
| 857 | environment: savedEnvironment, | 287 | target: TARGET, |
| 858 | clerkToken: "clerk-token", | 288 | clerkToken: "clerk-token", |
| 859 | }), | 289 | }), |
| 290 | { | ||
| 291 | status: { status: "available", version: "2026.6.0" }, | ||
| 292 | installEvents: [], | ||
| 293 | }, | ||
| 860 | ).pipe(Effect.flip); | 294 | ).pipe(Effect.flip); |
| 861 | expect(error).toMatchObject({ | 295 | |
| 862 | _tag: "CloudEnvironmentLinkError", | 296 | expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); |
| 863 | message: "Relay returned credentials for a different endpoint provider.", | ||
| 864 | }); | ||
| 865 | expect(fetchMock).toHaveBeenCalledTimes(3); | ||
| 866 | }), | 297 | }), |
| 867 | ); | 298 | ); |
| 868 | 299 | ||
| 869 | it.effect("passes the relay issuer from the link response into local relay config", () => | 300 | it.effect("unlinks locally before revoking the relay record", () => |
| 870 | Effect.gen(function* () { | 301 | Effect.gen(function* () { |
| 871 | const fetchMock = vi | 302 | const fetchMock = vi |
| 872 | .fn() | 303 | .fn() |
| 873 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 874 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 875 | .mockResolvedValueOnce( | ||
| 876 | Response.json({ | ||
| 877 | ok: true, | ||
| 878 | environmentId: "env-1", | ||
| 879 | endpoint: { | ||
| 880 | httpBaseUrl: "https://desktop.example.test", | ||
| 881 | wsBaseUrl: "wss://desktop.example.test", | ||
| 882 | providerKind: "cloudflare_tunnel", | ||
| 883 | }, | ||
| 884 | endpointRuntime: null, | ||
| 885 | relayIssuer: "https://issuer.example.test", | ||
| 886 | cloudUserId: "user_123", | ||
| 887 | environmentCredential: "t3env_test_credential", | ||
| 888 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 889 | }), | ||
| 890 | ) | ||
| 891 | .mockResolvedValueOnce( | 304 | .mockResolvedValueOnce( |
| 892 | Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), | 305 | Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), |
| 893 | ); | 306 | ) |
| 307 | .mockResolvedValueOnce(Response.json({ ok: true })); | ||
| 894 | vi.stubGlobal("fetch", fetchMock); | 308 | vi.stubGlobal("fetch", fetchMock); |
| 895 | 309 | ||
| 896 | yield* withCloudServices( | 310 | yield* withServices( |
| 897 | linkEnvironmentToCloud({ | 311 | unlinkPrimaryEnvironmentFromCloud({ |
| 898 | environment: savedEnvironment, | 312 | target: TARGET, |
| 899 | clerkToken: "clerk-token", | 313 | clerkToken: "clerk-token", |
| 900 | }), | 314 | }), |
| 901 | ); | 315 | ); |
| 902 | 316 | ||
| 903 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 317 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); |
| 904 | expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ | 318 | expect(String(fetchMock.mock.calls[1]?.[0])).toContain( |
| 905 | relayUrl: "https://relay.example.test", | 319 | `/v1/client/environment-links/${TARGET.environmentId}`, |
| 906 | relayIssuer: "https://issuer.example.test", | 320 | ); |
| 907 | cloudUserId: "user_123", | ||
| 908 | }); | ||
| 909 | }), | 321 | }), |
| 910 | ); | 322 | ); |
| 911 | }); | 323 | }); |
apps/web/src/cloud/linkEnvironment.ts+62-308| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | import * as Data from "effect/Data"; | 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; | ||
| 3 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 4 | import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http"; | 5 | import * as Stream from "effect/Stream"; |
| 6 | import { HttpClient } from "effect/unstable/http"; | ||
| 5 | import { | 7 | import { |
| 6 | EnvironmentCloudEndpointUnavailableError, | 8 | EnvironmentCloudEndpointUnavailableError, |
| 7 | type EnvironmentCloudLinkStateResult, | 9 | type EnvironmentCloudLinkStateResult, |
| ... | @@ -11,36 +13,23 @@ import { | ... | @@ -11,36 +13,23 @@ import { |
| 11 | EnvironmentHttpInternalServerError, | 13 | EnvironmentHttpInternalServerError, |
| 12 | EnvironmentHttpUnauthorizedError, | 14 | EnvironmentHttpUnauthorizedError, |
| 13 | EnvironmentId, | 15 | EnvironmentId, |
| 16 | WS_METHODS, | ||
| 14 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 15 | import { | 18 | import { |
| 16 | RelayEnvironmentConnectScope, | ||
| 17 | type RelayClientDeviceRecord, | 19 | type RelayClientDeviceRecord, |
| 18 | type RelayEnvironmentLinkResponse, | ||
| 19 | RelayProtectedError, | ||
| 20 | type RelayClientEnvironmentRecord, | 20 | type RelayClientEnvironmentRecord, |
| 21 | type RelayEnvironmentLinkResponse, | ||
| 21 | type RelayProtectedError as RelayProtectedErrorType, | 22 | type RelayProtectedError as RelayProtectedErrorType, |
| 22 | type RelayManagedEndpointProviderKind, | 23 | type RelayManagedEndpointProviderKind, |
| 23 | } from "@t3tools/contracts/relay"; | 24 | } from "@t3tools/contracts/relay"; |
| 24 | import { | 25 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; |
| 25 | exchangeRemoteDpopAccessToken, | 26 | import { request, runStream } from "@t3tools/client-runtime/rpc"; |
| 26 | fetchRemoteEnvironmentDescriptor, | 27 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; |
| 27 | makeEnvironmentHttpApiClient, | 28 | import { ManagedRelayClient, type ManagedRelayClientError } from "@t3tools/client-runtime/relay"; |
| 28 | ManagedRelayClient, | ||
| 29 | ManagedRelayDpopSigner, | ||
| 30 | type WsRpcClient, | ||
| 31 | } from "@t3tools/client-runtime"; | ||
| 32 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 33 | 29 | ||
| 34 | import { ensureLocalApi } from "../localApi"; | ||
| 35 | import { | ||
| 36 | getPrimaryEnvironmentConnection, | ||
| 37 | readEnvironmentConnection, | ||
| 38 | type SavedEnvironmentRecord, | ||
| 39 | } from "../environments/runtime"; | ||
| 40 | import { | 30 | import { |
| 41 | readPrimaryEnvironmentDescriptor, | 31 | readPrimaryEnvironmentDescriptor, |
| 42 | readPrimaryEnvironmentTarget, | 32 | readPrimaryEnvironmentTarget, |
| 43 | resolvePrimaryEnvironmentHttpUrl, | ||
| 44 | } from "../environments/primary"; | 33 | } from "../environments/primary"; |
| 45 | import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; | 34 | import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; |
| 46 | import { resolveCloudPublicConfig } from "./publicConfig"; | 35 | import { resolveCloudPublicConfig } from "./publicConfig"; |
| ... | @@ -65,6 +54,7 @@ function relayUrl(): string | null { | ... | @@ -65,6 +54,7 @@ function relayUrl(): string | null { |
| 65 | export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ | 54 | export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ |
| 66 | readonly message: string; | 55 | readonly message: string; |
| 67 | readonly cause?: unknown; | 56 | readonly cause?: unknown; |
| 57 | readonly traceId?: string; | ||
| 68 | }> {} | 58 | }> {} |
| 69 | 59 | ||
| 70 | const relayClientRpcError = (message: string) => (cause: unknown) => | 60 | const relayClientRpcError = (message: string) => (cause: unknown) => |
| ... | @@ -74,13 +64,13 @@ const relayClientRpcError = (message: string) => (cause: unknown) => | ... | @@ -74,13 +64,13 @@ const relayClientRpcError = (message: string) => (cause: unknown) => |
| 74 | }); | 64 | }); |
| 75 | 65 | ||
| 76 | function ensureRelayClientAvailable( | 66 | function ensureRelayClientAvailable( |
| 77 | client: WsRpcClient, | 67 | environmentId: EnvironmentId, |
| 78 | ): Effect.Effect<void, CloudEnvironmentLinkError> { | 68 | ): Effect.Effect<void, CloudEnvironmentLinkError, EnvironmentRegistry> { |
| 79 | return Effect.gen(function* () { | 69 | return Effect.gen(function* () { |
| 80 | const status = yield* Effect.tryPromise({ | 70 | const registry = yield* EnvironmentRegistry; |
| 81 | try: () => client.cloud.getRelayClientStatus(), | 71 | const status = yield* registry |
| 82 | catch: relayClientRpcError("Could not check relay client availability."), | 72 | .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) |
| 83 | }); | 73 | .pipe(Effect.mapError(relayClientRpcError("Could not check relay client availability."))); |
| 84 | if (status.status === "available") return; | 74 | if (status.status === "available") return; |
| 85 | if (status.status === "unsupported") { | 75 | if (status.status === "unsupported") { |
| 86 | return yield* new CloudEnvironmentLinkError({ | 76 | return yield* new CloudEnvironmentLinkError({ |
| ... | @@ -98,22 +88,35 @@ function ensureRelayClientAvailable( | ... | @@ -98,22 +88,35 @@ function ensureRelayClientAvailable( |
| 98 | }); | 88 | }); |
| 99 | } | 89 | } |
| 100 | 90 | ||
| 101 | const installed = yield* Effect.tryPromise({ | 91 | const installed = yield* registry |
| 102 | try: () => client.cloud.installRelayClient(reportRelayClientInstallProgress), | 92 | .runStream( |
| 103 | catch: relayClientRpcError("Could not install the relay client."), | 93 | environmentId, |
| 104 | }).pipe(Effect.ensuring(Effect.sync(finishRelayClientInstall))); | 94 | runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( |
| 105 | if (installed.status !== "available") { | 95 | Stream.tap((event) => Effect.sync(() => reportRelayClientInstallProgress(event))), |
| 96 | ), | ||
| 97 | ) | ||
| 98 | .pipe( | ||
| 99 | Stream.runLast, | ||
| 100 | Effect.mapError(relayClientRpcError("Could not install the relay client.")), | ||
| 101 | Effect.ensuring(Effect.sync(finishRelayClientInstall)), | ||
| 102 | ); | ||
| 103 | if (Option.isNone(installed) || installed.value.type !== "complete") { | ||
| 104 | return yield* new CloudEnvironmentLinkError({ | ||
| 105 | message: "The relay client install completed without a final status.", | ||
| 106 | }); | ||
| 107 | } | ||
| 108 | const installedStatus = installed.value.status; | ||
| 109 | if (installedStatus.status !== "available") { | ||
| 106 | return yield* new CloudEnvironmentLinkError({ | 110 | return yield* new CloudEnvironmentLinkError({ |
| 107 | message: | 111 | message: |
| 108 | installed.status === "unsupported" | 112 | installedStatus.status === "unsupported" |
| 109 | ? `T3 Code cannot install the relay client automatically on ${installed.platform}-${installed.arch}.` | 113 | ? `T3 Code cannot install the relay client automatically on ${installedStatus.platform}-${installedStatus.arch}.` |
| 110 | : "The relay client is still unavailable after installation.", | 114 | : "The relay client is still unavailable after installation.", |
| 111 | }); | 115 | }); |
| 112 | } | 116 | } |
| 113 | }); | 117 | }); |
| 114 | } | 118 | } |
| 115 | 119 | ||
| 116 | const isRelayProtectedError = Schema.is(RelayProtectedError); | ||
| 117 | const isEnvironmentCloudApiError = Schema.is( | 120 | const isEnvironmentCloudApiError = Schema.is( |
| 118 | Schema.Union([ | 121 | Schema.Union([ |
| 119 | EnvironmentHttpBadRequestError, | 122 | EnvironmentHttpBadRequestError, |
| ... | @@ -156,31 +159,22 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { | ... | @@ -156,31 +159,22 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { |
| 156 | case "RelayAgentActivityPublishProofInvalidError": | 159 | case "RelayAgentActivityPublishProofInvalidError": |
| 157 | return `Relay rejected the agent activity publish proof (${error.reason}).`; | 160 | return `Relay rejected the agent activity publish proof (${error.reason}).`; |
| 158 | case "RelayInternalError": | 161 | case "RelayInternalError": |
| 159 | return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; | 162 | return `Relay encountered an internal error (${error.reason}).`; |
| 160 | } | 163 | } |
| 161 | } | 164 | } |
| 162 | 165 | ||
| 163 | function decodedRelayClientError(message: string) { | 166 | function decodedRelayClientError(message: string) { |
| 164 | return (cause: unknown) => { | 167 | return (cause: ManagedRelayClientError) => { |
| 165 | const relayError = findRelayProtectedError(cause); | 168 | const relayError = cause.relayError; |
| 166 | const detail = relayError ? relayProtectedErrorMessage(relayError) : null; | 169 | const detail = relayError ? relayProtectedErrorMessage(relayError) : null; |
| 167 | return new CloudEnvironmentLinkError({ | 170 | return new CloudEnvironmentLinkError({ |
| 168 | message: detail ? `${message}: ${detail}` : message, | 171 | message: detail ? `${message}: ${detail}` : message, |
| 169 | cause, | 172 | cause, |
| 173 | ...(cause.traceId ? { traceId: cause.traceId } : {}), | ||
| 170 | }); | 174 | }); |
| 171 | }; | 175 | }; |
| 172 | } | 176 | } |
| 173 | 177 | ||
| 174 | function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { | ||
| 175 | if (isRelayProtectedError(cause)) { | ||
| 176 | return cause; | ||
| 177 | } | ||
| 178 | if (typeof cause !== "object" || cause === null) { | ||
| 179 | return null; | ||
| 180 | } | ||
| 181 | return "cause" in cause ? findRelayProtectedError(cause.cause) : null; | ||
| 182 | } | ||
| 183 | |||
| 184 | function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { | 178 | function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { |
| 185 | if (isEnvironmentCloudApiError(cause)) { | 179 | if (isEnvironmentCloudApiError(cause)) { |
| 186 | return cause; | 180 | return cause; |
| ... | @@ -239,16 +233,6 @@ export interface CloudLinkTarget { | ... | @@ -239,16 +233,6 @@ export interface CloudLinkTarget { |
| 239 | 233 | ||
| 240 | export type CloudLinkState = EnvironmentCloudLinkStateResult; | 234 | export type CloudLinkState = EnvironmentCloudLinkStateResult; |
| 241 | 235 | ||
| 242 | export interface CloudManagedConnection { | ||
| 243 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 244 | readonly label: string; | ||
| 245 | readonly httpBaseUrl: string; | ||
| 246 | readonly wsBaseUrl: string; | ||
| 247 | readonly relayUrl: string; | ||
| 248 | readonly accessToken: string; | ||
| 249 | readonly relayTraceHeaders: Headers.Headers; | ||
| 250 | } | ||
| 251 | |||
| 252 | export function collectCloudLinkTargets(input: { | 236 | export function collectCloudLinkTargets(input: { |
| 253 | readonly primary: CloudLinkTarget | null; | 237 | readonly primary: CloudLinkTarget | null; |
| 254 | readonly saved: ReadonlyArray<CloudLinkTarget>; | 238 | readonly saved: ReadonlyArray<CloudLinkTarget>; |
| ... | @@ -336,130 +320,11 @@ export function listCloudDevices(input: { | ... | @@ -336,130 +320,11 @@ export function listCloudDevices(input: { |
| 336 | }); | 320 | }); |
| 337 | } | 321 | } |
| 338 | 322 | ||
| 339 | export function connectManagedCloudEnvironment(input: { | 323 | export function readPrimaryCloudLinkState(input: { |
| 340 | readonly clerkToken: string; | 324 | readonly target: CloudLinkTarget; |
| 341 | readonly environment: RelayClientEnvironmentRecord; | 325 | }): Effect.Effect<CloudLinkState | null, CloudEnvironmentLinkError, HttpClient.HttpClient> { |
| 342 | readonly relayUrl?: string; | ||
| 343 | }): Effect.Effect< | ||
| 344 | CloudManagedConnection, | ||
| 345 | CloudEnvironmentLinkError, | ||
| 346 | HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner | ||
| 347 | > { | ||
| 348 | return Effect.gen(function* () { | ||
| 349 | const configuredRelayUrl = relayUrl(); | ||
| 350 | if (!configuredRelayUrl) { | ||
| 351 | return yield* new CloudEnvironmentLinkError({ | ||
| 352 | message: "T3CODE_RELAY_URL is not configured.", | ||
| 353 | }); | ||
| 354 | } | ||
| 355 | const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); | ||
| 356 | if (persistedRelayUrl && persistedRelayUrl !== configuredRelayUrl) { | ||
| 357 | return yield* new CloudEnvironmentLinkError({ | ||
| 358 | message: "The saved environment is linked through a different configured relay.", | ||
| 359 | }); | ||
| 360 | } | ||
| 361 | const relayClient = yield* ManagedRelayClient; | ||
| 362 | const connected = yield* relayClient | ||
| 363 | .connectEnvironment({ | ||
| 364 | clerkToken: input.clerkToken, | ||
| 365 | scopes: [RelayEnvironmentConnectScope], | ||
| 366 | environmentId: input.environment.environmentId, | ||
| 367 | }) | ||
| 368 | .pipe( | ||
| 369 | Effect.mapError( | ||
| 370 | (cause) => | ||
| 371 | new CloudEnvironmentLinkError({ | ||
| 372 | message: "Could not connect to relay-managed environment.", | ||
| 373 | cause, | ||
| 374 | }), | ||
| 375 | ), | ||
| 376 | ); | ||
| 377 | if (connected.environmentId !== input.environment.environmentId) { | ||
| 378 | return yield* new CloudEnvironmentLinkError({ | ||
| 379 | message: "Relay returned credentials for a different environment.", | ||
| 380 | }); | ||
| 381 | } | ||
| 382 | if ( | ||
| 383 | connected.endpoint.httpBaseUrl !== input.environment.endpoint.httpBaseUrl || | ||
| 384 | connected.endpoint.wsBaseUrl !== input.environment.endpoint.wsBaseUrl || | ||
| 385 | connected.endpoint.providerKind !== input.environment.endpoint.providerKind | ||
| 386 | ) { | ||
| 387 | return yield* new CloudEnvironmentLinkError({ | ||
| 388 | message: "Relay returned credentials for a different endpoint.", | ||
| 389 | }); | ||
| 390 | } | ||
| 391 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 392 | httpBaseUrl: connected.endpoint.httpBaseUrl, | ||
| 393 | }).pipe( | ||
| 394 | Effect.mapError( | ||
| 395 | (cause) => | ||
| 396 | new CloudEnvironmentLinkError({ | ||
| 397 | message: "Could not read connected environment descriptor.", | ||
| 398 | cause, | ||
| 399 | }), | ||
| 400 | ), | ||
| 401 | ); | ||
| 402 | if (descriptor.environmentId !== connected.environmentId) { | ||
| 403 | return yield* new CloudEnvironmentLinkError({ | ||
| 404 | message: "Connected endpoint does not match the selected environment.", | ||
| 405 | }); | ||
| 406 | } | ||
| 407 | const signer = yield* ManagedRelayDpopSigner; | ||
| 408 | const bootstrapProof = yield* signer | ||
| 409 | .createProof({ | ||
| 410 | method: "POST", | ||
| 411 | url: new URL("/oauth/token", connected.endpoint.httpBaseUrl).toString(), | ||
| 412 | }) | ||
| 413 | .pipe( | ||
| 414 | Effect.mapError( | ||
| 415 | (cause) => | ||
| 416 | new CloudEnvironmentLinkError({ | ||
| 417 | message: "Could not create environment DPoP proof.", | ||
| 418 | cause, | ||
| 419 | }), | ||
| 420 | ), | ||
| 421 | ); | ||
| 422 | const session = yield* exchangeRemoteDpopAccessToken({ | ||
| 423 | httpBaseUrl: connected.endpoint.httpBaseUrl, | ||
| 424 | credential: connected.credential, | ||
| 425 | dpopProof: bootstrapProof, | ||
| 426 | }).pipe( | ||
| 427 | Effect.mapError( | ||
| 428 | (cause) => | ||
| 429 | new CloudEnvironmentLinkError({ | ||
| 430 | message: "Could not authorize managed environment.", | ||
| 431 | cause, | ||
| 432 | }), | ||
| 433 | ), | ||
| 434 | ); | ||
| 435 | return { | ||
| 436 | environmentId: descriptor.environmentId, | ||
| 437 | label: descriptor.label, | ||
| 438 | httpBaseUrl: connected.endpoint.httpBaseUrl, | ||
| 439 | wsBaseUrl: connected.endpoint.wsBaseUrl, | ||
| 440 | relayUrl: configuredRelayUrl, | ||
| 441 | accessToken: session.access_token, | ||
| 442 | relayTraceHeaders: HttpTraceContext.toHeaders(yield* Effect.currentSpan.pipe(Effect.orDie)), | ||
| 443 | }; | ||
| 444 | }).pipe( | ||
| 445 | Effect.withSpan("relay.environment.connect", { | ||
| 446 | root: true, | ||
| 447 | attributes: { "relay.environment_id": input.environment.environmentId }, | ||
| 448 | }), | ||
| 449 | withRelayClientTracing, | ||
| 450 | ); | ||
| 451 | } | ||
| 452 | |||
| 453 | export function readPrimaryCloudLinkState(): Effect.Effect< | ||
| 454 | CloudLinkState | null, | ||
| 455 | CloudEnvironmentLinkError, | ||
| 456 | HttpClient.HttpClient | ||
| 457 | > { | ||
| 458 | return Effect.gen(function* () { | 326 | return Effect.gen(function* () { |
| 459 | if (!readPrimaryCloudLinkTarget()) { | 327 | const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); |
| 460 | return null; | ||
| 461 | } | ||
| 462 | const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); | ||
| 463 | return yield* client.connect | 328 | return yield* client.connect |
| 464 | .linkState({ headers: {} }) | 329 | .linkState({ headers: {} }) |
| 465 | .pipe( | 330 | .pipe( |
| ... | @@ -470,10 +335,11 @@ export function readPrimaryCloudLinkState(): Effect.Effect< | ... | @@ -470,10 +335,11 @@ export function readPrimaryCloudLinkState(): Effect.Effect< |
| 470 | } | 335 | } |
| 471 | 336 | ||
| 472 | export function updatePrimaryCloudPreferences(input: { | 337 | export function updatePrimaryCloudPreferences(input: { |
| 338 | readonly target: CloudLinkTarget; | ||
| 473 | readonly publishAgentActivity: boolean; | 339 | readonly publishAgentActivity: boolean; |
| 474 | }): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> { | 340 | }): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> { |
| 475 | return Effect.gen(function* () { | 341 | return Effect.gen(function* () { |
| 476 | const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); | 342 | const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); |
| 477 | return yield* client.connect | 343 | return yield* client.connect |
| 478 | .preferences({ | 344 | .preferences({ |
| 479 | headers: {}, | 345 | headers: {}, |
| ... | @@ -487,16 +353,11 @@ export function updatePrimaryCloudPreferences(input: { | ... | @@ -487,16 +353,11 @@ export function updatePrimaryCloudPreferences(input: { |
| 487 | } | 353 | } |
| 488 | 354 | ||
| 489 | export function unlinkPrimaryEnvironmentFromCloud(input: { | 355 | export function unlinkPrimaryEnvironmentFromCloud(input: { |
| 356 | readonly target: CloudLinkTarget; | ||
| 490 | readonly clerkToken: string | null; | 357 | readonly clerkToken: string | null; |
| 491 | }): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> { | 358 | }): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> { |
| 492 | return Effect.gen(function* () { | 359 | return Effect.gen(function* () { |
| 493 | const target = readPrimaryCloudLinkTarget(); | 360 | const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); |
| 494 | if (!target) { | ||
| 495 | return yield* new CloudEnvironmentLinkError({ | ||
| 496 | message: "Local environment is not ready yet.", | ||
| 497 | }); | ||
| 498 | } | ||
| 499 | const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); | ||
| 500 | yield* client.connect | 361 | yield* client.connect |
| 501 | .unlink({ headers: {} }) | 362 | .unlink({ headers: {} }) |
| 502 | .pipe( | 363 | .pipe( |
| ... | @@ -510,7 +371,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { | ... | @@ -510,7 +371,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { |
| 510 | yield* relayClient | 371 | yield* relayClient |
| 511 | .unlinkEnvironment({ | 372 | .unlinkEnvironment({ |
| 512 | clerkToken: input.clerkToken, | 373 | clerkToken: input.clerkToken, |
| 513 | environmentId: EnvironmentId.make(target.environmentId), | 374 | environmentId: EnvironmentId.make(input.target.environmentId), |
| 514 | }) | 375 | }) |
| 515 | .pipe( | 376 | .pipe( |
| 516 | Effect.catch((cause) => | 377 | Effect.catch((cause) => |
| ... | @@ -523,115 +384,14 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { | ... | @@ -523,115 +384,14 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { |
| 523 | }); | 384 | }); |
| 524 | } | 385 | } |
| 525 | 386 | ||
| 526 | export function linkEnvironmentToCloud(input: { | ||
| 527 | readonly environment: SavedEnvironmentRecord; | ||
| 528 | readonly clerkToken: string; | ||
| 529 | }): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> { | ||
| 530 | return Effect.gen(function* () { | ||
| 531 | const configuredRelayUrl = relayUrl(); | ||
| 532 | if (!configuredRelayUrl) { | ||
| 533 | return yield* new CloudEnvironmentLinkError({ | ||
| 534 | message: "T3CODE_RELAY_URL is not configured.", | ||
| 535 | }); | ||
| 536 | } | ||
| 537 | const relayClient = yield* ManagedRelayClient; | ||
| 538 | const bearerToken = yield* Effect.tryPromise({ | ||
| 539 | try: () => | ||
| 540 | ensureLocalApi().persistence.getSavedEnvironmentSecret(input.environment.environmentId), | ||
| 541 | catch: (cause) => | ||
| 542 | new CloudEnvironmentLinkError({ | ||
| 543 | message: `Could not read saved bearer token for ${input.environment.label}.`, | ||
| 544 | cause, | ||
| 545 | }), | ||
| 546 | }); | ||
| 547 | if (!bearerToken) { | ||
| 548 | return yield* new CloudEnvironmentLinkError({ | ||
| 549 | message: `No saved bearer token for ${input.environment.label}.`, | ||
| 550 | }); | ||
| 551 | } | ||
| 552 | |||
| 553 | const connection = readEnvironmentConnection(input.environment.environmentId); | ||
| 554 | if (!connection) { | ||
| 555 | return yield* new CloudEnvironmentLinkError({ | ||
| 556 | message: `${input.environment.label} is not connected.`, | ||
| 557 | }); | ||
| 558 | } | ||
| 559 | yield* ensureRelayClientAvailable(connection.client); | ||
| 560 | |||
| 561 | const environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl); | ||
| 562 | const headers = { authorization: `Bearer ${bearerToken}` }; | ||
| 563 | |||
| 564 | const challenge = yield* relayClient | ||
| 565 | .createEnvironmentLinkChallenge({ | ||
| 566 | clerkToken: input.clerkToken, | ||
| 567 | payload: { | ||
| 568 | notificationsEnabled: true, | ||
| 569 | liveActivitiesEnabled: true, | ||
| 570 | managedTunnelsEnabled: true, | ||
| 571 | }, | ||
| 572 | }) | ||
| 573 | .pipe( | ||
| 574 | Effect.mapError( | ||
| 575 | decodedRelayClientError( | ||
| 576 | `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, | ||
| 577 | ), | ||
| 578 | ), | ||
| 579 | ); | ||
| 580 | const proof = yield* environmentClient.connect | ||
| 581 | .linkProof({ | ||
| 582 | headers, | ||
| 583 | payload: { | ||
| 584 | challenge: challenge.challenge, | ||
| 585 | relayIssuer: configuredRelayUrl, | ||
| 586 | endpoint: { | ||
| 587 | httpBaseUrl: input.environment.httpBaseUrl, | ||
| 588 | wsBaseUrl: input.environment.wsBaseUrl, | ||
| 589 | providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, | ||
| 590 | }, | ||
| 591 | origin: endpointOrigin(input.environment.httpBaseUrl), | ||
| 592 | }, | ||
| 593 | }) | ||
| 594 | .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); | ||
| 595 | const link = yield* relayClient | ||
| 596 | .linkEnvironment({ | ||
| 597 | clerkToken: input.clerkToken, | ||
| 598 | payload: { | ||
| 599 | proof, | ||
| 600 | notificationsEnabled: true, | ||
| 601 | liveActivitiesEnabled: true, | ||
| 602 | managedTunnelsEnabled: true, | ||
| 603 | }, | ||
| 604 | }) | ||
| 605 | .pipe( | ||
| 606 | Effect.mapError( | ||
| 607 | decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), | ||
| 608 | ), | ||
| 609 | ); | ||
| 610 | yield* ensureLinkedEnvironmentMatches({ | ||
| 611 | expectedEnvironmentId: input.environment.environmentId, | ||
| 612 | expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, | ||
| 613 | link, | ||
| 614 | }); | ||
| 615 | |||
| 616 | yield* environmentClient.connect | ||
| 617 | .relayConfig({ | ||
| 618 | headers, | ||
| 619 | payload: { | ||
| 620 | relayUrl: configuredRelayUrl, | ||
| 621 | relayIssuer: link.relayIssuer, | ||
| 622 | cloudUserId: link.cloudUserId, | ||
| 623 | environmentCredential: link.environmentCredential, | ||
| 624 | cloudMintPublicKey: link.cloudMintPublicKey, | ||
| 625 | endpointRuntime: link.endpointRuntime, | ||
| 626 | }, | ||
| 627 | }) | ||
| 628 | .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); | ||
| 629 | }); | ||
| 630 | } | ||
| 631 | |||
| 632 | export function linkPrimaryEnvironmentToCloud(input: { | 387 | export function linkPrimaryEnvironmentToCloud(input: { |
| 388 | readonly target: CloudLinkTarget; | ||
| 633 | readonly clerkToken: string; | 389 | readonly clerkToken: string; |
| 634 | }): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> { | 390 | }): Effect.Effect< |
| 391 | void, | ||
| 392 | CloudEnvironmentLinkError, | ||
| 393 | EnvironmentRegistry | HttpClient.HttpClient | ManagedRelayClient | ||
| 394 | > { | ||
| 635 | return Effect.gen(function* () { | 395 | return Effect.gen(function* () { |
| 636 | const configuredRelayUrl = relayUrl(); | 396 | const configuredRelayUrl = relayUrl(); |
| 637 | if (!configuredRelayUrl) { | 397 | if (!configuredRelayUrl) { |
| ... | @@ -640,14 +400,8 @@ export function linkPrimaryEnvironmentToCloud(input: { | ... | @@ -640,14 +400,8 @@ export function linkPrimaryEnvironmentToCloud(input: { |
| 640 | }); | 400 | }); |
| 641 | } | 401 | } |
| 642 | const relayClient = yield* ManagedRelayClient; | 402 | const relayClient = yield* ManagedRelayClient; |
| 643 | const target = readPrimaryCloudLinkTarget(); | 403 | const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); |
| 644 | if (!target) { | 404 | yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); |
| 645 | return yield* new CloudEnvironmentLinkError({ | ||
| 646 | message: "Local environment is not ready yet.", | ||
| 647 | }); | ||
| 648 | } | ||
| 649 | const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl); | ||
| 650 | yield* ensureRelayClientAvailable(getPrimaryEnvironmentConnection().client); | ||
| 651 | 405 | ||
| 652 | const challenge = yield* relayClient | 406 | const challenge = yield* relayClient |
| 653 | .createEnvironmentLinkChallenge({ | 407 | .createEnvironmentLinkChallenge({ |
| ... | @@ -672,11 +426,11 @@ export function linkPrimaryEnvironmentToCloud(input: { | ... | @@ -672,11 +426,11 @@ export function linkPrimaryEnvironmentToCloud(input: { |
| 672 | challenge: challenge.challenge, | 426 | challenge: challenge.challenge, |
| 673 | relayIssuer: configuredRelayUrl, | 427 | relayIssuer: configuredRelayUrl, |
| 674 | endpoint: { | 428 | endpoint: { |
| 675 | httpBaseUrl: target.httpBaseUrl, | 429 | httpBaseUrl: input.target.httpBaseUrl, |
| 676 | wsBaseUrl: target.wsBaseUrl, | 430 | wsBaseUrl: input.target.wsBaseUrl, |
| 677 | providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, | 431 | providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, |
| 678 | }, | 432 | }, |
| 679 | origin: endpointOrigin(target.httpBaseUrl), | 433 | origin: endpointOrigin(input.target.httpBaseUrl), |
| 680 | }, | 434 | }, |
| 681 | }) | 435 | }) |
| 682 | .pipe( | 436 | .pipe( |
| ... | @@ -699,7 +453,7 @@ export function linkPrimaryEnvironmentToCloud(input: { | ... | @@ -699,7 +453,7 @@ export function linkPrimaryEnvironmentToCloud(input: { |
| 699 | ), | 453 | ), |
| 700 | ); | 454 | ); |
| 701 | yield* ensureLinkedEnvironmentMatches({ | 455 | yield* ensureLinkedEnvironmentMatches({ |
| 702 | expectedEnvironmentId: target.environmentId, | 456 | expectedEnvironmentId: input.target.environmentId, |
| 703 | expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, | 457 | expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, |
| 704 | link, | 458 | link, |
| 705 | }); | 459 | }); |
apps/web/src/cloud/linkEnvironmentAtoms.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { | ||
| 2 | createAtomCommandScheduler, | ||
| 3 | createRuntimeCommand, | ||
| 4 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 5 | |||
| 6 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 7 | import { | ||
| 8 | linkPrimaryEnvironmentToCloud, | ||
| 9 | type CloudLinkTarget, | ||
| 10 | unlinkPrimaryEnvironmentFromCloud, | ||
| 11 | } from "./linkEnvironment"; | ||
| 12 | |||
| 13 | const cloudLinkScheduler = createAtomCommandScheduler(); | ||
| 14 | const cloudLinkConcurrency = { | ||
| 15 | mode: "serial" as const, | ||
| 16 | key: (input: { readonly target: CloudLinkTarget }) => input.target.environmentId, | ||
| 17 | }; | ||
| 18 | |||
| 19 | export const linkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, { | ||
| 20 | label: "web:cloud:link-primary-environment", | ||
| 21 | scheduler: cloudLinkScheduler, | ||
| 22 | concurrency: cloudLinkConcurrency, | ||
| 23 | execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string }) => | ||
| 24 | linkPrimaryEnvironmentToCloud(input), | ||
| 25 | }); | ||
| 26 | |||
| 27 | export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, { | ||
| 28 | label: "web:cloud:unlink-primary-environment", | ||
| 29 | scheduler: cloudLinkScheduler, | ||
| 30 | concurrency: cloudLinkConcurrency, | ||
| 31 | execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => | ||
| 32 | unlinkPrimaryEnvironmentFromCloud(input), | ||
| 33 | }); | ||
apps/web/src/cloud/managedAuth.test.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay"; | ||
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 5 | import { | ||
| 6 | activateManagedRelayAuthentication, | ||
| 7 | deactivateManagedRelayAuthentication, | ||
| 8 | readManagedRelayClerkToken, | ||
| 9 | } from "./managedAuth"; | ||
| 10 | |||
| 11 | vi.mock("@clerk/react", () => ({ | ||
| 12 | useAuth: vi.fn(), | ||
| 13 | })); | ||
| 14 | |||
| 15 | vi.mock("../lib/runtime", () => ({ | ||
| 16 | runtime: { | ||
| 17 | runPromiseExit: vi.fn(), | ||
| 18 | }, | ||
| 19 | })); | ||
| 20 | |||
| 21 | vi.mock("../connection/catalog", () => ({ | ||
| 22 | environmentCatalog: { | ||
| 23 | removeRelayEnvironments: {}, | ||
| 24 | }, | ||
| 25 | })); | ||
| 26 | |||
| 27 | afterEach(() => { | ||
| 28 | deactivateManagedRelayAuthentication(); | ||
| 29 | }); | ||
| 30 | |||
| 31 | describe("managed relay authentication", () => { | ||
| 32 | it("clears all token access synchronously before account cleanup can fail", async () => { | ||
| 33 | activateManagedRelayAuthentication("account-1", async () => "account-1-token"); | ||
| 34 | expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1"); | ||
| 35 | expect(await readManagedRelayClerkToken()).toBe("account-1-token"); | ||
| 36 | |||
| 37 | deactivateManagedRelayAuthentication(); | ||
| 38 | const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined); | ||
| 39 | |||
| 40 | expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull(); | ||
| 41 | expect(await readManagedRelayClerkToken()).toBeNull(); | ||
| 42 | await cleanup; | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("replaces an existing account session atomically", () => { | ||
| 46 | setManagedRelaySession(appAtomRegistry, { | ||
| 47 | accountId: "account-1", | ||
| 48 | readClerkToken: async () => "account-1-token", | ||
| 49 | }); | ||
| 50 | |||
| 51 | activateManagedRelayAuthentication("account-2", async () => "account-2-token"); | ||
| 52 | |||
| 53 | expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-2"); | ||
| 54 | }); | ||
| 55 | }); | ||
apps/web/src/cloud/managedAuth.tsx+95-16| ... | @@ -1,8 +1,17 @@ | ... | @@ -1,8 +1,17 @@ |
| 1 | import { useAuth } from "@clerk/react"; | 1 | import { useAuth } from "@clerk/react"; |
| 2 | import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; | 2 | import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay"; |
| 3 | import { useEffect, type ReactNode } from "react"; | 3 | import { |
| 4 | reportAtomCommandResult, | ||
| 5 | settleAsyncResult, | ||
| 6 | settlePromise, | ||
| 7 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import { useEffect, useRef, type ReactNode } from "react"; | ||
| 4 | 10 | ||
| 11 | import { environmentCatalog } from "../connection/catalog"; | ||
| 12 | import { runtime } from "../lib/runtime"; | ||
| 5 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 13 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 14 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 6 | import { resolveRelayClerkTokenOptions } from "./publicConfig"; | 15 | import { resolveRelayClerkTokenOptions } from "./publicConfig"; |
| 7 | 16 | ||
| 8 | let relayTokenProvider: (() => Promise<string | null>) | null = null; | 17 | let relayTokenProvider: (() => Promise<string | null>) | null = null; |
| ... | @@ -11,25 +20,95 @@ export async function readManagedRelayClerkToken(): Promise<string | null> { | ... | @@ -11,25 +20,95 @@ export async function readManagedRelayClerkToken(): Promise<string | null> { |
| 11 | return relayTokenProvider?.() ?? null; | 20 | return relayTokenProvider?.() ?? null; |
| 12 | } | 21 | } |
| 13 | 22 | ||
| 23 | export function deactivateManagedRelayAuthentication(): void { | ||
| 24 | relayTokenProvider = null; | ||
| 25 | setManagedRelaySession(appAtomRegistry, null); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function activateManagedRelayAuthentication( | ||
| 29 | accountId: string, | ||
| 30 | readClerkToken: () => Promise<string | null>, | ||
| 31 | ): void { | ||
| 32 | relayTokenProvider = readClerkToken; | ||
| 33 | setManagedRelaySession(appAtomRegistry, { | ||
| 34 | accountId, | ||
| 35 | readClerkToken, | ||
| 36 | }); | ||
| 37 | } | ||
| 38 | |||
| 14 | export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) { | 39 | export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) { |
| 15 | const { getToken, isSignedIn, userId } = useAuth(); | 40 | const { getToken, isLoaded, isSignedIn, userId } = useAuth({ |
| 41 | treatPendingAsSignedOut: false, | ||
| 42 | }); | ||
| 43 | const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { | ||
| 44 | reportFailure: false, | ||
| 45 | reportDefect: false, | ||
| 46 | }); | ||
| 47 | const observedAccountRef = useRef<string | null | undefined>(undefined); | ||
| 48 | const accountTransitionRef = useRef<Promise<void> | null>(null); | ||
| 16 | 49 | ||
| 17 | useEffect(() => { | 50 | useEffect(() => { |
| 18 | relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null; | 51 | if (!isLoaded) { |
| 19 | setManagedRelaySession( | 52 | return; |
| 20 | appAtomRegistry, | 53 | } |
| 21 | isSignedIn && userId | 54 | |
| 22 | ? createManagedRelaySession({ | 55 | let cancelled = false; |
| 23 | accountId: userId, | 56 | const previousAccount = observedAccountRef.current; |
| 24 | readClerkToken: () => getToken(resolveRelayClerkTokenOptions()), | 57 | const nextAccount = isSignedIn && userId ? userId : null; |
| 25 | }) | 58 | observedAccountRef.current = nextAccount; |
| 26 | : null, | 59 | |
| 27 | ); | 60 | const queueAccountCleanup = () => { |
| 61 | const previousTransition = accountTransitionRef.current ?? Promise.resolve(); | ||
| 62 | accountTransitionRef.current = previousTransition.then(async () => { | ||
| 63 | const results = await Promise.all([ | ||
| 64 | removeRelayEnvironments(), | ||
| 65 | settleAsyncResult(() => | ||
| 66 | runtime.runPromiseExit( | ||
| 67 | ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), | ||
| 68 | ), | ||
| 69 | ), | ||
| 70 | ]); | ||
| 71 | for (const result of results) { | ||
| 72 | reportAtomCommandResult(result, { label: "cloud account cleanup" }); | ||
| 73 | } | ||
| 74 | }); | ||
| 75 | return accountTransitionRef.current; | ||
| 76 | }; | ||
| 77 | |||
| 78 | if (!isSignedIn || !userId) { | ||
| 79 | deactivateManagedRelayAuthentication(); | ||
| 80 | if (previousAccount !== null) { | ||
| 81 | void queueAccountCleanup(); | ||
| 82 | } | ||
| 83 | } else { | ||
| 84 | const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); | ||
| 85 | const activateSession = () => { | ||
| 86 | if (!cancelled) { | ||
| 87 | activateManagedRelayAuthentication(userId, tokenProvider); | ||
| 88 | } | ||
| 89 | }; | ||
| 90 | const activateAfterTransition = (transition: Promise<void>) => { | ||
| 91 | void (async () => { | ||
| 92 | const result = await settlePromise(async () => { | ||
| 93 | await transition; | ||
| 94 | activateSession(); | ||
| 95 | }); | ||
| 96 | reportAtomCommandResult(result, { label: "cloud account activation" }); | ||
| 97 | })(); | ||
| 98 | }; | ||
| 99 | if (previousAccount !== undefined && previousAccount !== null && previousAccount !== userId) { | ||
| 100 | deactivateManagedRelayAuthentication(); | ||
| 101 | activateAfterTransition(queueAccountCleanup()); | ||
| 102 | } else { | ||
| 103 | activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); | ||
| 104 | } | ||
| 105 | } | ||
| 28 | return () => { | 106 | return () => { |
| 29 | relayTokenProvider = null; | 107 | cancelled = true; |
| 30 | setManagedRelaySession(appAtomRegistry, null); | ||
| 31 | }; | 108 | }; |
| 32 | }, [getToken, isSignedIn, userId]); | 109 | }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]); |
| 110 | |||
| 111 | useEffect(() => () => deactivateManagedRelayAuthentication(), []); | ||
| 33 | 112 | ||
| 34 | return children; | 113 | return children; |
| 35 | } | 114 | } |
apps/web/src/cloud/managedRelayLayer.ts+19-15| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import { | 1 | import { |
| 2 | managedRelayClientLayer, | 2 | managedRelayClientLayer as makeManagedRelayClientLayer, |
| 3 | ManagedRelayDpopSigner, | 3 | ManagedRelayDpopSigner, |
| 4 | ManagedRelayDpopSignerError, | 4 | ManagedRelayDpopSignerError, |
| 5 | } from "@t3tools/client-runtime"; | 5 | } from "@t3tools/client-runtime/relay"; |
| 6 | import { RelayWebClientId } from "@t3tools/contracts/relay"; | 6 | import { RelayWebClientId } from "@t3tools/contracts/relay"; |
| 7 | import * as Crypto from "effect/Crypto"; | 7 | import * as Crypto from "effect/Crypto"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| ... | @@ -17,7 +17,7 @@ import { | ... | @@ -17,7 +17,7 @@ import { |
| 17 | type BrowserDpopKey, | 17 | type BrowserDpopKey, |
| 18 | } from "./dpop"; | 18 | } from "./dpop"; |
| 19 | 19 | ||
| 20 | export const webRelayDpopSignerLayer = Layer.effect( | 20 | export const relayDpopSignerLayer = Layer.effect( |
| 21 | ManagedRelayDpopSigner, | 21 | ManagedRelayDpopSigner, |
| 22 | Effect.gen(function* () { | 22 | Effect.gen(function* () { |
| 23 | const crypto = yield* Crypto.Crypto; | 23 | const crypto = yield* Crypto.Crypto; |
| ... | @@ -39,24 +39,28 @@ export const webRelayDpopSignerLayer = Layer.effect( | ... | @@ -39,24 +39,28 @@ export const webRelayDpopSignerLayer = Layer.effect( |
| 39 | return generated; | 39 | return generated; |
| 40 | }), | 40 | }), |
| 41 | ); | 41 | ); |
| 42 | const signerError = (cause: unknown) => new ManagedRelayDpopSignerError({ cause }); | 42 | |
| 43 | return ManagedRelayDpopSigner.of({ | 43 | return ManagedRelayDpopSigner.of({ |
| 44 | thumbprint: loadOrCreateBrowserDpopKey.pipe( | 44 | thumbprint: loadOrCreateBrowserDpopKey.pipe( |
| 45 | Effect.map((proofKey) => proofKey.thumbprint), | 45 | Effect.map((proofKey) => proofKey.thumbprint), |
| 46 | Effect.mapError(signerError), | 46 | Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), |
| 47 | Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"), | ||
| 48 | ), | ||
| 49 | createProof: Effect.fn("web.managedRelayDpopSigner.createProof")( | ||
| 50 | function* (input) { | ||
| 51 | const proofKey = yield* loadOrCreateBrowserDpopKey; | ||
| 52 | return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( | ||
| 53 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 54 | Effect.map((proof) => proof.proof), | ||
| 55 | ); | ||
| 56 | }, | ||
| 57 | Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), | ||
| 47 | ), | 58 | ), |
| 48 | createProof: (input) => | ||
| 49 | loadOrCreateBrowserDpopKey.pipe( | ||
| 50 | Effect.flatMap((proofKey) => createBrowserDpopProof({ ...input, proofKey })), | ||
| 51 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 52 | Effect.map((proof) => proof.proof), | ||
| 53 | Effect.mapError(signerError), | ||
| 54 | ), | ||
| 55 | }); | 59 | }); |
| 56 | }), | 60 | }), |
| 57 | ); | 61 | ); |
| 58 | 62 | ||
| 59 | export const webManagedRelayClientLayer = (relayUrl: string) => | 63 | export const managedRelayClientLayer = (relayUrl: string) => |
| 60 | managedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( | 64 | makeManagedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( |
| 61 | Layer.provideMerge(webRelayDpopSignerLayer), | 65 | Layer.provideMerge(relayDpopSignerLayer), |
| 62 | ); | 66 | ); |
apps/web/src/cloud/managedRelayState.ts+24-8| ... | @@ -4,7 +4,7 @@ import { | ... | @@ -4,7 +4,7 @@ import { |
| 4 | ManagedRelayClient, | 4 | ManagedRelayClient, |
| 5 | managedRelaySessionAtom, | 5 | managedRelaySessionAtom, |
| 6 | readManagedRelaySnapshotState, | 6 | readManagedRelaySnapshotState, |
| 7 | } from "@t3tools/client-runtime"; | 7 | } from "@t3tools/client-runtime/relay"; |
| 8 | import type { | 8 | import type { |
| 9 | RelayClientDeviceRecord, | 9 | RelayClientDeviceRecord, |
| 10 | RelayClientEnvironmentRecord, | 10 | RelayClientEnvironmentRecord, |
| ... | @@ -13,17 +13,15 @@ import * as Context from "effect/Context"; | ... | @@ -13,17 +13,15 @@ import * as Context from "effect/Context"; |
| 13 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 16 | import { useCallback } from "react"; | 16 | import { useCallback, useEffect } from "react"; |
| 17 | 17 | ||
| 18 | import { webRuntime } from "../lib/runtime"; | 18 | import { runtime } from "../lib/runtime"; |
| 19 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 19 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 20 | 20 | ||
| 21 | const managedRelayAtomRuntime = Atom.runtime( | 21 | const managedRelayAtomRuntime = Atom.runtime( |
| 22 | Layer.effect( | 22 | Layer.effect( |
| 23 | ManagedRelayClient, | 23 | ManagedRelayClient, |
| 24 | webRuntime.contextEffect.pipe( | 24 | runtime.contextEffect.pipe(Effect.map((context) => Context.get(context, ManagedRelayClient))), |
| 25 | Effect.map((context) => Context.get(context, ManagedRelayClient)), | ||
| 26 | ), | ||
| 27 | ), | 25 | ), |
| 28 | ); | 26 | ); |
| 29 | 27 | ||
| ... | @@ -44,6 +42,15 @@ export function useManagedRelayEnvironments() { | ... | @@ -44,6 +42,15 @@ export function useManagedRelayEnvironments() { |
| 44 | ? managedRelayQueryManager.environmentsAtom(accountId) | 42 | ? managedRelayQueryManager.environmentsAtom(accountId) |
| 45 | : EMPTY_ENVIRONMENTS_ATOM; | 43 | : EMPTY_ENVIRONMENTS_ATOM; |
| 46 | const result = useAtomValue(atom); | 44 | const result = useAtomValue(atom); |
| 45 | const snapshot = readManagedRelaySnapshotState(result); | ||
| 46 | useEffect(() => { | ||
| 47 | if (snapshot.error) { | ||
| 48 | console.error("[t3-cloud] Relay environment listing failed", { | ||
| 49 | message: snapshot.error, | ||
| 50 | traceId: snapshot.errorTraceId, | ||
| 51 | }); | ||
| 52 | } | ||
| 53 | }, [snapshot.error, snapshot.errorTraceId]); | ||
| 47 | const refresh = useCallback(() => { | 54 | const refresh = useCallback(() => { |
| 48 | if (accountId) { | 55 | if (accountId) { |
| 49 | managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); | 56 | managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); |
| ... | @@ -51,7 +58,7 @@ export function useManagedRelayEnvironments() { | ... | @@ -51,7 +58,7 @@ export function useManagedRelayEnvironments() { |
| 51 | }, [accountId]); | 58 | }, [accountId]); |
| 52 | 59 | ||
| 53 | return { | 60 | return { |
| 54 | ...readManagedRelaySnapshotState(result), | 61 | ...snapshot, |
| 55 | accountId, | 62 | accountId, |
| 56 | refresh, | 63 | refresh, |
| 57 | }; | 64 | }; |
| ... | @@ -62,6 +69,15 @@ export function useManagedRelayDevices() { | ... | @@ -62,6 +69,15 @@ export function useManagedRelayDevices() { |
| 62 | const accountId = session?.accountId ?? null; | 69 | const accountId = session?.accountId ?? null; |
| 63 | const atom = accountId ? managedRelayQueryManager.devicesAtom(accountId) : EMPTY_DEVICES_ATOM; | 70 | const atom = accountId ? managedRelayQueryManager.devicesAtom(accountId) : EMPTY_DEVICES_ATOM; |
| 64 | const result = useAtomValue(atom); | 71 | const result = useAtomValue(atom); |
| 72 | const snapshot = readManagedRelaySnapshotState(result); | ||
| 73 | useEffect(() => { | ||
| 74 | if (snapshot.error) { | ||
| 75 | console.error("[t3-cloud] Relay device listing failed", { | ||
| 76 | message: snapshot.error, | ||
| 77 | traceId: snapshot.errorTraceId, | ||
| 78 | }); | ||
| 79 | } | ||
| 80 | }, [snapshot.error, snapshot.errorTraceId]); | ||
| 65 | const refresh = useCallback(() => { | 81 | const refresh = useCallback(() => { |
| 66 | if (accountId) { | 82 | if (accountId) { |
| 67 | managedRelayQueryManager.refreshDevices(appAtomRegistry, accountId); | 83 | managedRelayQueryManager.refreshDevices(appAtomRegistry, accountId); |
| ... | @@ -69,7 +85,7 @@ export function useManagedRelayDevices() { | ... | @@ -69,7 +85,7 @@ export function useManagedRelayDevices() { |
| 69 | }, [accountId]); | 85 | }, [accountId]); |
| 70 | 86 | ||
| 71 | return { | 87 | return { |
| 72 | ...readManagedRelaySnapshotState(result), | 88 | ...snapshot, |
| 73 | accountId, | 89 | accountId, |
| 74 | refresh, | 90 | refresh, |
| 75 | }; | 91 | }; |
apps/web/src/cloud/primaryCloudLinkState.ts+38-20| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentCloudLinkStateResult, EnvironmentId } from "@t3tools/contracts"; | 2 | import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts"; |
| 3 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as Context from "effect/Context"; | 4 | import * as Context from "effect/Context"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| ... | @@ -7,51 +7,68 @@ import * as Layer from "effect/Layer"; | ... | @@ -7,51 +7,68 @@ import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 8 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 9 | import { HttpClient } from "effect/unstable/http"; | 9 | import { HttpClient } from "effect/unstable/http"; |
| 10 | import { useCallback } from "react"; | 10 | import { useCallback, useMemo } from "react"; |
| 11 | 11 | ||
| 12 | import { usePrimaryEnvironmentId } from "../environments/primary"; | 12 | import { usePrimaryEnvironment } from "../state/environments"; |
| 13 | import { webRuntime } from "../lib/runtime"; | 13 | import { runtime } from "../lib/runtime"; |
| 14 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 14 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 15 | import { readPrimaryCloudLinkState } from "./linkEnvironment"; | 15 | import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; |
| 16 | 16 | ||
| 17 | const primaryCloudLinkAtomRuntime = Atom.runtime( | 17 | const primaryCloudLinkAtomRuntime = Atom.runtime( |
| 18 | Layer.effect( | 18 | Layer.effect( |
| 19 | HttpClient.HttpClient, | 19 | HttpClient.HttpClient, |
| 20 | webRuntime.contextEffect.pipe( | 20 | runtime.contextEffect.pipe( |
| 21 | Effect.map((context) => Context.get(context, HttpClient.HttpClient)), | 21 | Effect.map((context) => Context.get(context, HttpClient.HttpClient)), |
| 22 | ), | 22 | ), |
| 23 | ), | 23 | ), |
| 24 | ); | 24 | ); |
| 25 | 25 | ||
| 26 | const primaryCloudLinkStateAtom = Atom.family((environmentId: EnvironmentId) => | 26 | const primaryCloudLinkStateAtom = Atom.family((key: string) => { |
| 27 | primaryCloudLinkAtomRuntime | 27 | const target = JSON.parse(key) as CloudLinkTarget; |
| 28 | .atom(readPrimaryCloudLinkState()) | 28 | return primaryCloudLinkAtomRuntime |
| 29 | .atom(readPrimaryCloudLinkState({ target })) | ||
| 29 | .pipe( | 30 | .pipe( |
| 30 | Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), | 31 | Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), |
| 31 | Atom.setIdleTTL(5 * 60_000), | 32 | Atom.setIdleTTL(5 * 60_000), |
| 32 | Atom.withLabel(`primary-cloud-link:${environmentId}`), | 33 | Atom.withLabel(`primary-cloud-link:${target.environmentId}`), |
| 33 | ), | 34 | ); |
| 34 | ); | 35 | }); |
| 35 | 36 | ||
| 36 | const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make( | 37 | const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make( |
| 37 | AsyncResult.success<EnvironmentCloudLinkStateResult | null>(null), | 38 | AsyncResult.success<EnvironmentCloudLinkStateResult | null>(null), |
| 38 | ).pipe(Atom.keepAlive, Atom.withLabel("primary-cloud-link:null")); | 39 | ).pipe(Atom.keepAlive, Atom.withLabel("primary-cloud-link:null")); |
| 39 | 40 | ||
| 40 | export function refreshPrimaryCloudLinkState(environmentId: EnvironmentId | null): void { | 41 | function targetKey(target: CloudLinkTarget): string { |
| 41 | if (environmentId) { | 42 | return JSON.stringify(target); |
| 42 | appAtomRegistry.refresh(primaryCloudLinkStateAtom(environmentId)); | 43 | } |
| 44 | |||
| 45 | export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { | ||
| 46 | if (target) { | ||
| 47 | appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); | ||
| 43 | } | 48 | } |
| 44 | } | 49 | } |
| 45 | 50 | ||
| 46 | export function usePrimaryCloudLinkState() { | 51 | export function usePrimaryCloudLinkState() { |
| 47 | const environmentId = usePrimaryEnvironmentId(); | 52 | const primary = usePrimaryEnvironment(); |
| 48 | const atom = environmentId | 53 | const target = useMemo( |
| 49 | ? primaryCloudLinkStateAtom(environmentId) | 54 | () => |
| 55 | primary?.entry.target._tag === "PrimaryConnectionTarget" | ||
| 56 | ? { | ||
| 57 | environmentId: primary.environmentId, | ||
| 58 | label: primary.label, | ||
| 59 | httpBaseUrl: primary.entry.target.httpBaseUrl, | ||
| 60 | wsBaseUrl: primary.entry.target.wsBaseUrl, | ||
| 61 | } | ||
| 62 | : null, | ||
| 63 | [primary], | ||
| 64 | ); | ||
| 65 | const atom = target | ||
| 66 | ? primaryCloudLinkStateAtom(targetKey(target)) | ||
| 50 | : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; | 67 | : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; |
| 51 | const result = useAtomValue(atom); | 68 | const result = useAtomValue(atom); |
| 52 | const refresh = useCallback(() => { | 69 | const refresh = useCallback(() => { |
| 53 | refreshPrimaryCloudLinkState(environmentId); | 70 | refreshPrimaryCloudLinkState(target); |
| 54 | }, [environmentId]); | 71 | }, [target]); |
| 55 | let error: string | null = null; | 72 | let error: string | null = null; |
| 56 | if (result._tag === "Failure") { | 73 | if (result._tag === "Failure") { |
| 57 | const cause = Cause.squash(result.cause); | 74 | const cause = Cause.squash(result.cause); |
| ... | @@ -63,5 +80,6 @@ export function usePrimaryCloudLinkState() { | ... | @@ -63,5 +80,6 @@ export function usePrimaryCloudLinkState() { |
| 63 | error, | 80 | error, |
| 64 | isPending: result.waiting, | 81 | isPending: result.waiting, |
| 65 | refresh, | 82 | refresh, |
| 83 | target, | ||
| 66 | }; | 84 | }; |
| 67 | } | 85 | } |
apps/web/src/commandPaletteContext.tsx created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { createContext, use, type ReactNode } from "react"; | ||
| 2 | |||
| 3 | const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null); | ||
| 4 | |||
| 5 | export function OpenAddProjectCommandPaletteProvider(props: { | ||
| 6 | readonly children: ReactNode; | ||
| 7 | readonly openAddProject: () => void; | ||
| 8 | }) { | ||
| 9 | return ( | ||
| 10 | <OpenAddProjectCommandPaletteContext value={props.openAddProject}> | ||
| 11 | {props.children} | ||
| 12 | </OpenAddProjectCommandPaletteContext> | ||
| 13 | ); | ||
| 14 | } | ||
| 15 | |||
| 16 | export function useOpenAddProjectCommandPalette(): () => void { | ||
| 17 | const openAddProject = use(OpenAddProjectCommandPaletteContext); | ||
| 18 | if (!openAddProject) { | ||
| 19 | throw new Error("Command palette actions must be used inside CommandPalette"); | ||
| 20 | } | ||
| 21 | return openAddProject; | ||
| 22 | } | ||
| 23 | |||
| 24 | /** Read at event time so the chat tree does not subscribe to transient dialog state. */ | ||
| 25 | export function isCommandPaletteOpen(): boolean { | ||
| 26 | return ( | ||
| 27 | typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null | ||
| 28 | ); | ||
| 29 | } | ||
apps/web/src/commandPaletteStore.ts deleted-32| ... | @@ -1,32 +0,0 @@ | ||
| 1 | import { create } from "zustand"; | ||
| 2 | |||
| 3 | interface CommandPaletteOpenIntent { | ||
| 4 | kind: "add-project"; | ||
| 5 | requestId: number; | ||
| 6 | } | ||
| 7 | |||
| 8 | interface CommandPaletteStore { | ||
| 9 | open: boolean; | ||
| 10 | openIntent: CommandPaletteOpenIntent | null; | ||
| 11 | setOpen: (open: boolean) => void; | ||
| 12 | toggleOpen: () => void; | ||
| 13 | openAddProject: () => void; | ||
| 14 | clearOpenIntent: () => void; | ||
| 15 | } | ||
| 16 | |||
| 17 | export const useCommandPaletteStore = create<CommandPaletteStore>((set) => ({ | ||
| 18 | open: false, | ||
| 19 | openIntent: null, | ||
| 20 | setOpen: (open) => set({ open, ...(open ? {} : { openIntent: null }) }), | ||
| 21 | toggleOpen: () => | ||
| 22 | set((state) => ({ open: !state.open, ...(state.open ? { openIntent: null } : {}) })), | ||
| 23 | openAddProject: () => | ||
| 24 | set((state) => ({ | ||
| 25 | open: true, | ||
| 26 | openIntent: { | ||
| 27 | kind: "add-project", | ||
| 28 | requestId: (state.openIntent?.requestId ?? 0) + 1, | ||
| 29 | }, | ||
| 30 | })), | ||
| 31 | clearOpenIntent: () => set({ openIntent: null }), | ||
| 32 | })); | ||
apps/web/src/components/AppSidebarLayout.tsx-26| ... | @@ -3,10 +3,6 @@ import { useNavigate } from "@tanstack/react-router"; | ... | @@ -3,10 +3,6 @@ import { useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | ||
| 4 | import ThreadSidebar from "./Sidebar"; | 4 | import ThreadSidebar from "./Sidebar"; |
| 5 | import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; | 5 | import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; |
| 6 | import { | ||
| 7 | clearShortcutModifierState, | ||
| 8 | syncShortcutModifierStateFromKeyboardEvent, | ||
| 9 | } from "../shortcutModifierState"; | ||
| 10 | 6 | ||
| 11 | const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; | 7 | const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; |
| 12 | const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; | 8 | const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; |
| ... | @@ -14,28 +10,6 @@ const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; | ... | @@ -14,28 +10,6 @@ const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; |
| 14 | export function AppSidebarLayout({ children }: { children: ReactNode }) { | 10 | export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 15 | const navigate = useNavigate(); | 11 | const navigate = useNavigate(); |
| 16 | 12 | ||
| 17 | useEffect(() => { | ||
| 18 | const onWindowKeyDown = (event: KeyboardEvent) => { | ||
| 19 | syncShortcutModifierStateFromKeyboardEvent(event); | ||
| 20 | }; | ||
| 21 | const onWindowKeyUp = (event: KeyboardEvent) => { | ||
| 22 | syncShortcutModifierStateFromKeyboardEvent(event); | ||
| 23 | }; | ||
| 24 | const onWindowBlur = () => { | ||
| 25 | clearShortcutModifierState(); | ||
| 26 | }; | ||
| 27 | |||
| 28 | window.addEventListener("keydown", onWindowKeyDown, true); | ||
| 29 | window.addEventListener("keyup", onWindowKeyUp, true); | ||
| 30 | window.addEventListener("blur", onWindowBlur); | ||
| 31 | |||
| 32 | return () => { | ||
| 33 | window.removeEventListener("keydown", onWindowKeyDown, true); | ||
| 34 | window.removeEventListener("keyup", onWindowKeyUp, true); | ||
| 35 | window.removeEventListener("blur", onWindowBlur); | ||
| 36 | }; | ||
| 37 | }, []); | ||
| 38 | |||
| 39 | useEffect(() => { | 13 | useEffect(() => { |
| 40 | const onMenuAction = window.desktopBridge?.onMenuAction; | 14 | const onMenuAction = window.desktopBridge?.onMenuAction; |
| 41 | if (typeof onMenuAction !== "function") { | 15 | if (typeof onMenuAction !== "function") { |
apps/web/src/components/BranchToolbar.tsx+7-13| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { | 3 | import { |
| 4 | ChevronDownIcon, | 4 | ChevronDownIcon, |
| ... | @@ -11,9 +11,8 @@ import { | ... | @@ -11,9 +11,8 @@ import { |
| 11 | import { memo, useMemo } from "react"; | 11 | import { memo, useMemo } from "react"; |
| 12 | 12 | ||
| 13 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | 13 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 14 | import { useProject, useThread } from "../state/entities"; | ||
| 14 | import { useIsMobile } from "../hooks/useMediaQuery"; | 15 | import { useIsMobile } from "../hooks/useMediaQuery"; |
| 15 | import { useStore } from "../store"; | ||
| 16 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | ||
| 17 | import { | 16 | import { |
| 18 | type EnvMode, | 17 | type EnvMode, |
| 19 | type EnvironmentOption, | 18 | type EnvironmentOption, |
| ... | @@ -207,8 +206,7 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -207,8 +206,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 207 | () => scopeThreadRef(environmentId, threadId), | 206 | () => scopeThreadRef(environmentId, threadId), |
| 208 | [environmentId, threadId], | 207 | [environmentId, threadId], |
| 209 | ); | 208 | ); |
| 210 | const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); | 209 | const serverThread = useThread(threadRef); |
| 211 | const serverThread = useStore(serverThreadSelector); | ||
| 212 | const draftThread = useComposerDraftStore((store) => | 210 | const draftThread = useComposerDraftStore((store) => |
| 213 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), | 211 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), |
| 214 | ); | 212 | ); |
| ... | @@ -217,21 +215,17 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -217,21 +215,17 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 217 | : draftThread | 215 | : draftThread |
| 218 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | 216 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 219 | : null; | 217 | : null; |
| 220 | const activeProjectSelector = useMemo( | 218 | const activeProject = useProject(activeProjectRef); |
| 221 | () => createProjectSelectorByRef(activeProjectRef), | 219 | const hasActiveThread = serverThread !== null || draftThread !== null; |
| 222 | [activeProjectRef], | ||
| 223 | ); | ||
| 224 | const activeProject = useStore(activeProjectSelector); | ||
| 225 | const hasActiveThread = serverThread !== undefined || draftThread !== null; | ||
| 226 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 220 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| 227 | const effectiveEnvMode = | 221 | const effectiveEnvMode = |
| 228 | effectiveEnvModeOverride ?? | 222 | effectiveEnvModeOverride ?? |
| 229 | resolveEffectiveEnvMode({ | 223 | resolveEffectiveEnvMode({ |
| 230 | activeWorktreePath, | 224 | activeWorktreePath, |
| 231 | hasServerThread: serverThread !== undefined, | 225 | hasServerThread: serverThread !== null, |
| 232 | draftThreadEnvMode: draftThread?.envMode, | 226 | draftThreadEnvMode: draftThread?.envMode, |
| 233 | }); | 227 | }); |
| 234 | const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); | 228 | const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); |
| 235 | 229 | ||
| 236 | const showEnvironmentPicker = Boolean( | 230 | const showEnvironmentPicker = Boolean( |
| 237 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, | 231 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, |
apps/web/src/components/BranchToolbarBranchSelector.tsx+86-83| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { | ||
| 3 | isAtomCommandInterrupted, | ||
| 4 | squashAtomCommandFailure, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; | 6 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; |
| 3 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | 7 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 4 | import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; | 8 | import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; |
| ... | @@ -15,15 +19,15 @@ import { | ... | @@ -15,15 +19,15 @@ import { |
| 15 | } from "react"; | 19 | } from "react"; |
| 16 | 20 | ||
| 17 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | 21 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 18 | import { readEnvironmentApi } from "../environmentApi"; | 22 | import { usePaginatedBranches } from "../state/queries"; |
| 19 | import { useVcsStatus } from "../lib/vcsStatusState"; | 23 | import { useProject, useThread } from "../state/entities"; |
| 20 | import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState"; | 24 | import { useEnvironmentQuery } from "../state/query"; |
| 21 | import { newCommandId } from "../lib/utils"; | 25 | import { threadEnvironment } from "../state/threads"; |
| 26 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 27 | import { vcsEnvironment } from "../state/vcs"; | ||
| 22 | import { cn } from "../lib/utils"; | 28 | import { cn } from "../lib/utils"; |
| 23 | import { parsePullRequestReference } from "../pullRequestReference"; | 29 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 24 | import { getSourceControlPresentation } from "../sourceControlPresentation"; | 30 | import { getSourceControlPresentation } from "../sourceControlPresentation"; |
| 25 | import { useStore } from "../store"; | ||
| 26 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | ||
| 27 | import { | 31 | import { |
| 28 | deriveLocalBranchNameFromRemoteRef, | 32 | deriveLocalBranchNameFromRemoteRef, |
| 29 | resolveBranchSelectionTarget, | 33 | resolveBranchSelectionTarget, |
| ... | @@ -58,8 +62,6 @@ interface BranchToolbarBranchSelectorProps { | ... | @@ -58,8 +62,6 @@ interface BranchToolbarBranchSelectorProps { |
| 58 | onComposerFocusRequest?: () => void; | 62 | onComposerFocusRequest?: () => void; |
| 59 | } | 63 | } |
| 60 | 64 | ||
| 61 | const EMPTY_REFS: ReadonlyArray<VcsRef> = []; | ||
| 62 | |||
| 63 | function toBranchActionErrorMessage(error: unknown): string { | 65 | function toBranchActionErrorMessage(error: unknown): string { |
| 64 | return error instanceof Error ? error.message : "An error occurred."; | 66 | return error instanceof Error ? error.message : "An error occurred."; |
| 65 | } | 67 | } |
| ... | @@ -91,6 +93,17 @@ export function BranchToolbarBranchSelector({ | ... | @@ -91,6 +93,17 @@ export function BranchToolbarBranchSelector({ |
| 91 | onCheckoutPullRequestRequest, | 93 | onCheckoutPullRequestRequest, |
| 92 | onComposerFocusRequest, | 94 | onComposerFocusRequest, |
| 93 | }: BranchToolbarBranchSelectorProps) { | 95 | }: BranchToolbarBranchSelectorProps) { |
| 96 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); | ||
| 97 | const updateThreadMetadata = useAtomCommand( | ||
| 98 | threadEnvironment.updateMetadata, | ||
| 99 | "thread metadata update", | ||
| 100 | ); | ||
| 101 | const switchRef = useAtomCommand(vcsEnvironment.switchRef, { | ||
| 102 | reportFailure: false, | ||
| 103 | }); | ||
| 104 | const createRefMutation = useAtomCommand(vcsEnvironment.createRef, { | ||
| 105 | reportFailure: false, | ||
| 106 | }); | ||
| 94 | // --------------------------------------------------------------------------- | 107 | // --------------------------------------------------------------------------- |
| 95 | // Thread / project state (pushed down from parent to colocate with mutation) | 108 | // Thread / project state (pushed down from parent to colocate with mutation) |
| 96 | // --------------------------------------------------------------------------- | 109 | // --------------------------------------------------------------------------- |
| ... | @@ -98,10 +111,8 @@ export function BranchToolbarBranchSelector({ | ... | @@ -98,10 +111,8 @@ export function BranchToolbarBranchSelector({ |
| 98 | () => scopeThreadRef(environmentId, threadId), | 111 | () => scopeThreadRef(environmentId, threadId), |
| 99 | [environmentId, threadId], | 112 | [environmentId, threadId], |
| 100 | ); | 113 | ); |
| 101 | const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); | 114 | const serverThread = useThread(threadRef); |
| 102 | const serverThread = useStore(serverThreadSelector); | ||
| 103 | const serverSession = serverThread?.session ?? null; | 115 | const serverSession = serverThread?.session ?? null; |
| 104 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | ||
| 105 | const draftThread = useComposerDraftStore((store) => | 116 | const draftThread = useComposerDraftStore((store) => |
| 106 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), | 117 | draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), |
| 107 | ); | 118 | ); |
| ... | @@ -112,11 +123,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -112,11 +123,7 @@ export function BranchToolbarBranchSelector({ |
| 112 | : draftThread | 123 | : draftThread |
| 113 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | 124 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 114 | : null; | 125 | : null; |
| 115 | const activeProjectSelector = useMemo( | 126 | const activeProject = useProject(activeProjectRef); |
| 116 | () => createProjectSelectorByRef(activeProjectRef), | ||
| 117 | [activeProjectRef], | ||
| 118 | ); | ||
| 119 | const activeProject = useStore(activeProjectSelector); | ||
| 120 | 127 | ||
| 121 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); | 128 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); |
| 122 | const activeThreadBranch = | 129 | const activeThreadBranch = |
| ... | @@ -124,9 +131,9 @@ export function BranchToolbarBranchSelector({ | ... | @@ -124,9 +131,9 @@ export function BranchToolbarBranchSelector({ |
| 124 | ? activeThreadBranchOverride | 131 | ? activeThreadBranchOverride |
| 125 | : (serverThread?.branch ?? draftThread?.branch ?? null); | 132 | : (serverThread?.branch ?? draftThread?.branch ?? null); |
| 126 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 133 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| 127 | const activeProjectCwd = activeProject?.cwd ?? null; | 134 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| 128 | const branchCwd = activeWorktreePath ?? activeProjectCwd; | 135 | const branchCwd = activeWorktreePath ?? activeProjectCwd; |
| 129 | const hasServerThread = serverThread !== undefined; | 136 | const hasServerThread = serverThread !== null; |
| 130 | const effectiveEnvMode = | 137 | const effectiveEnvMode = |
| 131 | effectiveEnvModeOverride ?? | 138 | effectiveEnvModeOverride ?? |
| 132 | resolveEffectiveEnvMode({ | 139 | resolveEffectiveEnvMode({ |
| ... | @@ -141,29 +148,24 @@ export function BranchToolbarBranchSelector({ | ... | @@ -141,29 +148,24 @@ export function BranchToolbarBranchSelector({ |
| 141 | const setThreadBranch = useCallback( | 148 | const setThreadBranch = useCallback( |
| 142 | (branch: string | null, worktreePath: string | null) => { | 149 | (branch: string | null, worktreePath: string | null) => { |
| 143 | if (!activeThreadId || !activeProject) return; | 150 | if (!activeThreadId || !activeProject) return; |
| 144 | const api = readEnvironmentApi(environmentId); | 151 | if (serverSession && worktreePath !== activeWorktreePath) { |
| 145 | if (serverSession && worktreePath !== activeWorktreePath && api) { | 152 | void stopThreadSession({ |
| 146 | void api.orchestration | 153 | environmentId, |
| 147 | .dispatchCommand({ | 154 | input: { threadId: activeThreadId }, |
| 148 | type: "thread.session.stop", | 155 | }); |
| 149 | commandId: newCommandId(), | ||
| 150 | threadId: activeThreadId, | ||
| 151 | createdAt: new Date().toISOString(), | ||
| 152 | }) | ||
| 153 | .catch(() => undefined); | ||
| 154 | } | 156 | } |
| 155 | if (api && hasServerThread) { | 157 | if (hasServerThread) { |
| 156 | void api.orchestration.dispatchCommand({ | 158 | void updateThreadMetadata({ |
| 157 | type: "thread.meta.update", | 159 | environmentId, |
| 158 | commandId: newCommandId(), | 160 | input: { |
| 159 | threadId: activeThreadId, | 161 | threadId: activeThreadId, |
| 160 | branch, | 162 | branch, |
| 161 | worktreePath, | 163 | worktreePath, |
| 164 | }, | ||
| 162 | }); | 165 | }); |
| 163 | } | 166 | } |
| 164 | if (hasServerThread) { | 167 | if (hasServerThread) { |
| 165 | onActiveThreadBranchOverrideChange?.(branch); | 168 | onActiveThreadBranchOverrideChange?.(branch); |
| 166 | setThreadBranchAction(threadRef, branch, worktreePath); | ||
| 167 | return; | 169 | return; |
| 168 | } | 170 | } |
| 169 | const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ | 171 | const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ |
| ... | @@ -185,12 +187,13 @@ export function BranchToolbarBranchSelector({ | ... | @@ -185,12 +187,13 @@ export function BranchToolbarBranchSelector({ |
| 185 | activeWorktreePath, | 187 | activeWorktreePath, |
| 186 | hasServerThread, | 188 | hasServerThread, |
| 187 | onActiveThreadBranchOverrideChange, | 189 | onActiveThreadBranchOverrideChange, |
| 188 | setThreadBranchAction, | ||
| 189 | setDraftThreadContext, | 190 | setDraftThreadContext, |
| 190 | draftId, | 191 | draftId, |
| 191 | threadRef, | 192 | threadRef, |
| 192 | environmentId, | 193 | environmentId, |
| 193 | effectiveEnvMode, | 194 | effectiveEnvMode, |
| 195 | stopThreadSession, | ||
| 196 | updateThreadMetadata, | ||
| 194 | ], | 197 | ], |
| 195 | ); | 198 | ); |
| 196 | 199 | ||
| ... | @@ -201,7 +204,14 @@ export function BranchToolbarBranchSelector({ | ... | @@ -201,7 +204,14 @@ export function BranchToolbarBranchSelector({ |
| 201 | const [branchQuery, setBranchQuery] = useState(""); | 204 | const [branchQuery, setBranchQuery] = useState(""); |
| 202 | const deferredBranchQuery = useDeferredValue(branchQuery); | 205 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 203 | 206 | ||
| 204 | const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd }); | 207 | const branchStatusQuery = useEnvironmentQuery( |
| 208 | branchCwd === null | ||
| 209 | ? null | ||
| 210 | : vcsEnvironment.status({ | ||
| 211 | environmentId, | ||
| 212 | input: { cwd: branchCwd }, | ||
| 213 | }), | ||
| 214 | ); | ||
| 205 | const trimmedBranchQuery = branchQuery.trim(); | 215 | const trimmedBranchQuery = branchQuery.trim(); |
| 206 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); | 216 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); |
| 207 | const branchRefTarget = useMemo( | 217 | const branchRefTarget = useMemo( |
| ... | @@ -212,11 +222,11 @@ export function BranchToolbarBranchSelector({ | ... | @@ -212,11 +222,11 @@ export function BranchToolbarBranchSelector({ |
| 212 | }), | 222 | }), |
| 213 | [branchCwd, deferredTrimmedBranchQuery, environmentId], | 223 | [branchCwd, deferredTrimmedBranchQuery, environmentId], |
| 214 | ); | 224 | ); |
| 215 | const branchRefState = useVcsRefs(branchRefTarget); | 225 | const branchRefState = usePaginatedBranches(branchRefTarget); |
| 216 | const refs = branchRefState.data?.refs ?? EMPTY_REFS; | 226 | const refs = branchRefState.refs; |
| 217 | const hasNextPage = | 227 | const hasNextPage = |
| 218 | branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; | 228 | branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; |
| 219 | const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); | 229 | const isFetchingNextPage = branchRefState.isPending && branchRefState.data !== null; |
| 220 | const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; | 230 | const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; |
| 221 | const currentGitBranch = | 231 | const currentGitBranch = |
| 222 | branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; | 232 | branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; |
| ... | @@ -295,16 +305,14 @@ export function BranchToolbarBranchSelector({ | ... | @@ -295,16 +305,14 @@ export function BranchToolbarBranchSelector({ |
| 295 | // --------------------------------------------------------------------------- | 305 | // --------------------------------------------------------------------------- |
| 296 | const runBranchAction = (action: () => Promise<void>) => { | 306 | const runBranchAction = (action: () => Promise<void>) => { |
| 297 | startBranchActionTransition(async () => { | 307 | startBranchActionTransition(async () => { |
| 298 | await action().catch(() => undefined); | 308 | await action(); |
| 299 | await vcsRefManager | 309 | branchRefState.refresh(); |
| 300 | .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) | 310 | branchStatusQuery.refresh(); |
| 301 | .catch(() => undefined); | ||
| 302 | }); | 311 | }); |
| 303 | }; | 312 | }; |
| 304 | 313 | ||
| 305 | const selectBranch = (refName: VcsRef) => { | 314 | const selectBranch = (refName: VcsRef) => { |
| 306 | const api = readEnvironmentApi(environmentId); | 315 | if (!branchCwd || !activeProjectCwd || isBranchActionPending) return; |
| 307 | if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; | ||
| 308 | 316 | ||
| 309 | if (isSelectingWorktreeBase) { | 317 | if (isSelectingWorktreeBase) { |
| 310 | setThreadBranch(refName.name, null); | 318 | setThreadBranch(refName.name, null); |
| ... | @@ -336,23 +344,28 @@ export function BranchToolbarBranchSelector({ | ... | @@ -336,23 +344,28 @@ export function BranchToolbarBranchSelector({ |
| 336 | runBranchAction(async () => { | 344 | runBranchAction(async () => { |
| 337 | const previousBranch = resolvedActiveBranch; | 345 | const previousBranch = resolvedActiveBranch; |
| 338 | setOptimisticBranch(selectedBranchName); | 346 | setOptimisticBranch(selectedBranchName); |
| 339 | try { | 347 | const checkoutResult = await switchRef({ |
| 340 | const checkoutResult = await api.vcs.switchRef({ | 348 | environmentId, |
| 349 | input: { | ||
| 341 | cwd: selectionTarget.checkoutCwd, | 350 | cwd: selectionTarget.checkoutCwd, |
| 342 | refName: refName.name, | 351 | refName: refName.name, |
| 343 | }); | 352 | }, |
| 353 | }); | ||
| 354 | if (checkoutResult._tag === "Success") { | ||
| 344 | const nextBranchName = refName.isRemote | 355 | const nextBranchName = refName.isRemote |
| 345 | ? (checkoutResult.refName ?? selectedBranchName) | 356 | ? (checkoutResult.value.refName ?? selectedBranchName) |
| 346 | : selectedBranchName; | 357 | : selectedBranchName; |
| 347 | setOptimisticBranch(nextBranchName); | 358 | setOptimisticBranch(nextBranchName); |
| 348 | setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); | 359 | setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); |
| 349 | } catch (error) { | 360 | return; |
| 350 | setOptimisticBranch(previousBranch); | 361 | } |
| 362 | setOptimisticBranch(previousBranch); | ||
| 363 | if (!isAtomCommandInterrupted(checkoutResult)) { | ||
| 351 | toastManager.add( | 364 | toastManager.add( |
| 352 | stackedThreadToast({ | 365 | stackedThreadToast({ |
| 353 | type: "error", | 366 | type: "error", |
| 354 | title: "Failed to switch ref.", | 367 | title: "Failed to switch ref.", |
| 355 | description: toBranchActionErrorMessage(error), | 368 | description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)), |
| 356 | }), | 369 | }), |
| 357 | ); | 370 | ); |
| 358 | } | 371 | } |
| ... | @@ -361,8 +374,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -361,8 +374,7 @@ export function BranchToolbarBranchSelector({ |
| 361 | 374 | ||
| 362 | const createRef = (rawName: string) => { | 375 | const createRef = (rawName: string) => { |
| 363 | const name = rawName.trim(); | 376 | const name = rawName.trim(); |
| 364 | const api = readEnvironmentApi(environmentId); | 377 | if (!branchCwd || !name || isBranchActionPending) return; |
| 365 | if (!api || !branchCwd || !name || isBranchActionPending) return; | ||
| 366 | 378 | ||
| 367 | setIsBranchMenuOpen(false); | 379 | setIsBranchMenuOpen(false); |
| 368 | onComposerFocusRequest?.(); | 380 | onComposerFocusRequest?.(); |
| ... | @@ -370,21 +382,26 @@ export function BranchToolbarBranchSelector({ | ... | @@ -370,21 +382,26 @@ export function BranchToolbarBranchSelector({ |
| 370 | runBranchAction(async () => { | 382 | runBranchAction(async () => { |
| 371 | const previousBranch = resolvedActiveBranch; | 383 | const previousBranch = resolvedActiveBranch; |
| 372 | setOptimisticBranch(name); | 384 | setOptimisticBranch(name); |
| 373 | try { | 385 | const createBranchResult = await createRefMutation({ |
| 374 | const createBranchResult = await api.vcs.createRef({ | 386 | environmentId, |
| 387 | input: { | ||
| 375 | cwd: branchCwd, | 388 | cwd: branchCwd, |
| 376 | refName: name, | 389 | refName: name, |
| 377 | switchRef: true, | 390 | switchRef: true, |
| 378 | }); | 391 | }, |
| 379 | setOptimisticBranch(createBranchResult.refName); | 392 | }); |
| 380 | setThreadBranch(createBranchResult.refName, activeWorktreePath); | 393 | if (createBranchResult._tag === "Success") { |
| 381 | } catch (error) { | 394 | setOptimisticBranch(createBranchResult.value.refName); |
| 382 | setOptimisticBranch(previousBranch); | 395 | setThreadBranch(createBranchResult.value.refName, activeWorktreePath); |
| 396 | return; | ||
| 397 | } | ||
| 398 | setOptimisticBranch(previousBranch); | ||
| 399 | if (!isAtomCommandInterrupted(createBranchResult)) { | ||
| 383 | toastManager.add( | 400 | toastManager.add( |
| 384 | stackedThreadToast({ | 401 | stackedThreadToast({ |
| 385 | type: "error", | 402 | type: "error", |
| 386 | title: "Failed to create and switch ref.", | 403 | title: "Failed to create and switch ref.", |
| 387 | description: toBranchActionErrorMessage(error), | 404 | description: toBranchActionErrorMessage(squashAtomCommandFailure(createBranchResult)), |
| 388 | }), | 405 | }), |
| 389 | ); | 406 | ); |
| 390 | } | 407 | } |
| ... | @@ -413,11 +430,9 @@ export function BranchToolbarBranchSelector({ | ... | @@ -413,11 +430,9 @@ export function BranchToolbarBranchSelector({ |
| 413 | setBranchQuery(""); | 430 | setBranchQuery(""); |
| 414 | return; | 431 | return; |
| 415 | } | 432 | } |
| 416 | void vcsRefManager | 433 | branchRefState.refresh(); |
| 417 | .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) | ||
| 418 | .catch(() => undefined); | ||
| 419 | }, | 434 | }, |
| 420 | [branchRefTarget], | 435 | [branchRefState.refresh], |
| 421 | ); | 436 | ); |
| 422 | 437 | ||
| 423 | const branchListScrollElementRef = useRef<HTMLElement | null>(null); | 438 | const branchListScrollElementRef = useRef<HTMLElement | null>(null); |
| ... | @@ -428,12 +443,8 @@ export function BranchToolbarBranchSelector({ | ... | @@ -428,12 +443,8 @@ export function BranchToolbarBranchSelector({ |
| 428 | return; | 443 | return; |
| 429 | } | 444 | } |
| 430 | 445 | ||
| 431 | setIsFetchingNextPage(true); | 446 | branchRefState.loadNext(); |
| 432 | void vcsRefManager | 447 | }, [branchRefState.loadNext, hasNextPage, isFetchingNextPage]); |
| 433 | .loadNext(branchRefTarget, undefined, { limit: 100 }) | ||
| 434 | .catch(() => undefined) | ||
| 435 | .finally(() => setIsFetchingNextPage(false)); | ||
| 436 | }, [branchRefTarget, hasNextPage, isFetchingNextPage]); | ||
| 437 | const maybeFetchNextBranchPage = useCallback(() => { | 448 | const maybeFetchNextBranchPage = useCallback(() => { |
| 438 | if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { | 449 | if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { |
| 439 | return; | 450 | return; |
| ... | @@ -465,14 +476,6 @@ export function BranchToolbarBranchSelector({ | ... | @@ -465,14 +476,6 @@ export function BranchToolbarBranchSelector({ |
| 465 | setShowBottomBranchScrollFade(maxScrollOffset - scrollElement.scrollTop > 1); | 476 | setShowBottomBranchScrollFade(maxScrollOffset - scrollElement.scrollTop > 1); |
| 466 | }, []); | 477 | }, []); |
| 467 | 478 | ||
| 468 | useEffect(() => { | ||
| 469 | if (isBranchMenuOpen) { | ||
| 470 | return; | ||
| 471 | } | ||
| 472 | setShowTopBranchScrollFade(false); | ||
| 473 | setShowBottomBranchScrollFade(false); | ||
| 474 | }, [isBranchMenuOpen]); | ||
| 475 | |||
| 476 | useLayoutEffect(() => { | 479 | useLayoutEffect(() => { |
| 477 | if (!isBranchMenuOpen) { | 480 | if (!isBranchMenuOpen) { |
| 478 | return; | 481 | return; |
apps/web/src/components/ChatMarkdown.browser.tsx deleted-892| ... | @@ -1,892 +0,0 @@ | ||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { page } from "vite-plus/test/browser"; | ||
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | import { render } from "vitest-browser-react"; | ||
| 6 | |||
| 7 | const { | ||
| 8 | contextMenuShowMock, | ||
| 9 | openFileInPreviewMock, | ||
| 10 | openInPreferredEditorMock, | ||
| 11 | openUrlInPreviewMock, | ||
| 12 | readLocalApiMock, | ||
| 13 | } = vi.hoisted(() => ({ | ||
| 14 | contextMenuShowMock: vi.fn(), | ||
| 15 | openFileInPreviewMock: vi.fn(async () => undefined), | ||
| 16 | openInPreferredEditorMock: vi.fn(async () => "vscode"), | ||
| 17 | openUrlInPreviewMock: vi.fn(async () => undefined), | ||
| 18 | readLocalApiMock: vi.fn(() => ({ | ||
| 19 | contextMenu: { show: contextMenuShowMock }, | ||
| 20 | server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, | ||
| 21 | shell: { | ||
| 22 | openExternal: vi.fn(async () => undefined), | ||
| 23 | openInEditor: vi.fn(async () => undefined), | ||
| 24 | }, | ||
| 25 | })), | ||
| 26 | })); | ||
| 27 | |||
| 28 | vi.mock("../editorPreferences", () => ({ | ||
| 29 | openInPreferredEditor: openInPreferredEditorMock, | ||
| 30 | })); | ||
| 31 | |||
| 32 | vi.mock("../localApi", () => ({ | ||
| 33 | ensureLocalApi: vi.fn(() => { | ||
| 34 | throw new Error("ensureLocalApi not implemented in browser test"); | ||
| 35 | }), | ||
| 36 | readLocalApi: readLocalApiMock, | ||
| 37 | })); | ||
| 38 | |||
| 39 | vi.mock("../previewStateStore", async (importOriginal) => ({ | ||
| 40 | ...(await importOriginal<typeof import("../previewStateStore")>()), | ||
| 41 | isPreviewSupportedInRuntime: () => true, | ||
| 42 | })); | ||
| 43 | |||
| 44 | vi.mock("../browser/openFileInPreview", async (importOriginal) => ({ | ||
| 45 | ...(await importOriginal<typeof import("../browser/openFileInPreview")>()), | ||
| 46 | openFileInPreview: openFileInPreviewMock, | ||
| 47 | openUrlInPreview: openUrlInPreviewMock, | ||
| 48 | })); | ||
| 49 | |||
| 50 | import ChatMarkdown from "./ChatMarkdown"; | ||
| 51 | import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; | ||
| 52 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 53 | import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; | ||
| 54 | |||
| 55 | const threadRef = { | ||
| 56 | environmentId: EnvironmentId.make("environment-test"), | ||
| 57 | threadId: ThreadId.make("thread-test"), | ||
| 58 | }; | ||
| 59 | |||
| 60 | describe("ChatMarkdown", () => { | ||
| 61 | afterEach(() => { | ||
| 62 | openInPreferredEditorMock.mockClear(); | ||
| 63 | openFileInPreviewMock.mockClear(); | ||
| 64 | openUrlInPreviewMock.mockClear(); | ||
| 65 | contextMenuShowMock.mockReset(); | ||
| 66 | readLocalApiMock.mockClear(); | ||
| 67 | useRightPanelStore.setState({ byThreadKey: {} }); | ||
| 68 | localStorage.clear(); | ||
| 69 | document.body.innerHTML = ""; | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("makes task-list checkboxes interactive when a change handler is provided", async () => { | ||
| 73 | const onTaskListChange = vi.fn(); | ||
| 74 | const screen = await render( | ||
| 75 | <ChatMarkdown | ||
| 76 | text={"- [ ] Ship it"} | ||
| 77 | cwd="/repo/project" | ||
| 78 | onTaskListChange={onTaskListChange} | ||
| 79 | />, | ||
| 80 | ); | ||
| 81 | |||
| 82 | try { | ||
| 83 | const checkbox = page.getByRole("checkbox", { name: "Toggle task" }); | ||
| 84 | await expect.element(checkbox).not.toBeDisabled(); | ||
| 85 | await checkbox.click(); | ||
| 86 | expect(onTaskListChange).toHaveBeenCalledWith({ markerOffset: 2, checked: true }); | ||
| 87 | } finally { | ||
| 88 | await screen.unmount(); | ||
| 89 | } | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("rewrites file uri hrefs into direct paths before rendering", async () => { | ||
| 93 | const filePath = | ||
| 94 | "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; | ||
| 95 | const screen = await render( | ||
| 96 | <ChatMarkdown text={`[PermissionRule.ts](file://${filePath})`} cwd="/repo/project" />, | ||
| 97 | ); | ||
| 98 | |||
| 99 | try { | ||
| 100 | const link = page.getByRole("link", { name: "PermissionRule.ts" }); | ||
| 101 | await expect.element(link).toBeInTheDocument(); | ||
| 102 | await expect.element(link).toHaveAttribute("href", filePath); | ||
| 103 | |||
| 104 | await link.click(); | ||
| 105 | |||
| 106 | await vi.waitFor(() => { | ||
| 107 | expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath); | ||
| 108 | }); | ||
| 109 | } finally { | ||
| 110 | await screen.unmount(); | ||
| 111 | } | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("keeps line anchors working after rewriting file uri hrefs", async () => { | ||
| 115 | const filePath = | ||
| 116 | "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; | ||
| 117 | const screen = await render( | ||
| 118 | <ChatMarkdown text={`[PermissionRule.ts:1](file://${filePath}#L1)`} cwd="/repo/project" />, | ||
| 119 | ); | ||
| 120 | |||
| 121 | try { | ||
| 122 | const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); | ||
| 123 | await expect.element(link).toBeInTheDocument(); | ||
| 124 | await expect.element(link).toHaveAttribute("href", `${filePath}:1`); | ||
| 125 | |||
| 126 | await link.click(); | ||
| 127 | |||
| 128 | await vi.waitFor(() => { | ||
| 129 | expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:1`); | ||
| 130 | }); | ||
| 131 | } finally { | ||
| 132 | await screen.unmount(); | ||
| 133 | } | ||
| 134 | }); | ||
| 135 | |||
| 136 | it("shows column information inline when present", async () => { | ||
| 137 | const filePath = | ||
| 138 | "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; | ||
| 139 | const screen = await render( | ||
| 140 | <ChatMarkdown text={`[PermissionRule.ts](file://${filePath}#L1C7)`} cwd="/repo/project" />, | ||
| 141 | ); | ||
| 142 | |||
| 143 | try { | ||
| 144 | const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); | ||
| 145 | await expect.element(link).toBeInTheDocument(); | ||
| 146 | await expect.element(link).toHaveAttribute("href", `${filePath}:1:7`); | ||
| 147 | |||
| 148 | await link.click(); | ||
| 149 | |||
| 150 | await vi.waitFor(() => { | ||
| 151 | expect(openInPreferredEditorMock).toHaveBeenCalledWith( | ||
| 152 | expect.anything(), | ||
| 153 | `${filePath}:1:7`, | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | } finally { | ||
| 157 | await screen.unmount(); | ||
| 158 | } | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("disambiguates duplicate file basenames inline", async () => { | ||
| 162 | const firstPath = "/Users/yashsingh/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx"; | ||
| 163 | const secondPath = "/Users/yashsingh/p/t3code/apps/web/src/components/MessagesTimeline.tsx"; | ||
| 164 | const screen = await render( | ||
| 165 | <ChatMarkdown | ||
| 166 | text={`See [MessagesTimeline.tsx](file://${firstPath}) and [MessagesTimeline.tsx](file://${secondPath}).`} | ||
| 167 | cwd="/repo/project" | ||
| 168 | />, | ||
| 169 | ); | ||
| 170 | |||
| 171 | try { | ||
| 172 | await expect | ||
| 173 | .element(page.getByRole("link", { name: "MessagesTimeline.tsx · components/chat" })) | ||
| 174 | .toBeInTheDocument(); | ||
| 175 | await expect | ||
| 176 | .element(page.getByRole("link", { name: "MessagesTimeline.tsx · src/components" })) | ||
| 177 | .toBeInTheDocument(); | ||
| 178 | } finally { | ||
| 179 | await screen.unmount(); | ||
| 180 | } | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("keeps normal web links unchanged", async () => { | ||
| 184 | const screen = await render( | ||
| 185 | <ChatMarkdown text="[OpenAI](https://openai.com/docs)" cwd="/repo/project" />, | ||
| 186 | ); | ||
| 187 | |||
| 188 | try { | ||
| 189 | const link = page.getByRole("link", { name: "OpenAI" }); | ||
| 190 | await expect.element(link).toBeInTheDocument(); | ||
| 191 | await expect.element(link).toHaveAttribute("href", "https://openai.com/docs"); | ||
| 192 | await expect.element(link).toHaveAttribute("target", "_blank"); | ||
| 193 | const favicon = link.element().querySelector<HTMLElement>(".chat-markdown-link-favicon"); | ||
| 194 | const leading = link.element().querySelector<HTMLElement>(".chat-markdown-link-leading"); | ||
| 195 | expect(favicon).not.toBeNull(); | ||
| 196 | expect(leading).not.toBeNull(); | ||
| 197 | expect(leading?.contains(favicon)).toBe(true); | ||
| 198 | expect(getComputedStyle(leading!).display).toBe("inline"); | ||
| 199 | expect(getComputedStyle(leading!).whiteSpace).toBe("nowrap"); | ||
| 200 | expect(getComputedStyle(favicon!).verticalAlign).not.toBe("baseline"); | ||
| 201 | expect(leading?.textContent).toBe("O"); | ||
| 202 | expect(link.element().textContent).toBe("OpenAI"); | ||
| 203 | expect(getComputedStyle(link.element()).textDecorationLine).toBe("none"); | ||
| 204 | expect(link.element().querySelector("img, svg")?.getBoundingClientRect().width).toBe(14); | ||
| 205 | await link.hover(); | ||
| 206 | expect(getComputedStyle(link.element()).backgroundImage).not.toBe("none"); | ||
| 207 | await expect.element(page.getByText("https://openai.com/docs")).toBeVisible(); | ||
| 208 | } finally { | ||
| 209 | await screen.unmount(); | ||
| 210 | } | ||
| 211 | }); | ||
| 212 | |||
| 213 | it("opens web links in the integrated browser from the context menu", async () => { | ||
| 214 | contextMenuShowMock.mockResolvedValue("open-in-browser"); | ||
| 215 | const screen = await render( | ||
| 216 | <ChatMarkdown | ||
| 217 | text="[OpenAI](https://openai.com/docs)" | ||
| 218 | cwd="/repo/project" | ||
| 219 | threadRef={threadRef} | ||
| 220 | />, | ||
| 221 | ); | ||
| 222 | |||
| 223 | try { | ||
| 224 | const link = page.getByRole("link", { name: "OpenAI" }).element(); | ||
| 225 | link.dispatchEvent( | ||
| 226 | new MouseEvent("contextmenu", { | ||
| 227 | bubbles: true, | ||
| 228 | cancelable: true, | ||
| 229 | clientX: 12, | ||
| 230 | clientY: 24, | ||
| 231 | }), | ||
| 232 | ); | ||
| 233 | |||
| 234 | await vi.waitFor(() => { | ||
| 235 | expect(contextMenuShowMock).toHaveBeenCalled(); | ||
| 236 | expect(openUrlInPreviewMock).toHaveBeenCalledWith(threadRef, "https://openai.com/docs"); | ||
| 237 | }); | ||
| 238 | } finally { | ||
| 239 | await screen.unmount(); | ||
| 240 | } | ||
| 241 | }); | ||
| 242 | |||
| 243 | it("offers integrated browser opening for HTML file links", async () => { | ||
| 244 | contextMenuShowMock.mockResolvedValue("open-in-browser"); | ||
| 245 | const filePath = "/repo/project/report.html"; | ||
| 246 | const screen = await render( | ||
| 247 | <ChatMarkdown text="[report.html](report.html)" cwd="/repo/project" threadRef={threadRef} />, | ||
| 248 | ); | ||
| 249 | |||
| 250 | try { | ||
| 251 | const link = page.getByRole("link", { name: "report.html" }).element(); | ||
| 252 | link.dispatchEvent( | ||
| 253 | new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }), | ||
| 254 | ); | ||
| 255 | |||
| 256 | await vi.waitFor(() => { | ||
| 257 | expect(contextMenuShowMock).toHaveBeenCalledWith( | ||
| 258 | expect.arrayContaining([ | ||
| 259 | expect.objectContaining({ | ||
| 260 | id: "open-in-browser", | ||
| 261 | label: "Open in integrated browser", | ||
| 262 | }), | ||
| 263 | ]), | ||
| 264 | { x: 4, y: 8 }, | ||
| 265 | ); | ||
| 266 | expect(openFileInPreviewMock).toHaveBeenCalledWith(threadRef, filePath); | ||
| 267 | }); | ||
| 268 | } finally { | ||
| 269 | await screen.unmount(); | ||
| 270 | } | ||
| 271 | }); | ||
| 272 | |||
| 273 | it("opens code file links in the right-panel file preview", async () => { | ||
| 274 | const screen = await render( | ||
| 275 | <ChatMarkdown | ||
| 276 | text="[ChatMarkdown.tsx](apps/web/src/components/ChatMarkdown.tsx#L978)" | ||
| 277 | cwd="/repo/project" | ||
| 278 | threadRef={threadRef} | ||
| 279 | />, | ||
| 280 | ); | ||
| 281 | |||
| 282 | try { | ||
| 283 | await page.getByRole("link", { name: "ChatMarkdown.tsx · L978" }).click(); | ||
| 284 | |||
| 285 | await vi.waitFor(() => { | ||
| 286 | expect( | ||
| 287 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef), | ||
| 288 | ).toMatchObject({ | ||
| 289 | isOpen: true, | ||
| 290 | activeSurfaceId: "file:apps/web/src/components/ChatMarkdown.tsx", | ||
| 291 | surfaces: [ | ||
| 292 | expect.objectContaining({ | ||
| 293 | relativePath: "apps/web/src/components/ChatMarkdown.tsx", | ||
| 294 | revealLine: 978, | ||
| 295 | revealRequestId: 1, | ||
| 296 | }), | ||
| 297 | ], | ||
| 298 | }); | ||
| 299 | expect(openInPreferredEditorMock).not.toHaveBeenCalled(); | ||
| 300 | expect(openFileInPreviewMock).not.toHaveBeenCalled(); | ||
| 301 | }); | ||
| 302 | } finally { | ||
| 303 | await screen.unmount(); | ||
| 304 | } | ||
| 305 | }); | ||
| 306 | |||
| 307 | it("opens HTML and PDF file links in the integrated browser preview", async () => { | ||
| 308 | const screen = await render( | ||
| 309 | <ChatMarkdown | ||
| 310 | text="[report.html](report.html) [report.pdf](report.pdf)" | ||
| 311 | cwd="/repo/project" | ||
| 312 | threadRef={threadRef} | ||
| 313 | />, | ||
| 314 | ); | ||
| 315 | |||
| 316 | try { | ||
| 317 | await page.getByRole("link", { name: "report.html" }).click(); | ||
| 318 | await page.getByRole("link", { name: "report.pdf" }).click(); | ||
| 319 | |||
| 320 | await vi.waitFor(() => { | ||
| 321 | expect(openFileInPreviewMock).toHaveBeenNthCalledWith( | ||
| 322 | 1, | ||
| 323 | threadRef, | ||
| 324 | "/repo/project/report.html", | ||
| 325 | ); | ||
| 326 | expect(openFileInPreviewMock).toHaveBeenNthCalledWith( | ||
| 327 | 2, | ||
| 328 | threadRef, | ||
| 329 | "/repo/project/report.pdf", | ||
| 330 | ); | ||
| 331 | expect(openInPreferredEditorMock).not.toHaveBeenCalled(); | ||
| 332 | }); | ||
| 333 | } finally { | ||
| 334 | await screen.unmount(); | ||
| 335 | } | ||
| 336 | }); | ||
| 337 | |||
| 338 | it("keeps opening file links in the editor from the context menu", async () => { | ||
| 339 | contextMenuShowMock.mockResolvedValue("open"); | ||
| 340 | const filePath = "/repo/project/src/index.ts"; | ||
| 341 | const screen = await render( | ||
| 342 | <ChatMarkdown text="[index.ts](src/index.ts)" cwd="/repo/project" threadRef={threadRef} />, | ||
| 343 | ); | ||
| 344 | |||
| 345 | try { | ||
| 346 | page | ||
| 347 | .getByRole("link", { name: "index.ts" }) | ||
| 348 | .element() | ||
| 349 | .dispatchEvent( | ||
| 350 | new MouseEvent("contextmenu", { | ||
| 351 | bubbles: true, | ||
| 352 | cancelable: true, | ||
| 353 | clientX: 4, | ||
| 354 | clientY: 8, | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | await vi.waitFor(() => { | ||
| 359 | expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath); | ||
| 360 | }); | ||
| 361 | } finally { | ||
| 362 | await screen.unmount(); | ||
| 363 | } | ||
| 364 | }); | ||
| 365 | |||
| 366 | it("keeps a favicon with the leading segment of a wrapping URL", async () => { | ||
| 367 | const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; | ||
| 368 | const screen = await render( | ||
| 369 | <div style={{ width: 180 }}> | ||
| 370 | <ChatMarkdown text={`[${url}](${url})`} cwd="/repo/project" /> | ||
| 371 | </div>, | ||
| 372 | ); | ||
| 373 | |||
| 374 | try { | ||
| 375 | const link = page.getByRole("link", { name: url }); | ||
| 376 | const leading = link.element().querySelector<HTMLElement>(".chat-markdown-link-leading"); | ||
| 377 | const favicon = link.element().querySelector<HTMLElement>(".chat-markdown-link-favicon"); | ||
| 378 | expect(leading).not.toBeNull(); | ||
| 379 | expect(favicon).not.toBeNull(); | ||
| 380 | expect(leading?.contains(favicon)).toBe(true); | ||
| 381 | expect(leading?.textContent).toBe("https://"); | ||
| 382 | expect(getComputedStyle(leading!).display).toBe("inline"); | ||
| 383 | expect(getComputedStyle(leading!).whiteSpace).toBe("nowrap"); | ||
| 384 | expect(getComputedStyle(favicon!).verticalAlign).not.toBe("baseline"); | ||
| 385 | expect(link.element().textContent).toBe(url); | ||
| 386 | expect(link.element().querySelectorAll("wbr").length).toBeGreaterThan(0); | ||
| 387 | const markdownRoot = link.element().closest<HTMLElement>(".chat-markdown"); | ||
| 388 | expect(markdownRoot).not.toBeNull(); | ||
| 389 | expect(markdownRoot!.scrollWidth).toBeLessThanOrEqual(markdownRoot!.clientWidth); | ||
| 390 | } finally { | ||
| 391 | await screen.unmount(); | ||
| 392 | } | ||
| 393 | }); | ||
| 394 | |||
| 395 | it("renders file links with the shared file tag chip treatment", async () => { | ||
| 396 | const screen = await render( | ||
| 397 | <ChatMarkdown text="[package.json](path/to/package.json)" cwd="/repo/project" />, | ||
| 398 | ); | ||
| 399 | |||
| 400 | try { | ||
| 401 | const link = page.getByRole("link", { name: "package.json" }); | ||
| 402 | await expect.element(link).toHaveClass(/chat-markdown-file-link/); | ||
| 403 | const element = document.querySelector<HTMLElement>(".chat-markdown-file-link"); | ||
| 404 | expect(element?.querySelector("img, svg")).not.toBeNull(); | ||
| 405 | expect(getComputedStyle(element!).display).toBe("inline-flex"); | ||
| 406 | expect(getComputedStyle(element!).textDecorationLine).toBe("none"); | ||
| 407 | expect(getComputedStyle(element!).borderStyle).toBe("solid"); | ||
| 408 | expect(getComputedStyle(element!).userSelect).not.toBe("none"); | ||
| 409 | } finally { | ||
| 410 | await screen.unmount(); | ||
| 411 | } | ||
| 412 | }); | ||
| 413 | |||
| 414 | it("renders sanitized details with the design-system collapsible", async () => { | ||
| 415 | const source = [ | ||
| 416 | "<details open>", | ||
| 417 | "<summary>Expandable details section</summary>", | ||
| 418 | "", | ||
| 419 | "This content includes **formatted text**.", | ||
| 420 | "", | ||
| 421 | '<span title="native tooltip should be stripped">Safe inline HTML</span>', | ||
| 422 | "<script>window.__unsafeMarkdownScript = true</script>", | ||
| 423 | "</details>", | ||
| 424 | ].join("\n"); | ||
| 425 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 426 | |||
| 427 | try { | ||
| 428 | const details = document.querySelector<HTMLElement>("[data-markdown-details]"); | ||
| 429 | const trigger = page.getByRole("button", { name: "Expandable details section" }); | ||
| 430 | expect(details).not.toBeNull(); | ||
| 431 | expect(details?.tagName).toBe("DIV"); | ||
| 432 | await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); | ||
| 433 | expect(details?.querySelector("strong")?.textContent).toBe("formatted text"); | ||
| 434 | expect(details?.querySelector("script")).toBeNull(); | ||
| 435 | expect(details?.querySelector("[title]")).toBeNull(); | ||
| 436 | |||
| 437 | await trigger.click(); | ||
| 438 | await expect.element(trigger).toHaveAttribute("aria-expanded", "false"); | ||
| 439 | await trigger.click(); | ||
| 440 | await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); | ||
| 441 | } finally { | ||
| 442 | await screen.unmount(); | ||
| 443 | } | ||
| 444 | }); | ||
| 445 | |||
| 446 | it("renders footnotes as same-document references", async () => { | ||
| 447 | const source = [ | ||
| 448 | "A claim with supporting context.[^context]", | ||
| 449 | "", | ||
| 450 | "[^context]: Supporting **footnote text**.", | ||
| 451 | ].join("\n"); | ||
| 452 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 453 | |||
| 454 | try { | ||
| 455 | const reference = document.querySelector<HTMLAnchorElement>( | ||
| 456 | '.chat-markdown a[data-footnote-ref=""]', | ||
| 457 | ); | ||
| 458 | const footnotes = document.querySelector<HTMLElement>( | ||
| 459 | ".chat-markdown section[data-footnotes]", | ||
| 460 | ); | ||
| 461 | expect(reference).not.toBeNull(); | ||
| 462 | expect(reference?.getAttribute("href")).toMatch(/^#user-content-fn-/); | ||
| 463 | expect(reference?.hasAttribute("target")).toBe(false); | ||
| 464 | expect(footnotes).not.toBeNull(); | ||
| 465 | expect(footnotes?.querySelector("strong")?.textContent).toBe("footnote text"); | ||
| 466 | expect(footnotes?.querySelector<HTMLAnchorElement>("a[data-footnote-backref]")?.target).toBe( | ||
| 467 | "", | ||
| 468 | ); | ||
| 469 | } finally { | ||
| 470 | await screen.unmount(); | ||
| 471 | } | ||
| 472 | }); | ||
| 473 | |||
| 474 | it("navigates hash links within the clicked markdown message", async () => { | ||
| 475 | const source = [ | ||
| 476 | "A claim with supporting context.[^context]", | ||
| 477 | "", | ||
| 478 | "[^context]: Supporting footnote text.", | ||
| 479 | ].join("\n"); | ||
| 480 | const originalUrl = window.location.href; | ||
| 481 | const scrollIntoView = vi | ||
| 482 | .spyOn(HTMLElement.prototype, "scrollIntoView") | ||
| 483 | .mockImplementation(() => undefined); | ||
| 484 | const screen = await render( | ||
| 485 | <div> | ||
| 486 | <ChatMarkdown text={source} cwd="/repo/project" /> | ||
| 487 | <ChatMarkdown text={source} cwd="/repo/project" /> | ||
| 488 | </div>, | ||
| 489 | ); | ||
| 490 | |||
| 491 | try { | ||
| 492 | const markdownRoots = document.querySelectorAll<HTMLElement>(".chat-markdown"); | ||
| 493 | const secondRoot = markdownRoots[1]; | ||
| 494 | const secondReference = | ||
| 495 | secondRoot?.querySelector<HTMLAnchorElement>('a[data-footnote-ref=""]'); | ||
| 496 | const secondFootnote = secondRoot?.querySelector<HTMLElement>( | ||
| 497 | "section[data-footnotes] li[id]", | ||
| 498 | ); | ||
| 499 | expect(secondReference).not.toBeNull(); | ||
| 500 | expect(secondFootnote).not.toBeNull(); | ||
| 501 | |||
| 502 | secondReference?.click(); | ||
| 503 | |||
| 504 | expect(scrollIntoView).toHaveBeenCalledTimes(1); | ||
| 505 | expect(scrollIntoView.mock.instances[0]).toBe(secondFootnote); | ||
| 506 | expect(window.location.hash).toBe(secondReference?.hash); | ||
| 507 | |||
| 508 | const secondBackref = secondRoot?.querySelector<HTMLAnchorElement>( | ||
| 509 | "a[data-footnote-backref]", | ||
| 510 | ); | ||
| 511 | expect(secondBackref).not.toBeNull(); | ||
| 512 | secondBackref?.click(); | ||
| 513 | |||
| 514 | const secondReferenceTarget = secondReference?.closest<HTMLElement>("[id]"); | ||
| 515 | expect(scrollIntoView).toHaveBeenCalledTimes(2); | ||
| 516 | expect(scrollIntoView.mock.instances[1]).toBe(secondReferenceTarget); | ||
| 517 | } finally { | ||
| 518 | scrollIntoView.mockRestore(); | ||
| 519 | window.history.replaceState(window.history.state, "", originalUrl); | ||
| 520 | await screen.unmount(); | ||
| 521 | } | ||
| 522 | }); | ||
| 523 | |||
| 524 | describe("code block chrome", () => { | ||
| 525 | it("shows icon-only language titles, text fallbacks, and filename overrides", async () => { | ||
| 526 | const source = [ | ||
| 527 | "```ts", | ||
| 528 | "const a = 1;", | ||
| 529 | "```", | ||
| 530 | "", | ||
| 531 | '```ts title="src/main.ts"', | ||
| 532 | "const b = 2;", | ||
| 533 | "```", | ||
| 534 | "", | ||
| 535 | "```text", | ||
| 536 | "plain", | ||
| 537 | "```", | ||
| 538 | ].join("\n"); | ||
| 539 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 540 | |||
| 541 | try { | ||
| 542 | const titles = [...document.querySelectorAll(".chat-markdown-codeblock-title")]; | ||
| 543 | expect(titles).toHaveLength(3); | ||
| 544 | |||
| 545 | // Language with a known icon: icon XOR text — never the redundant pair. | ||
| 546 | const languageOnly = titles[0]!; | ||
| 547 | const hasIcon = languageOnly.querySelector("svg[data-pierre-icon]") != null; | ||
| 548 | const hasText = (languageOnly.textContent ?? "").includes("ts"); | ||
| 549 | expect(hasIcon || hasText).toBe(true); | ||
| 550 | expect(hasIcon && hasText).toBe(false); | ||
| 551 | if (hasIcon) { | ||
| 552 | const languageTrigger = page.getByLabelText("Language: ts").first(); | ||
| 553 | await languageTrigger.hover(); | ||
| 554 | await vi.waitFor(() => { | ||
| 555 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | ||
| 556 | expect(tooltip?.textContent).toContain("ts"); | ||
| 557 | }); | ||
| 558 | } | ||
| 559 | |||
| 560 | // Explicit filename: text always shown. | ||
| 561 | expect(titles[1]!.textContent).toBe("src/main.ts"); | ||
| 562 | |||
| 563 | // Unknown language: no icon attempt, text label. | ||
| 564 | expect(titles[2]!.querySelector("svg[data-pierre-icon]")).toBeNull(); | ||
| 565 | expect(titles[2]!.textContent).toBe("text"); | ||
| 566 | } finally { | ||
| 567 | await screen.unmount(); | ||
| 568 | } | ||
| 569 | }); | ||
| 570 | |||
| 571 | it("toggles line wrapping per block", async () => { | ||
| 572 | const screen = await render( | ||
| 573 | <ChatMarkdown text={'```ts\nconst x = "long";\n```'} cwd="/repo/project" />, | ||
| 574 | ); | ||
| 575 | |||
| 576 | try { | ||
| 577 | const block = document.querySelector(".chat-markdown-codeblock"); | ||
| 578 | expect(block?.getAttribute("data-wrap")).toBe("false"); | ||
| 579 | |||
| 580 | const toggle = page.getByRole("button", { name: "Wrap lines" }); | ||
| 581 | await expect.element(toggle).not.toHaveAttribute("title"); | ||
| 582 | await toggle.hover(); | ||
| 583 | await vi.waitFor(() => { | ||
| 584 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | ||
| 585 | expect(tooltip?.textContent).toContain("Wrap lines"); | ||
| 586 | }); | ||
| 587 | await toggle.click(); | ||
| 588 | expect(block?.getAttribute("data-wrap")).toBe("true"); | ||
| 589 | |||
| 590 | await page.getByRole("button", { name: "Disable line wrap" }).click(); | ||
| 591 | expect(block?.getAttribute("data-wrap")).toBe("false"); | ||
| 592 | } finally { | ||
| 593 | await screen.unmount(); | ||
| 594 | } | ||
| 595 | }); | ||
| 596 | }); | ||
| 597 | |||
| 598 | it("scrolls wide tables horizontally instead of letter-wrapping cells", async () => { | ||
| 599 | const header = `| ${Array.from({ length: 8 }, (_, i) => `ColumnHeading${i}`).join(" | ")} |`; | ||
| 600 | const separator = `| ${Array.from({ length: 8 }, () => "---").join(" | ")} |`; | ||
| 601 | const row = `| ${Array.from({ length: 8 }, () => "averylongunbrokencellvalue@example-domain.com").join(" | ")} |`; | ||
| 602 | const screen = await render( | ||
| 603 | <ChatMarkdown text={[header, separator, row].join("\n")} cwd="/repo/project" />, | ||
| 604 | ); | ||
| 605 | |||
| 606 | try { | ||
| 607 | const viewport = document.querySelector( | ||
| 608 | '.chat-markdown-table-container [data-slot="scroll-area-viewport"]', | ||
| 609 | ); | ||
| 610 | expect(viewport).not.toBeNull(); | ||
| 611 | expect(viewport!.querySelector("table")).not.toBeNull(); | ||
| 612 | // Content exceeds the container — the scroll-fade viewport scrolls | ||
| 613 | // horizontally rather than squishing columns. | ||
| 614 | expect(viewport!.scrollWidth).toBeGreaterThan(viewport!.clientWidth); | ||
| 615 | // And cells keep their longest word intact instead of breaking mid-word. | ||
| 616 | const cell = viewport!.querySelector("td"); | ||
| 617 | expect(cell!.getBoundingClientRect().width).toBeGreaterThan(100); | ||
| 618 | } finally { | ||
| 619 | await screen.unmount(); | ||
| 620 | } | ||
| 621 | }); | ||
| 622 | |||
| 623 | describe("table chrome", () => { | ||
| 624 | const longCell = | ||
| 625 | "This service has been experiencing intermittent latency spikes during peak traffic hours and the on-call team is investigating."; | ||
| 626 | |||
| 627 | it("truncates cells by default and expands them from the footer toggle", async () => { | ||
| 628 | const source = ["| Name | Notes |", "| --- | --- |", `| api | ${longCell} |`].join("\n"); | ||
| 629 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 630 | |||
| 631 | try { | ||
| 632 | const container = document.querySelector(".chat-markdown-table-container"); | ||
| 633 | expect(container?.getAttribute("data-expanded")).toBe("false"); | ||
| 634 | |||
| 635 | const noteCell = [...document.querySelectorAll(".chat-markdown td")].at(-1)!; | ||
| 636 | expect(getComputedStyle(noteCell).whiteSpace).toBe("nowrap"); | ||
| 637 | expect(noteCell.scrollWidth).toBeGreaterThan(noteCell.clientWidth); | ||
| 638 | |||
| 639 | const expandButton = page.getByRole("button", { name: "Expand table cells" }); | ||
| 640 | await expect.element(expandButton).not.toHaveAttribute("title"); | ||
| 641 | await expandButton.hover(); | ||
| 642 | await vi.waitFor(() => { | ||
| 643 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | ||
| 644 | expect(tooltip?.textContent).toContain("Expand table cells"); | ||
| 645 | }); | ||
| 646 | await expandButton.click(); | ||
| 647 | expect(container?.getAttribute("data-expanded")).toBe("true"); | ||
| 648 | expect(getComputedStyle(noteCell).whiteSpace).not.toBe("nowrap"); | ||
| 649 | |||
| 650 | await page.getByRole("button", { name: "Collapse table cells" }).click(); | ||
| 651 | expect(container?.getAttribute("data-expanded")).toBe("false"); | ||
| 652 | |||
| 653 | const copyButton = page.getByRole("button", { name: "Copy table" }); | ||
| 654 | await expect.element(copyButton).not.toHaveAttribute("title"); | ||
| 655 | await copyButton.hover(); | ||
| 656 | await vi.waitFor(() => { | ||
| 657 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | ||
| 658 | expect(tooltip?.textContent).toContain("Copy table"); | ||
| 659 | }); | ||
| 660 | expect(document.querySelector(".chat-markdown [title]")).toBeNull(); | ||
| 661 | } finally { | ||
| 662 | await screen.unmount(); | ||
| 663 | } | ||
| 664 | }); | ||
| 665 | |||
| 666 | it("retains column widths when cells expand", async () => { | ||
| 667 | const source = [ | ||
| 668 | "| ID | Owner | Status | Priority | Region | Summary | Long Description | Metrics | Payload | Notes |", | ||
| 669 | "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", | ||
| 670 | '| 1001 | Ada Lovelace | Active | High | us-west-2 | Payment workflow migration | This cell has enough text to wrap across several lines when expanded without shrinking its column. | Requests: 128,440; Error rate: 0.04%; P95: 212ms | `{ "feature": "billing", "version": 3 }` | Needs post-release monitoring for 24 hours. |', | ||
| 671 | ].join("\n"); | ||
| 672 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 673 | |||
| 674 | try { | ||
| 675 | const viewport = document.querySelector( | ||
| 676 | '.chat-markdown-table-container [data-slot="scroll-area-viewport"]', | ||
| 677 | )!; | ||
| 678 | const table = viewport.querySelector("table")!; | ||
| 679 | const collapsedWidths = [...table.querySelectorAll("thead th")].map( | ||
| 680 | (cell) => cell.getBoundingClientRect().width, | ||
| 681 | ); | ||
| 682 | expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); | ||
| 683 | |||
| 684 | await page.getByRole("button", { name: "Expand table cells" }).click(); | ||
| 685 | |||
| 686 | const expandedWidths = [...table.querySelectorAll("thead th")].map( | ||
| 687 | (cell) => cell.getBoundingClientRect().width, | ||
| 688 | ); | ||
| 689 | expect(expandedWidths).toHaveLength(collapsedWidths.length); | ||
| 690 | expandedWidths.forEach((width, index) => { | ||
| 691 | expect(width).toBeGreaterThanOrEqual(collapsedWidths[index]! - 1); | ||
| 692 | }); | ||
| 693 | expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); | ||
| 694 | } finally { | ||
| 695 | await screen.unmount(); | ||
| 696 | } | ||
| 697 | }); | ||
| 698 | |||
| 699 | it("exports tables as markdown and csv", async () => { | ||
| 700 | const source = [ | ||
| 701 | "| Name | Count |", | ||
| 702 | "| --- | ---: |", | ||
| 703 | '| widget, "deluxe" | 2 |', | ||
| 704 | "| plain | 1 |", | ||
| 705 | ].join("\n"); | ||
| 706 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 707 | |||
| 708 | try { | ||
| 709 | const table = document.querySelector(".chat-markdown table")!; | ||
| 710 | expect(serializeTableElementToMarkdown(table)).toBe( | ||
| 711 | ["| Name | Count |", "| --- | ---: |", '| widget, "deluxe" | 2 |', "| plain | 1 |"].join( | ||
| 712 | "\n", | ||
| 713 | ), | ||
| 714 | ); | ||
| 715 | expect(serializeTableElementToCsv(table)).toBe( | ||
| 716 | ["Name,Count", '"widget, ""deluxe""",2', "plain,1"].join("\n"), | ||
| 717 | ); | ||
| 718 | } finally { | ||
| 719 | await screen.unmount(); | ||
| 720 | } | ||
| 721 | }); | ||
| 722 | }); | ||
| 723 | |||
| 724 | describe("copying rendered markdown", () => { | ||
| 725 | function copySelectedMarkdown(): { text: string; html: string } { | ||
| 726 | const root = document.querySelector(".chat-markdown"); | ||
| 727 | if (!root) throw new Error("chat-markdown root not rendered"); | ||
| 728 | const selection = window.getSelection(); | ||
| 729 | if (!selection) throw new Error("selection unavailable"); | ||
| 730 | selection.removeAllRanges(); | ||
| 731 | const range = document.createRange(); | ||
| 732 | range.selectNodeContents(root); | ||
| 733 | selection.addRange(range); | ||
| 734 | |||
| 735 | const clipboardData = new DataTransfer(); | ||
| 736 | root.dispatchEvent( | ||
| 737 | new ClipboardEvent("copy", { clipboardData, bubbles: true, cancelable: true }), | ||
| 738 | ); | ||
| 739 | selection.removeAllRanges(); | ||
| 740 | return { | ||
| 741 | text: clipboardData.getData("text/plain"), | ||
| 742 | html: clipboardData.getData("text/html"), | ||
| 743 | }; | ||
| 744 | } | ||
| 745 | |||
| 746 | it("round-trips links, emphasis, and inline code", async () => { | ||
| 747 | const screen = await render( | ||
| 748 | <ChatMarkdown | ||
| 749 | text="Check out [Anthropic](https://anthropic.com), **bold**, *italic*, and `code`." | ||
| 750 | cwd="/repo/project" | ||
| 751 | />, | ||
| 752 | ); | ||
| 753 | |||
| 754 | try { | ||
| 755 | const { text, html } = copySelectedMarkdown(); | ||
| 756 | expect(text).toBe( | ||
| 757 | "Check out [Anthropic](https://anthropic.com), **bold**, *italic*, and `code`.", | ||
| 758 | ); | ||
| 759 | expect(html).toContain('href="https://anthropic.com"'); | ||
| 760 | } finally { | ||
| 761 | await screen.unmount(); | ||
| 762 | } | ||
| 763 | }); | ||
| 764 | |||
| 765 | it("round-trips block structure: headings, lists, quotes, and fences", async () => { | ||
| 766 | const source = [ | ||
| 767 | "## Heading", | ||
| 768 | "", | ||
| 769 | "- first", | ||
| 770 | "- second", | ||
| 771 | " - nested", | ||
| 772 | "", | ||
| 773 | "1. one", | ||
| 774 | "2. two", | ||
| 775 | "", | ||
| 776 | "- [x] done", | ||
| 777 | "- [ ] todo", | ||
| 778 | "", | ||
| 779 | "> quoted", | ||
| 780 | "", | ||
| 781 | "```ts", | ||
| 782 | "const x = 1;", | ||
| 783 | "", | ||
| 784 | "const y = 2;", | ||
| 785 | "```", | ||
| 786 | ].join("\n"); | ||
| 787 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 788 | |||
| 789 | try { | ||
| 790 | const { text } = copySelectedMarkdown(); | ||
| 791 | expect(text).toBe(source); | ||
| 792 | } finally { | ||
| 793 | await screen.unmount(); | ||
| 794 | } | ||
| 795 | }); | ||
| 796 | |||
| 797 | it("round-trips tables with alignment", async () => { | ||
| 798 | const source = ["| Name | Count |", "| --- | ---: |", "| a | 1 |", "| b | 2 |"].join("\n"); | ||
| 799 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 800 | |||
| 801 | try { | ||
| 802 | const { text } = copySelectedMarkdown(); | ||
| 803 | expect(text).toBe(source); | ||
| 804 | } finally { | ||
| 805 | await screen.unmount(); | ||
| 806 | } | ||
| 807 | }); | ||
| 808 | |||
| 809 | it("round-trips details rendered through the collapsible", async () => { | ||
| 810 | const source = [ | ||
| 811 | "<details open>", | ||
| 812 | "<summary>Expandable details section</summary>", | ||
| 813 | "", | ||
| 814 | "This content includes **formatted text**.", | ||
| 815 | "</details>", | ||
| 816 | ].join("\n"); | ||
| 817 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 818 | |||
| 819 | try { | ||
| 820 | const { text } = copySelectedMarkdown(); | ||
| 821 | expect(text).toBe(source); | ||
| 822 | } finally { | ||
| 823 | await screen.unmount(); | ||
| 824 | } | ||
| 825 | }); | ||
| 826 | |||
| 827 | it("excludes the code block header chrome from copied markdown", async () => { | ||
| 828 | const source = ["```ts", "const x = 1;", "```"].join("\n"); | ||
| 829 | const screen = await render(<ChatMarkdown text={source} cwd="/repo/project" />); | ||
| 830 | |||
| 831 | try { | ||
| 832 | const { text } = copySelectedMarkdown(); | ||
| 833 | expect(text).toBe(source); | ||
| 834 | } finally { | ||
| 835 | await screen.unmount(); | ||
| 836 | } | ||
| 837 | }); | ||
| 838 | |||
| 839 | it("copies file links as markdown and skips UI affordances", async () => { | ||
| 840 | const filePath = "/Users/yashsingh/p/t3code/src/utils/permissions/PermissionRule.ts"; | ||
| 841 | const screen = await render( | ||
| 842 | <ChatMarkdown | ||
| 843 | text={`See [PermissionRule.ts](file://${filePath}) for details.`} | ||
| 844 | cwd="/repo/project" | ||
| 845 | />, | ||
| 846 | ); | ||
| 847 | |||
| 848 | try { | ||
| 849 | const { text, html } = copySelectedMarkdown(); | ||
| 850 | expect(text).toBe( | ||
| 851 | `See [PermissionRule.ts](/Users/yashsingh/p/t3code/src/utils/permissions/PermissionRule.ts) for details.`, | ||
| 852 | ); | ||
| 853 | expect(html).toContain("PermissionRule.ts"); | ||
| 854 | expect(html).not.toContain("<img"); | ||
| 855 | } finally { | ||
| 856 | await screen.unmount(); | ||
| 857 | } | ||
| 858 | }); | ||
| 859 | |||
| 860 | it("copies skill and file chips with source encodings that recreate composer chips", async () => { | ||
| 861 | const source = | ||
| 862 | "Use $agent-browser with [package.json](path/to/package.json) before continuing."; | ||
| 863 | const screen = await render( | ||
| 864 | <ChatMarkdown | ||
| 865 | text={source} | ||
| 866 | cwd="/repo/project" | ||
| 867 | skills={[{ name: "agent-browser", displayName: "Agent Browser" }]} | ||
| 868 | />, | ||
| 869 | ); | ||
| 870 | |||
| 871 | try { | ||
| 872 | const root = document.querySelector(".chat-markdown")!; | ||
| 873 | const selection = window.getSelection()!; | ||
| 874 | selection.removeAllRanges(); | ||
| 875 | const range = document.createRange(); | ||
| 876 | range.selectNodeContents(root); | ||
| 877 | selection.addRange(range); | ||
| 878 | expect(selection.toString()).toContain("Agent Browser"); | ||
| 879 | expect(selection.toString()).toContain("package.json"); | ||
| 880 | selection.removeAllRanges(); | ||
| 881 | |||
| 882 | const { text, html } = copySelectedMarkdown(); | ||
| 883 | expect(text).toBe(source); | ||
| 884 | expect(html).toContain("Agent Browser"); | ||
| 885 | expect(html).toContain("package.json"); | ||
| 886 | expect(html).not.toContain("<img"); | ||
| 887 | } finally { | ||
| 888 | await screen.unmount(); | ||
| 889 | } | ||
| 890 | }); | ||
| 891 | }); | ||
| 892 | }); | ||
apps/web/src/components/ChatMarkdown.tsx+138-85| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 1 | import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; | 2 | import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; |
| 2 | import { | 3 | import { |
| 3 | CheckIcon, | 4 | CheckIcon, |
| ... | @@ -9,6 +10,13 @@ import { | ... | @@ -9,6 +10,13 @@ import { |
| 9 | WrapTextIcon, | 10 | WrapTextIcon, |
| 10 | } from "lucide-react"; | 11 | } from "lucide-react"; |
| 11 | import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; | 12 | import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; |
| 13 | import { | ||
| 14 | isAtomCommandInterrupted, | ||
| 15 | squashAtomCommandFailure, | ||
| 16 | type AtomCommandResult, | ||
| 17 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 18 | import * as Cause from "effect/Cause"; | ||
| 19 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 12 | import React, { | 20 | import React, { |
| 13 | Children, | 21 | Children, |
| 14 | Suspense, | 22 | Suspense, |
| ... | @@ -41,7 +49,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi | ... | @@ -41,7 +49,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi |
| 41 | import { ScrollArea } from "./ui/scroll-area"; | 49 | import { ScrollArea } from "./ui/scroll-area"; |
| 42 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | 50 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; |
| 43 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 51 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 44 | import { openInPreferredEditor } from "../editorPreferences"; | 52 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 45 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; | 53 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 46 | import { fnv1a32 } from "../lib/diffRendering"; | 54 | import { fnv1a32 } from "../lib/diffRendering"; |
| 47 | import { LRUCache } from "../lib/lruCache"; | 55 | import { LRUCache } from "../lib/lruCache"; |
| ... | @@ -59,11 +67,19 @@ import { | ... | @@ -59,11 +67,19 @@ import { |
| 59 | import { readLocalApi } from "../localApi"; | 67 | import { readLocalApi } from "../localApi"; |
| 60 | import { cn } from "../lib/utils"; | 68 | import { cn } from "../lib/utils"; |
| 61 | import { useRightPanelStore } from "../rightPanelStore"; | 69 | import { useRightPanelStore } from "../rightPanelStore"; |
| 70 | import { useActiveEnvironmentId } from "../state/entities"; | ||
| 71 | import { serverEnvironment } from "../state/server"; | ||
| 72 | import { assetEnvironment } from "../state/assets"; | ||
| 73 | import { usePreparedConnection } from "../state/session"; | ||
| 74 | import { previewEnvironment } from "../state/preview"; | ||
| 75 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 76 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | ||
| 62 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | 77 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; |
| 63 | import { | 78 | import { |
| 64 | isBrowserPreviewFile, | 79 | isBrowserPreviewFile, |
| 65 | openFileInPreview, | 80 | openFileInPreview, |
| 66 | openUrlInPreview, | 81 | openUrlInPreview, |
| 82 | BrowserPreviewUnavailableError, | ||
| 67 | } from "../browser/openFileInPreview"; | 83 | } from "../browser/openFileInPreview"; |
| 68 | 84 | ||
| 69 | class CodeHighlightErrorBoundary extends React.Component< | 85 | class CodeHighlightErrorBoundary extends React.Component< |
| ... | @@ -676,6 +692,8 @@ interface MarkdownFileLinkProps { | ... | @@ -676,6 +692,8 @@ interface MarkdownFileLinkProps { |
| 676 | copyMarkdown: string; | 692 | copyMarkdown: string; |
| 677 | theme: "light" | "dark"; | 693 | theme: "light" | "dark"; |
| 678 | threadRef?: ScopedThreadRef | undefined; | 694 | threadRef?: ScopedThreadRef | undefined; |
| 695 | onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>; | ||
| 696 | onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined; | ||
| 679 | className?: string | undefined; | 697 | className?: string | undefined; |
| 680 | } | 698 | } |
| 681 | 699 | ||
| ... | @@ -942,54 +960,6 @@ function MarkdownExternalLinkContent({ | ... | @@ -942,54 +960,6 @@ function MarkdownExternalLinkContent({ |
| 942 | ); | 960 | ); |
| 943 | } | 961 | } |
| 944 | 962 | ||
| 945 | function MarkdownExternalLink({ | ||
| 946 | href, | ||
| 947 | threadRef, | ||
| 948 | children, | ||
| 949 | ...props | ||
| 950 | }: React.ComponentProps<"a"> & { | ||
| 951 | href: string; | ||
| 952 | threadRef?: ScopedThreadRef | undefined; | ||
| 953 | }) { | ||
| 954 | const handleContextMenu = useCallback( | ||
| 955 | async (event: ReactMouseEvent<HTMLAnchorElement>) => { | ||
| 956 | if (!threadRef || !isPreviewSupportedInRuntime()) return; | ||
| 957 | event.preventDefault(); | ||
| 958 | event.stopPropagation(); | ||
| 959 | |||
| 960 | const api = readLocalApi(); | ||
| 961 | if (!api) return; | ||
| 962 | const clicked = await api.contextMenu.show( | ||
| 963 | [ | ||
| 964 | { id: "open-in-browser", label: "Open in integrated browser" }, | ||
| 965 | { id: "open-external", label: "Open in system browser" }, | ||
| 966 | ] as const, | ||
| 967 | { x: event.clientX, y: event.clientY }, | ||
| 968 | ); | ||
| 969 | if (clicked === "open-in-browser") { | ||
| 970 | void openUrlInPreview(threadRef, href).catch((error) => { | ||
| 971 | toastManager.add( | ||
| 972 | stackedThreadToast({ | ||
| 973 | type: "error", | ||
| 974 | title: "Unable to open link in browser", | ||
| 975 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 976 | }), | ||
| 977 | ); | ||
| 978 | }); | ||
| 979 | } else if (clicked === "open-external") { | ||
| 980 | void api.shell.openExternal(href); | ||
| 981 | } | ||
| 982 | }, | ||
| 983 | [href, threadRef], | ||
| 984 | ); | ||
| 985 | |||
| 986 | return ( | ||
| 987 | <a {...props} href={href} onContextMenu={handleContextMenu}> | ||
| 988 | {children} | ||
| 989 | </a> | ||
| 990 | ); | ||
| 991 | } | ||
| 992 | |||
| 993 | const MarkdownFileLink = memo(function MarkdownFileLink({ | 963 | const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 994 | href, | 964 | href, |
| 995 | targetPath, | 965 | targetPath, |
| ... | @@ -1001,19 +971,17 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1001,19 +971,17 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1001 | copyMarkdown, | 971 | copyMarkdown, |
| 1002 | theme, | 972 | theme, |
| 1003 | threadRef, | 973 | threadRef, |
| 974 | onOpen, | ||
| 975 | onOpenInBrowser, | ||
| 1004 | className, | 976 | className, |
| 1005 | }: MarkdownFileLinkProps) { | 977 | }: MarkdownFileLinkProps) { |
| 1006 | const handleOpenInEditor = useCallback(() => { | 978 | const handleOpenInEditor = useCallback(() => { |
| 1007 | const api = readLocalApi(); | 979 | void (async () => { |
| 1008 | if (!api) { | 980 | const result = await onOpen(targetPath); |
| 1009 | toastManager.add({ | 981 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { |
| 1010 | type: "error", | 982 | return; |
| 1011 | title: "Open in editor is unavailable", | 983 | } |
| 1012 | }); | 984 | const error = squashAtomCommandFailure(result); |
| 1013 | return; | ||
| 1014 | } | ||
| 1015 | |||
| 1016 | void openInPreferredEditor(api, targetPath).catch((error) => { | ||
| 1017 | toastManager.add( | 985 | toastManager.add( |
| 1018 | stackedThreadToast({ | 986 | stackedThreadToast({ |
| 1019 | type: "error", | 987 | type: "error", |
| ... | @@ -1021,8 +989,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1021,8 +989,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1021 | description: error instanceof Error ? error.message : "An error occurred.", | 989 | description: error instanceof Error ? error.message : "An error occurred.", |
| 1022 | }), | 990 | }), |
| 1023 | ); | 991 | ); |
| 1024 | }); | 992 | })(); |
| 1025 | }, [targetPath]); | 993 | }, [onOpen, targetPath]); |
| 1026 | 994 | ||
| 1027 | const handleOpenInFilePreview = useCallback(() => { | 995 | const handleOpenInFilePreview = useCallback(() => { |
| 1028 | if (!threadRef || !workspaceRelativePath) { | 996 | if (!threadRef || !workspaceRelativePath) { |
| ... | @@ -1033,8 +1001,15 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1033,8 +1001,15 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1033 | }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); | 1001 | }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); |
| 1034 | 1002 | ||
| 1035 | const handleOpenInBrowser = useCallback(() => { | 1003 | const handleOpenInBrowser = useCallback(() => { |
| 1036 | if (!threadRef) return; | 1004 | if (!onOpenInBrowser) { |
| 1037 | void openFileInPreview(threadRef, iconPath).catch((error) => { | 1005 | return; |
| 1006 | } | ||
| 1007 | void (async () => { | ||
| 1008 | const result = await onOpenInBrowser(); | ||
| 1009 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | ||
| 1010 | return; | ||
| 1011 | } | ||
| 1012 | const error = squashAtomCommandFailure(result); | ||
| 1038 | toastManager.add( | 1013 | toastManager.add( |
| 1039 | stackedThreadToast({ | 1014 | stackedThreadToast({ |
| 1040 | type: "error", | 1015 | type: "error", |
| ... | @@ -1042,8 +1017,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1042,8 +1017,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1042 | description: error instanceof Error ? error.message : "An error occurred.", | 1017 | description: error instanceof Error ? error.message : "An error occurred.", |
| 1043 | }), | 1018 | }), |
| 1044 | ); | 1019 | ); |
| 1045 | }); | 1020 | })(); |
| 1046 | }, [iconPath, threadRef]); | 1021 | }, [onOpenInBrowser]); |
| 1047 | 1022 | ||
| 1048 | const handleCopy = useCallback((value: string, title: string) => { | 1023 | const handleCopy = useCallback((value: string, title: string) => { |
| 1049 | if (typeof window === "undefined" || !navigator.clipboard?.writeText) { | 1024 | if (typeof window === "undefined" || !navigator.clipboard?.writeText) { |
| ... | @@ -1085,12 +1060,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1085,12 +1060,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1085 | const api = readLocalApi(); | 1060 | const api = readLocalApi(); |
| 1086 | if (!api) return; | 1061 | if (!api) return; |
| 1087 | 1062 | ||
| 1088 | const canOpenInBrowser = | ||
| 1089 | Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath); | ||
| 1090 | const clicked = await api.contextMenu.show( | 1063 | const clicked = await api.contextMenu.show( |
| 1091 | [ | 1064 | [ |
| 1092 | { id: "open", label: "Open in editor" }, | 1065 | { id: "open", label: "Open in editor" }, |
| 1093 | ...(canOpenInBrowser | 1066 | ...(onOpenInBrowser |
| 1094 | ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) | 1067 | ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) |
| 1095 | : []), | 1068 | : []), |
| 1096 | { id: "copy-relative", label: "Copy relative path" }, | 1069 | { id: "copy-relative", label: "Copy relative path" }, |
| ... | @@ -1115,15 +1088,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1115,15 +1088,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1115 | handleCopy(targetPath, "Full path"); | 1088 | handleCopy(targetPath, "Full path"); |
| 1116 | } | 1089 | } |
| 1117 | }, | 1090 | }, |
| 1118 | [ | 1091 | [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], |
| 1119 | displayPath, | ||
| 1120 | handleCopy, | ||
| 1121 | handleOpenInBrowser, | ||
| 1122 | handleOpenInEditor, | ||
| 1123 | iconPath, | ||
| 1124 | targetPath, | ||
| 1125 | threadRef, | ||
| 1126 | ], | ||
| 1127 | ); | 1092 | ); |
| 1128 | 1093 | ||
| 1129 | return ( | 1094 | return ( |
| ... | @@ -1137,7 +1102,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1137,7 +1102,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1137 | onClick={(event) => { | 1102 | onClick={(event) => { |
| 1138 | event.preventDefault(); | 1103 | event.preventDefault(); |
| 1139 | event.stopPropagation(); | 1104 | event.stopPropagation(); |
| 1140 | if (threadRef && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath)) { | 1105 | if (onOpenInBrowser) { |
| 1141 | handleOpenInBrowser(); | 1106 | handleOpenInBrowser(); |
| 1142 | return; | 1107 | return; |
| 1143 | } | 1108 | } |
| ... | @@ -1175,8 +1140,9 @@ function areMarkdownFileLinkPropsEqual( | ... | @@ -1175,8 +1140,9 @@ function areMarkdownFileLinkPropsEqual( |
| 1175 | previous.label === next.label && | 1140 | previous.label === next.label && |
| 1176 | previous.copyMarkdown === next.copyMarkdown && | 1141 | previous.copyMarkdown === next.copyMarkdown && |
| 1177 | previous.theme === next.theme && | 1142 | previous.theme === next.theme && |
| 1178 | previous.threadRef?.environmentId === next.threadRef?.environmentId && | 1143 | previous.threadRef === next.threadRef && |
| 1179 | previous.threadRef?.threadId === next.threadRef?.threadId && | 1144 | previous.onOpen === next.onOpen && |
| 1145 | previous.onOpenInBrowser === next.onOpenInBrowser && | ||
| 1180 | previous.className === next.className | 1146 | previous.className === next.className |
| 1181 | ); | 1147 | ); |
| 1182 | } | 1148 | } |
| ... | @@ -1192,6 +1158,19 @@ function ChatMarkdown({ | ... | @@ -1192,6 +1158,19 @@ function ChatMarkdown({ |
| 1192 | lineBreaks = false, | 1158 | lineBreaks = false, |
| 1193 | }: ChatMarkdownProps) { | 1159 | }: ChatMarkdownProps) { |
| 1194 | const { resolvedTheme } = useTheme(); | 1160 | const { resolvedTheme } = useTheme(); |
| 1161 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | ||
| 1162 | reportFailure: false, | ||
| 1163 | }); | ||
| 1164 | const openPreview = useAtomCommand(previewEnvironment.open, { | ||
| 1165 | reportFailure: false, | ||
| 1166 | }); | ||
| 1167 | const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); | ||
| 1168 | const environmentId = useActiveEnvironmentId(); | ||
| 1169 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); | ||
| 1170 | const openInPreferredEditor = useOpenInPreferredEditor( | ||
| 1171 | environmentId, | ||
| 1172 | serverConfig?.availableEditors ?? [], | ||
| 1173 | ); | ||
| 1195 | const diffThemeName = resolveDiffThemeName(resolvedTheme); | 1174 | const diffThemeName = resolveDiffThemeName(resolvedTheme); |
| 1196 | const markdownFileLinkMetaByHref = useMemo(() => { | 1175 | const markdownFileLinkMetaByHref = useMemo(() => { |
| 1197 | const metaByHref = new Map< | 1176 | const metaByHref = new Map< |
| ... | @@ -1226,6 +1205,46 @@ function ChatMarkdown({ | ... | @@ -1226,6 +1205,46 @@ function ChatMarkdown({ |
| 1226 | event.clipboardData.setData("text/plain", payload.text); | 1205 | event.clipboardData.setData("text/plain", payload.text); |
| 1227 | event.clipboardData.setData("text/html", payload.html); | 1206 | event.clipboardData.setData("text/html", payload.html); |
| 1228 | }, []); | 1207 | }, []); |
| 1208 | const openExternalLinkInPreview = useCallback( | ||
| 1209 | (url: string) => { | ||
| 1210 | if (!threadRef) { | ||
| 1211 | return Promise.resolve( | ||
| 1212 | AsyncResult.failure<void, BrowserPreviewUnavailableError>( | ||
| 1213 | Cause.fail( | ||
| 1214 | new BrowserPreviewUnavailableError({ | ||
| 1215 | message: "Thread context is unavailable.", | ||
| 1216 | }), | ||
| 1217 | ), | ||
| 1218 | ), | ||
| 1219 | ); | ||
| 1220 | } | ||
| 1221 | return openUrlInPreview({ threadRef, url, openPreview }); | ||
| 1222 | }, | ||
| 1223 | [openPreview, threadRef], | ||
| 1224 | ); | ||
| 1225 | const openMarkdownFileInPreview = useCallback( | ||
| 1226 | (path: string) => { | ||
| 1227 | if (!threadRef || preparedConnection._tag === "None") { | ||
| 1228 | return Promise.resolve( | ||
| 1229 | AsyncResult.failure<void, BrowserPreviewUnavailableError>( | ||
| 1230 | Cause.fail( | ||
| 1231 | new BrowserPreviewUnavailableError({ | ||
| 1232 | message: "Environment is not connected.", | ||
| 1233 | }), | ||
| 1234 | ), | ||
| 1235 | ), | ||
| 1236 | ); | ||
| 1237 | } | ||
| 1238 | return openFileInPreview({ | ||
| 1239 | threadRef, | ||
| 1240 | filePath: path, | ||
| 1241 | httpBaseUrl: preparedConnection.value.httpBaseUrl, | ||
| 1242 | createAssetUrl, | ||
| 1243 | openPreview, | ||
| 1244 | }); | ||
| 1245 | }, | ||
| 1246 | [createAssetUrl, openPreview, preparedConnection, threadRef], | ||
| 1247 | ); | ||
| 1229 | const markdownComponents = useMemo<Components>( | 1248 | const markdownComponents = useMemo<Components>( |
| 1230 | () => ({ | 1249 | () => ({ |
| 1231 | p({ node: _node, children, ...props }) { | 1250 | p({ node: _node, children, ...props }) { |
| ... | @@ -1277,11 +1296,11 @@ function ChatMarkdown({ | ... | @@ -1277,11 +1296,11 @@ function ChatMarkdown({ |
| 1277 | const faviconHost = resolveExternalLinkHost(href); | 1296 | const faviconHost = resolveExternalLinkHost(href); |
| 1278 | const isSameDocumentLink = href?.startsWith("#") ?? false; | 1297 | const isSameDocumentLink = href?.startsWith("#") ?? false; |
| 1279 | const onClick = props.onClick; | 1298 | const onClick = props.onClick; |
| 1299 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); | ||
| 1280 | const link = ( | 1300 | const link = ( |
| 1281 | <MarkdownExternalLink | 1301 | <a |
| 1282 | {...props} | 1302 | {...props} |
| 1283 | href={href ?? ""} | 1303 | href={href} |
| 1284 | threadRef={faviconHost && isPreviewSupportedInRuntime() ? threadRef : undefined} | ||
| 1285 | target={isSameDocumentLink ? undefined : "_blank"} | 1304 | target={isSameDocumentLink ? undefined : "_blank"} |
| 1286 | rel={isSameDocumentLink ? undefined : "noopener noreferrer"} | 1305 | rel={isSameDocumentLink ? undefined : "noopener noreferrer"} |
| 1287 | onClick={(event) => { | 1306 | onClick={(event) => { |
| ... | @@ -1290,6 +1309,29 @@ function ChatMarkdown({ | ... | @@ -1290,6 +1309,29 @@ function ChatMarkdown({ |
| 1290 | handleMarkdownFragmentClick(event, href); | 1309 | handleMarkdownFragmentClick(event, href); |
| 1291 | } | 1310 | } |
| 1292 | }} | 1311 | }} |
| 1312 | onContextMenu={(event) => { | ||
| 1313 | if (!canOpenInPreview || !href) return; | ||
| 1314 | event.preventDefault(); | ||
| 1315 | event.stopPropagation(); | ||
| 1316 | const api = readLocalApi(); | ||
| 1317 | if (!api) return; | ||
| 1318 | void api.contextMenu | ||
| 1319 | .show( | ||
| 1320 | [ | ||
| 1321 | { id: "open-in-browser", label: "Open in integrated browser" }, | ||
| 1322 | { id: "open-external", label: "Open in system browser" }, | ||
| 1323 | ] as const, | ||
| 1324 | { x: event.clientX, y: event.clientY }, | ||
| 1325 | ) | ||
| 1326 | .then((clicked) => { | ||
| 1327 | if (clicked === "open-in-browser") { | ||
| 1328 | void openExternalLinkInPreview(href); | ||
| 1329 | return; | ||
| 1330 | } | ||
| 1331 | if (clicked === "open-external") return api.shell.openExternal(href); | ||
| 1332 | }) | ||
| 1333 | .catch(() => undefined); | ||
| 1334 | }} | ||
| 1293 | > | 1335 | > |
| 1294 | {faviconHost ? ( | 1336 | {faviconHost ? ( |
| 1295 | <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}> | 1337 | <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}> |
| ... | @@ -1298,7 +1340,7 @@ function ChatMarkdown({ | ... | @@ -1298,7 +1340,7 @@ function ChatMarkdown({ |
| 1298 | ) : ( | 1340 | ) : ( |
| 1299 | children | 1341 | children |
| 1300 | )} | 1342 | )} |
| 1301 | </MarkdownExternalLink> | 1343 | </a> |
| 1302 | ); | 1344 | ); |
| 1303 | if (!faviconHost || !href) { | 1345 | if (!faviconHost || !href) { |
| 1304 | return link; | 1346 | return link; |
| ... | @@ -1339,6 +1381,14 @@ function ChatMarkdown({ | ... | @@ -1339,6 +1381,14 @@ function ChatMarkdown({ |
| 1339 | copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} | 1381 | copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} |
| 1340 | theme={resolvedTheme} | 1382 | theme={resolvedTheme} |
| 1341 | threadRef={threadRef} | 1383 | threadRef={threadRef} |
| 1384 | onOpen={openInPreferredEditor} | ||
| 1385 | onOpenInBrowser={ | ||
| 1386 | threadRef && | ||
| 1387 | isPreviewSupportedInRuntime() && | ||
| 1388 | isBrowserPreviewFile(fileLinkMeta.filePath) | ||
| 1389 | ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) | ||
| 1390 | : undefined | ||
| 1391 | } | ||
| 1342 | className={props.className} | 1392 | className={props.className} |
| 1343 | /> | 1393 | /> |
| 1344 | ); | 1394 | ); |
| ... | @@ -1384,10 +1434,13 @@ function ChatMarkdown({ | ... | @@ -1384,10 +1434,13 @@ function ChatMarkdown({ |
| 1384 | isStreaming, | 1434 | isStreaming, |
| 1385 | markdownFileLinkMetaByHref, | 1435 | markdownFileLinkMetaByHref, |
| 1386 | onTaskListChange, | 1436 | onTaskListChange, |
| 1387 | threadRef, | 1437 | openInPreferredEditor, |
| 1438 | openExternalLinkInPreview, | ||
| 1439 | openMarkdownFileInPreview, | ||
| 1388 | resolvedTheme, | 1440 | resolvedTheme, |
| 1389 | skills, | 1441 | skills, |
| 1390 | text, | 1442 | text, |
| 1443 | threadRef, | ||
| 1391 | ], | 1444 | ], |
| 1392 | ); | 1445 | ); |
| 1393 | 1446 |
apps/web/src/components/ChatView.browser.tsx deleted-7456| ... | @@ -1,7456 +0,0 @@ | ||
| 1 | // Production CSS is part of the behavior under test because row height depends on it. | ||
| 2 | import "../index.css"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | EventId, | ||
| 6 | ORCHESTRATION_WS_METHODS, | ||
| 7 | EnvironmentId, | ||
| 8 | type EnvironmentApi, | ||
| 9 | type MessageId, | ||
| 10 | type OrchestrationReadModel, | ||
| 11 | type ProjectId, | ||
| 12 | ProviderDriverKind, | ||
| 13 | ProviderInstanceId, | ||
| 14 | type ServerConfig, | ||
| 15 | type TerminalMetadataStreamEvent, | ||
| 16 | type ServerLifecycleWelcomePayload, | ||
| 17 | type ThreadId, | ||
| 18 | type TurnId, | ||
| 19 | WS_METHODS, | ||
| 20 | OrchestrationSessionStatus, | ||
| 21 | DEFAULT_SERVER_SETTINGS, | ||
| 22 | DEFAULT_TERMINAL_ID, | ||
| 23 | ServerConfig as ServerConfigSchema, | ||
| 24 | } from "@t3tools/contracts"; | ||
| 25 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 26 | import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; | ||
| 27 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; | ||
| 28 | import * as Option from "effect/Option"; | ||
| 29 | import * as Schema from "effect/Schema"; | ||
| 30 | import { HttpResponse, http, ws } from "msw"; | ||
| 31 | import { setupWorker } from "msw/browser"; | ||
| 32 | import { page } from "vite-plus/test/browser"; | ||
| 33 | import { | ||
| 34 | afterAll, | ||
| 35 | afterEach, | ||
| 36 | beforeAll, | ||
| 37 | beforeEach, | ||
| 38 | describe, | ||
| 39 | expect, | ||
| 40 | it, | ||
| 41 | vi, | ||
| 42 | } from "vite-plus/test"; | ||
| 43 | import { render } from "vitest-browser-react"; | ||
| 44 | |||
| 45 | import { useCommandPaletteStore } from "../commandPaletteStore"; | ||
| 46 | import { useComposerDraftStore, DraftId } from "../composerDraftStore"; | ||
| 47 | import { | ||
| 48 | __resetEnvironmentApiOverridesForTests, | ||
| 49 | __setEnvironmentApiOverrideForTests, | ||
| 50 | } from "../environmentApi"; | ||
| 51 | import { | ||
| 52 | resetSavedEnvironmentRegistryStoreForTests, | ||
| 53 | resetSavedEnvironmentRuntimeStoreForTests, | ||
| 54 | useSavedEnvironmentRegistryStore, | ||
| 55 | useSavedEnvironmentRuntimeStore, | ||
| 56 | } from "../environments/runtime/catalog"; | ||
| 57 | import { | ||
| 58 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | ||
| 59 | removeInlineTerminalContextPlaceholder, | ||
| 60 | type TerminalContextDraft, | ||
| 61 | } from "../lib/terminalContext"; | ||
| 62 | import { isMacPlatform } from "../lib/utils"; | ||
| 63 | import { __resetLocalApiForTests } from "../localApi"; | ||
| 64 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | ||
| 65 | import { getServerConfig } from "../rpc/serverState"; | ||
| 66 | import { getRouter } from "../router"; | ||
| 67 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; | ||
| 68 | import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; | ||
| 69 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; | ||
| 70 | import { terminalSessionManager } from "../terminalSessionState"; | ||
| 71 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; | ||
| 72 | import { useUiStateStore } from "../uiStateStore"; | ||
| 73 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; | ||
| 74 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; | ||
| 75 | |||
| 76 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 77 | |||
| 78 | vi.mock("../lib/vcsStatusState", () => { | ||
| 79 | const status = { | ||
| 80 | data: { | ||
| 81 | isRepo: true, | ||
| 82 | sourceControlProvider: { | ||
| 83 | kind: "github", | ||
| 84 | name: "GitHub", | ||
| 85 | baseUrl: "https://github.com", | ||
| 86 | }, | ||
| 87 | hasPrimaryRemote: true, | ||
| 88 | isDefaultRef: true, | ||
| 89 | refName: "main", | ||
| 90 | hasWorkingTreeChanges: false, | ||
| 91 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 92 | hasUpstream: true, | ||
| 93 | aheadCount: 0, | ||
| 94 | behindCount: 0, | ||
| 95 | pr: null, | ||
| 96 | }, | ||
| 97 | error: null, | ||
| 98 | cause: null, | ||
| 99 | isPending: false, | ||
| 100 | }; | ||
| 101 | |||
| 102 | return { | ||
| 103 | getVcsStatusDataForTarget: (state: typeof status) => state.data, | ||
| 104 | getVcsStatusSnapshot: () => status, | ||
| 105 | useVcsStatus: () => status, | ||
| 106 | useVcsStatuses: () => new Map(), | ||
| 107 | refreshVcsStatus: () => Promise.resolve(null), | ||
| 108 | resetVcsStatusStateForTests: () => undefined, | ||
| 109 | }; | ||
| 110 | }); | ||
| 111 | |||
| 112 | const THREAD_ID = "thread-browser-test" as ThreadId; | ||
| 113 | const THREAD_TITLE = "Browser test thread"; | ||
| 114 | const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId; | ||
| 115 | const PROJECT_ID = "project-1" as ProjectId; | ||
| 116 | const SECOND_PROJECT_ID = "project-2" as ProjectId; | ||
| 117 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | ||
| 118 | const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); | ||
| 119 | const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); | ||
| 120 | const THREAD_KEY = scopedThreadKey(THREAD_REF); | ||
| 121 | const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; | ||
| 122 | const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; | ||
| 123 | const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( | ||
| 124 | { | ||
| 125 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 126 | id: PROJECT_ID, | ||
| 127 | cwd: "/repo/project", | ||
| 128 | repositoryIdentity: null, | ||
| 129 | }, | ||
| 130 | { | ||
| 131 | sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, | ||
| 132 | sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, | ||
| 133 | }, | ||
| 134 | ); | ||
| 135 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; | ||
| 136 | const BASE_TIME_MS = Date.parse(NOW_ISO); | ||
| 137 | const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>"; | ||
| 138 | const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)"; | ||
| 139 | |||
| 140 | interface TestFixture { | ||
| 141 | snapshot: OrchestrationReadModel; | ||
| 142 | serverConfig: ServerConfig; | ||
| 143 | welcome: ServerLifecycleWelcomePayload; | ||
| 144 | terminalMetadataEvents: ReadonlyArray<TerminalMetadataStreamEvent>; | ||
| 145 | } | ||
| 146 | |||
| 147 | let fixture: TestFixture; | ||
| 148 | const rpcHarness = new BrowserWsRpcHarness(); | ||
| 149 | const wsRequests = rpcHarness.requests; | ||
| 150 | let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; | ||
| 151 | const wsLink = ws.link(/ws(s)?:\/\/.*/); | ||
| 152 | const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); | ||
| 153 | |||
| 154 | interface ViewportSpec { | ||
| 155 | name: string; | ||
| 156 | width: number; | ||
| 157 | height: number; | ||
| 158 | textTolerancePx: number; | ||
| 159 | attachmentTolerancePx: number; | ||
| 160 | } | ||
| 161 | |||
| 162 | const DEFAULT_VIEWPORT: ViewportSpec = { | ||
| 163 | name: "desktop", | ||
| 164 | width: 960, | ||
| 165 | height: 1_100, | ||
| 166 | textTolerancePx: 44, | ||
| 167 | attachmentTolerancePx: 56, | ||
| 168 | }; | ||
| 169 | const WIDE_FOOTER_VIEWPORT: ViewportSpec = { | ||
| 170 | name: "wide-footer", | ||
| 171 | width: 1_400, | ||
| 172 | height: 1_100, | ||
| 173 | textTolerancePx: 44, | ||
| 174 | attachmentTolerancePx: 56, | ||
| 175 | }; | ||
| 176 | const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { | ||
| 177 | name: "compact-footer", | ||
| 178 | width: 430, | ||
| 179 | height: 932, | ||
| 180 | textTolerancePx: 56, | ||
| 181 | attachmentTolerancePx: 56, | ||
| 182 | }; | ||
| 183 | |||
| 184 | interface MountedChatView { | ||
| 185 | [Symbol.asyncDispose]: () => Promise<void>; | ||
| 186 | cleanup: () => Promise<void>; | ||
| 187 | setViewport: (viewport: ViewportSpec) => Promise<void>; | ||
| 188 | setContainerSize: (viewport: Pick<ViewportSpec, "width" | "height">) => Promise<void>; | ||
| 189 | router: ReturnType<typeof getRouter>; | ||
| 190 | } | ||
| 191 | |||
| 192 | function isoAt(offsetSeconds: number): string { | ||
| 193 | return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); | ||
| 194 | } | ||
| 195 | |||
| 196 | function createBaseServerConfig(): ServerConfig { | ||
| 197 | return { | ||
| 198 | environment: { | ||
| 199 | environmentId: EnvironmentId.make("environment-local"), | ||
| 200 | label: "Local environment", | ||
| 201 | platform: { os: "darwin" as const, arch: "arm64" as const }, | ||
| 202 | serverVersion: "0.0.0-test", | ||
| 203 | capabilities: { repositoryIdentity: true }, | ||
| 204 | }, | ||
| 205 | auth: { | ||
| 206 | policy: "loopback-browser", | ||
| 207 | bootstrapMethods: ["one-time-token"], | ||
| 208 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | ||
| 209 | sessionCookieName: "t3_session", | ||
| 210 | }, | ||
| 211 | cwd: "/repo/project", | ||
| 212 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | ||
| 213 | keybindings: [], | ||
| 214 | issues: [], | ||
| 215 | providers: [ | ||
| 216 | { | ||
| 217 | driver: ProviderDriverKind.make("codex"), | ||
| 218 | instanceId: ProviderInstanceId.make("codex"), | ||
| 219 | enabled: true, | ||
| 220 | installed: true, | ||
| 221 | version: "0.116.0", | ||
| 222 | status: "ready", | ||
| 223 | auth: { status: "authenticated" }, | ||
| 224 | checkedAt: NOW_ISO, | ||
| 225 | models: [], | ||
| 226 | slashCommands: [], | ||
| 227 | skills: [], | ||
| 228 | }, | ||
| 229 | ], | ||
| 230 | availableEditors: [], | ||
| 231 | observability: { | ||
| 232 | logsDirectoryPath: "/repo/project/.t3/logs", | ||
| 233 | localTracingEnabled: true, | ||
| 234 | otlpTracesEnabled: false, | ||
| 235 | otlpMetricsEnabled: false, | ||
| 236 | }, | ||
| 237 | settings: { | ||
| 238 | ...DEFAULT_SERVER_SETTINGS, | ||
| 239 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 240 | }, | ||
| 241 | }; | ||
| 242 | } | ||
| 243 | |||
| 244 | function createMockEnvironmentApi(input: { | ||
| 245 | browse: EnvironmentApi["filesystem"]["browse"]; | ||
| 246 | dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; | ||
| 247 | }): EnvironmentApi { | ||
| 248 | return { | ||
| 249 | terminal: {} as EnvironmentApi["terminal"], | ||
| 250 | projects: {} as EnvironmentApi["projects"], | ||
| 251 | filesystem: { | ||
| 252 | browse: input.browse, | ||
| 253 | }, | ||
| 254 | assets: { | ||
| 255 | createUrl: vi.fn(async ({ resource }) => ({ | ||
| 256 | relativeUrl: `/api/assets/test/${encodeURIComponent( | ||
| 257 | resource._tag === "attachment" | ||
| 258 | ? resource.attachmentId | ||
| 259 | : resource._tag === "project-favicon" | ||
| 260 | ? "favicon.svg" | ||
| 261 | : (resource.path.split(/[\\/]/).at(-1) ?? "asset"), | ||
| 262 | )}`, | ||
| 263 | expiresAt: Date.now() + 60_000, | ||
| 264 | })), | ||
| 265 | }, | ||
| 266 | sourceControl: {} as EnvironmentApi["sourceControl"], | ||
| 267 | vcs: {} as EnvironmentApi["vcs"], | ||
| 268 | git: {} as EnvironmentApi["git"], | ||
| 269 | review: {} as EnvironmentApi["review"], | ||
| 270 | orchestration: { | ||
| 271 | dispatchCommand: input.dispatchCommand, | ||
| 272 | getTurnDiff: (() => { | ||
| 273 | throw new Error("Not implemented in browser test."); | ||
| 274 | }) as EnvironmentApi["orchestration"]["getTurnDiff"], | ||
| 275 | getFullThreadDiff: (() => { | ||
| 276 | throw new Error("Not implemented in browser test."); | ||
| 277 | }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], | ||
| 278 | getArchivedShellSnapshot: (() => { | ||
| 279 | throw new Error("Not implemented in browser test."); | ||
| 280 | }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"], | ||
| 281 | subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], | ||
| 282 | subscribeThread: (() => () => | ||
| 283 | undefined) as EnvironmentApi["orchestration"]["subscribeThread"], | ||
| 284 | }, | ||
| 285 | preview: { | ||
| 286 | open: () => { | ||
| 287 | throw new Error("Not implemented in browser test."); | ||
| 288 | }, | ||
| 289 | navigate: () => { | ||
| 290 | throw new Error("Not implemented in browser test."); | ||
| 291 | }, | ||
| 292 | refresh: () => { | ||
| 293 | throw new Error("Not implemented in browser test."); | ||
| 294 | }, | ||
| 295 | close: () => { | ||
| 296 | throw new Error("Not implemented in browser test."); | ||
| 297 | }, | ||
| 298 | list: () => Promise.resolve({ sessions: [] }), | ||
| 299 | reportStatus: () => { | ||
| 300 | throw new Error("Not implemented in browser test."); | ||
| 301 | }, | ||
| 302 | automation: { | ||
| 303 | connect: () => () => undefined, | ||
| 304 | respond: () => Promise.resolve(), | ||
| 305 | reportOwner: () => Promise.resolve(), | ||
| 306 | clearOwner: () => Promise.resolve(), | ||
| 307 | }, | ||
| 308 | onEvent: () => () => undefined, | ||
| 309 | subscribePorts: () => () => undefined, | ||
| 310 | } as EnvironmentApi["preview"], | ||
| 311 | }; | ||
| 312 | } | ||
| 313 | |||
| 314 | function createUserMessage(options: { | ||
| 315 | id: MessageId; | ||
| 316 | text: string; | ||
| 317 | offsetSeconds: number; | ||
| 318 | attachments?: Array<{ | ||
| 319 | type: "image"; | ||
| 320 | id: string; | ||
| 321 | name: string; | ||
| 322 | mimeType: string; | ||
| 323 | sizeBytes: number; | ||
| 324 | }>; | ||
| 325 | }) { | ||
| 326 | return { | ||
| 327 | id: options.id, | ||
| 328 | role: "user" as const, | ||
| 329 | text: options.text, | ||
| 330 | ...(options.attachments ? { attachments: options.attachments } : {}), | ||
| 331 | turnId: null, | ||
| 332 | streaming: false, | ||
| 333 | createdAt: isoAt(options.offsetSeconds), | ||
| 334 | updatedAt: isoAt(options.offsetSeconds + 1), | ||
| 335 | }; | ||
| 336 | } | ||
| 337 | |||
| 338 | function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number }) { | ||
| 339 | return { | ||
| 340 | id: options.id, | ||
| 341 | role: "assistant" as const, | ||
| 342 | text: options.text, | ||
| 343 | turnId: null, | ||
| 344 | streaming: false, | ||
| 345 | createdAt: isoAt(options.offsetSeconds), | ||
| 346 | updatedAt: isoAt(options.offsetSeconds + 1), | ||
| 347 | }; | ||
| 348 | } | ||
| 349 | |||
| 350 | function createTerminalContext(input: { | ||
| 351 | id: string; | ||
| 352 | terminalLabel: string; | ||
| 353 | lineStart: number; | ||
| 354 | lineEnd: number; | ||
| 355 | text: string; | ||
| 356 | }): TerminalContextDraft { | ||
| 357 | return { | ||
| 358 | id: input.id, | ||
| 359 | threadId: THREAD_ID, | ||
| 360 | terminalId: `terminal-${input.id}`, | ||
| 361 | terminalLabel: input.terminalLabel, | ||
| 362 | lineStart: input.lineStart, | ||
| 363 | lineEnd: input.lineEnd, | ||
| 364 | text: input.text, | ||
| 365 | createdAt: NOW_ISO, | ||
| 366 | }; | ||
| 367 | } | ||
| 368 | |||
| 369 | function createSnapshotForTargetUser(options: { | ||
| 370 | targetMessageId: MessageId; | ||
| 371 | targetText: string; | ||
| 372 | targetAttachmentCount?: number; | ||
| 373 | sessionStatus?: OrchestrationSessionStatus; | ||
| 374 | }): OrchestrationReadModel { | ||
| 375 | const messages: Array<OrchestrationReadModel["threads"][number]["messages"][number]> = []; | ||
| 376 | |||
| 377 | for (let index = 0; index < 22; index += 1) { | ||
| 378 | const isTarget = index === 3; | ||
| 379 | const userId = `msg-user-${index}` as MessageId; | ||
| 380 | const assistantId = `msg-assistant-${index}` as MessageId; | ||
| 381 | const attachments = | ||
| 382 | isTarget && (options.targetAttachmentCount ?? 0) > 0 | ||
| 383 | ? Array.from({ length: options.targetAttachmentCount ?? 0 }, (_, attachmentIndex) => ({ | ||
| 384 | type: "image" as const, | ||
| 385 | id: `attachment-${attachmentIndex + 1}`, | ||
| 386 | name: `attachment-${attachmentIndex + 1}.png`, | ||
| 387 | mimeType: "image/png", | ||
| 388 | sizeBytes: 128, | ||
| 389 | })) | ||
| 390 | : undefined; | ||
| 391 | |||
| 392 | messages.push( | ||
| 393 | createUserMessage({ | ||
| 394 | id: isTarget ? options.targetMessageId : userId, | ||
| 395 | text: isTarget ? options.targetText : `filler user message ${index}`, | ||
| 396 | offsetSeconds: messages.length * 3, | ||
| 397 | ...(attachments ? { attachments } : {}), | ||
| 398 | }), | ||
| 399 | ); | ||
| 400 | messages.push( | ||
| 401 | createAssistantMessage({ | ||
| 402 | id: assistantId, | ||
| 403 | text: `assistant filler ${index}`, | ||
| 404 | offsetSeconds: messages.length * 3, | ||
| 405 | }), | ||
| 406 | ); | ||
| 407 | } | ||
| 408 | |||
| 409 | return { | ||
| 410 | snapshotSequence: 1, | ||
| 411 | projects: [ | ||
| 412 | { | ||
| 413 | id: PROJECT_ID, | ||
| 414 | title: "Project", | ||
| 415 | workspaceRoot: "/repo/project", | ||
| 416 | defaultModelSelection: { | ||
| 417 | instanceId: ProviderInstanceId.make("codex"), | ||
| 418 | model: "gpt-5", | ||
| 419 | }, | ||
| 420 | scripts: [], | ||
| 421 | createdAt: NOW_ISO, | ||
| 422 | updatedAt: NOW_ISO, | ||
| 423 | deletedAt: null, | ||
| 424 | }, | ||
| 425 | ], | ||
| 426 | threads: [ | ||
| 427 | { | ||
| 428 | id: THREAD_ID, | ||
| 429 | projectId: PROJECT_ID, | ||
| 430 | title: THREAD_TITLE, | ||
| 431 | modelSelection: { | ||
| 432 | instanceId: ProviderInstanceId.make("codex"), | ||
| 433 | model: "gpt-5", | ||
| 434 | }, | ||
| 435 | interactionMode: "default", | ||
| 436 | runtimeMode: "full-access", | ||
| 437 | branch: "main", | ||
| 438 | worktreePath: null, | ||
| 439 | latestTurn: null, | ||
| 440 | createdAt: NOW_ISO, | ||
| 441 | updatedAt: NOW_ISO, | ||
| 442 | archivedAt: null, | ||
| 443 | deletedAt: null, | ||
| 444 | messages, | ||
| 445 | activities: [], | ||
| 446 | proposedPlans: [], | ||
| 447 | checkpoints: [], | ||
| 448 | session: { | ||
| 449 | threadId: THREAD_ID, | ||
| 450 | status: options.sessionStatus ?? "ready", | ||
| 451 | providerName: "codex", | ||
| 452 | runtimeMode: "full-access", | ||
| 453 | activeTurnId: null, | ||
| 454 | lastError: null, | ||
| 455 | updatedAt: NOW_ISO, | ||
| 456 | }, | ||
| 457 | }, | ||
| 458 | ], | ||
| 459 | updatedAt: NOW_ISO, | ||
| 460 | }; | ||
| 461 | } | ||
| 462 | |||
| 463 | function buildFixture(snapshot: OrchestrationReadModel): TestFixture { | ||
| 464 | return { | ||
| 465 | snapshot, | ||
| 466 | serverConfig: createBaseServerConfig(), | ||
| 467 | welcome: { | ||
| 468 | environment: { | ||
| 469 | environmentId: EnvironmentId.make("environment-local"), | ||
| 470 | label: "Local environment", | ||
| 471 | platform: { os: "darwin" as const, arch: "arm64" as const }, | ||
| 472 | serverVersion: "0.0.0-test", | ||
| 473 | capabilities: { repositoryIdentity: true }, | ||
| 474 | }, | ||
| 475 | cwd: "/repo/project", | ||
| 476 | projectName: "Project", | ||
| 477 | bootstrapProjectId: PROJECT_ID, | ||
| 478 | bootstrapThreadId: THREAD_ID, | ||
| 479 | }, | ||
| 480 | terminalMetadataEvents: [], | ||
| 481 | }; | ||
| 482 | } | ||
| 483 | |||
| 484 | function addThreadToSnapshot( | ||
| 485 | snapshot: OrchestrationReadModel, | ||
| 486 | threadId: ThreadId, | ||
| 487 | ): OrchestrationReadModel { | ||
| 488 | return { | ||
| 489 | ...snapshot, | ||
| 490 | snapshotSequence: snapshot.snapshotSequence + 1, | ||
| 491 | threads: [ | ||
| 492 | ...snapshot.threads, | ||
| 493 | { | ||
| 494 | id: threadId, | ||
| 495 | projectId: PROJECT_ID, | ||
| 496 | title: "New thread", | ||
| 497 | modelSelection: { | ||
| 498 | instanceId: ProviderInstanceId.make("codex"), | ||
| 499 | model: "gpt-5", | ||
| 500 | }, | ||
| 501 | interactionMode: "default", | ||
| 502 | runtimeMode: "full-access", | ||
| 503 | branch: "main", | ||
| 504 | worktreePath: null, | ||
| 505 | latestTurn: null, | ||
| 506 | createdAt: NOW_ISO, | ||
| 507 | updatedAt: NOW_ISO, | ||
| 508 | archivedAt: null, | ||
| 509 | deletedAt: null, | ||
| 510 | messages: [], | ||
| 511 | activities: [], | ||
| 512 | proposedPlans: [], | ||
| 513 | checkpoints: [], | ||
| 514 | session: { | ||
| 515 | threadId, | ||
| 516 | status: "ready", | ||
| 517 | providerName: "codex", | ||
| 518 | runtimeMode: "full-access", | ||
| 519 | activeTurnId: null, | ||
| 520 | lastError: null, | ||
| 521 | updatedAt: NOW_ISO, | ||
| 522 | }, | ||
| 523 | }, | ||
| 524 | ], | ||
| 525 | }; | ||
| 526 | } | ||
| 527 | |||
| 528 | function toShellThread(thread: OrchestrationReadModel["threads"][number]) { | ||
| 529 | return { | ||
| 530 | id: thread.id, | ||
| 531 | projectId: thread.projectId, | ||
| 532 | title: thread.title, | ||
| 533 | modelSelection: thread.modelSelection, | ||
| 534 | runtimeMode: thread.runtimeMode, | ||
| 535 | interactionMode: thread.interactionMode, | ||
| 536 | branch: thread.branch, | ||
| 537 | worktreePath: thread.worktreePath, | ||
| 538 | latestTurn: thread.latestTurn, | ||
| 539 | createdAt: thread.createdAt, | ||
| 540 | updatedAt: thread.updatedAt, | ||
| 541 | archivedAt: thread.archivedAt, | ||
| 542 | session: thread.session, | ||
| 543 | latestUserMessageAt: | ||
| 544 | thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, | ||
| 545 | hasPendingApprovals: false, | ||
| 546 | hasPendingUserInput: false, | ||
| 547 | hasActionableProposedPlan: false, | ||
| 548 | }; | ||
| 549 | } | ||
| 550 | |||
| 551 | function toShellSnapshot(snapshot: OrchestrationReadModel) { | ||
| 552 | return { | ||
| 553 | snapshotSequence: snapshot.snapshotSequence, | ||
| 554 | projects: snapshot.projects.map((project) => ({ | ||
| 555 | id: project.id, | ||
| 556 | title: project.title, | ||
| 557 | workspaceRoot: project.workspaceRoot, | ||
| 558 | repositoryIdentity: project.repositoryIdentity ?? null, | ||
| 559 | defaultModelSelection: project.defaultModelSelection, | ||
| 560 | scripts: project.scripts, | ||
| 561 | createdAt: project.createdAt, | ||
| 562 | updatedAt: project.updatedAt, | ||
| 563 | })), | ||
| 564 | threads: snapshot.threads.map(toShellThread), | ||
| 565 | updatedAt: snapshot.updatedAt, | ||
| 566 | }; | ||
| 567 | } | ||
| 568 | |||
| 569 | function updateThreadSessionInSnapshot( | ||
| 570 | snapshot: OrchestrationReadModel, | ||
| 571 | threadId: ThreadId, | ||
| 572 | session: OrchestrationReadModel["threads"][number]["session"], | ||
| 573 | ): OrchestrationReadModel { | ||
| 574 | return { | ||
| 575 | ...snapshot, | ||
| 576 | snapshotSequence: snapshot.snapshotSequence + 1, | ||
| 577 | threads: snapshot.threads.map((thread) => | ||
| 578 | thread.id === threadId | ||
| 579 | ? { | ||
| 580 | ...thread, | ||
| 581 | session, | ||
| 582 | updatedAt: NOW_ISO, | ||
| 583 | } | ||
| 584 | : thread, | ||
| 585 | ), | ||
| 586 | }; | ||
| 587 | } | ||
| 588 | |||
| 589 | function sendShellThreadUpsert( | ||
| 590 | threadId: ThreadId, | ||
| 591 | options?: { | ||
| 592 | readonly session?: OrchestrationReadModel["threads"][number]["session"]; | ||
| 593 | }, | ||
| 594 | ): void { | ||
| 595 | const thread = fixture.snapshot.threads.find((entry) => entry.id === threadId); | ||
| 596 | if (!thread) { | ||
| 597 | throw new Error(`Expected thread ${threadId} in snapshot.`); | ||
| 598 | } | ||
| 599 | |||
| 600 | const shellThread = | ||
| 601 | options?.session !== undefined | ||
| 602 | ? toShellThread({ ...thread, session: options.session }) | ||
| 603 | : toShellThread(thread); | ||
| 604 | rpcHarness.emitStreamValue(ORCHESTRATION_WS_METHODS.subscribeShell, { | ||
| 605 | kind: "thread-upserted", | ||
| 606 | sequence: fixture.snapshot.snapshotSequence, | ||
| 607 | thread: shellThread, | ||
| 608 | }); | ||
| 609 | } | ||
| 610 | |||
| 611 | async function waitForWsClient(): Promise<void> { | ||
| 612 | await vi.waitFor( | ||
| 613 | () => { | ||
| 614 | expect( | ||
| 615 | wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell), | ||
| 616 | ).toBe(true); | ||
| 617 | expect( | ||
| 618 | wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), | ||
| 619 | ).toBe(true); | ||
| 620 | expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( | ||
| 621 | true, | ||
| 622 | ); | ||
| 623 | }, | ||
| 624 | { timeout: 8_000, interval: 16 }, | ||
| 625 | ); | ||
| 626 | } | ||
| 627 | |||
| 628 | function threadRefFor(threadId: ThreadId) { | ||
| 629 | return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); | ||
| 630 | } | ||
| 631 | |||
| 632 | function threadKeyFor(threadId: ThreadId): string { | ||
| 633 | return scopedThreadKey(threadRefFor(threadId)); | ||
| 634 | } | ||
| 635 | |||
| 636 | function composerDraftFor(target: string) { | ||
| 637 | const { draftsByThreadKey } = useComposerDraftStore.getState(); | ||
| 638 | return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)]; | ||
| 639 | } | ||
| 640 | |||
| 641 | function draftIdFromPath(pathname: string) { | ||
| 642 | const segments = pathname.split("/"); | ||
| 643 | const draftId = segments[segments.length - 1]; | ||
| 644 | if (!draftId) { | ||
| 645 | throw new Error(`Expected thread path, received "${pathname}".`); | ||
| 646 | } | ||
| 647 | return DraftId.make(draftId); | ||
| 648 | } | ||
| 649 | |||
| 650 | function draftThreadIdFor(draftId: ReturnType<typeof draftIdFromPath>): ThreadId { | ||
| 651 | const draftSession = useComposerDraftStore.getState().getDraftSession(draftId); | ||
| 652 | if (!draftSession) { | ||
| 653 | throw new Error(`Expected draft session for "${draftId}".`); | ||
| 654 | } | ||
| 655 | return draftSession.threadId; | ||
| 656 | } | ||
| 657 | |||
| 658 | function serverThreadPath(threadId: ThreadId): string { | ||
| 659 | return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`; | ||
| 660 | } | ||
| 661 | |||
| 662 | async function waitForAppBootstrap(): Promise<void> { | ||
| 663 | await vi.waitFor( | ||
| 664 | () => { | ||
| 665 | expect(getServerConfig()).not.toBeNull(); | ||
| 666 | expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true); | ||
| 667 | }, | ||
| 668 | { timeout: 8_000, interval: 16 }, | ||
| 669 | ); | ||
| 670 | } | ||
| 671 | |||
| 672 | async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | ||
| 673 | await waitForWsClient(); | ||
| 674 | fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); | ||
| 675 | fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, null); | ||
| 676 | sendShellThreadUpsert(threadId, { session: null }); | ||
| 677 | } | ||
| 678 | |||
| 679 | async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | ||
| 680 | fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, { | ||
| 681 | threadId, | ||
| 682 | status: "running", | ||
| 683 | providerName: "codex", | ||
| 684 | runtimeMode: "full-access", | ||
| 685 | activeTurnId: `turn-${threadId}` as TurnId, | ||
| 686 | lastError: null, | ||
| 687 | updatedAt: NOW_ISO, | ||
| 688 | }); | ||
| 689 | sendShellThreadUpsert(threadId); | ||
| 690 | } | ||
| 691 | |||
| 692 | async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | ||
| 693 | await materializePromotedDraftThreadViaDomainEvent(threadId); | ||
| 694 | await startPromotedServerThreadViaDomainEvent(threadId); | ||
| 695 | await vi.waitFor( | ||
| 696 | () => { | ||
| 697 | expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe( | ||
| 698 | undefined, | ||
| 699 | ); | ||
| 700 | }, | ||
| 701 | { timeout: 8_000, interval: 16 }, | ||
| 702 | ); | ||
| 703 | } | ||
| 704 | |||
| 705 | function createDraftOnlySnapshot(): OrchestrationReadModel { | ||
| 706 | const snapshot = createSnapshotForTargetUser({ | ||
| 707 | targetMessageId: "msg-user-draft-target" as MessageId, | ||
| 708 | targetText: "draft thread", | ||
| 709 | }); | ||
| 710 | return { | ||
| 711 | ...snapshot, | ||
| 712 | threads: [], | ||
| 713 | }; | ||
| 714 | } | ||
| 715 | |||
| 716 | function createProjectlessSnapshot(): OrchestrationReadModel { | ||
| 717 | const snapshot = createSnapshotForTargetUser({ | ||
| 718 | targetMessageId: "msg-user-projectless-target" as MessageId, | ||
| 719 | targetText: "projectless", | ||
| 720 | }); | ||
| 721 | return { | ||
| 722 | ...snapshot, | ||
| 723 | projects: [], | ||
| 724 | threads: [], | ||
| 725 | }; | ||
| 726 | } | ||
| 727 | |||
| 728 | function withProjectScripts( | ||
| 729 | snapshot: OrchestrationReadModel, | ||
| 730 | scripts: OrchestrationReadModel["projects"][number]["scripts"], | ||
| 731 | ): OrchestrationReadModel { | ||
| 732 | return { | ||
| 733 | ...snapshot, | ||
| 734 | projects: snapshot.projects.map((project) => | ||
| 735 | project.id === PROJECT_ID ? { ...project, scripts: Array.from(scripts) } : project, | ||
| 736 | ), | ||
| 737 | }; | ||
| 738 | } | ||
| 739 | |||
| 740 | function setDraftThreadWithoutWorktree(): void { | ||
| 741 | useComposerDraftStore.setState({ | ||
| 742 | draftThreadsByThreadKey: { | ||
| 743 | [THREAD_KEY]: { | ||
| 744 | threadId: THREAD_ID, | ||
| 745 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 746 | projectId: PROJECT_ID, | ||
| 747 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 748 | createdAt: NOW_ISO, | ||
| 749 | runtimeMode: "full-access", | ||
| 750 | interactionMode: "default", | ||
| 751 | branch: null, | ||
| 752 | worktreePath: null, | ||
| 753 | envMode: "local", | ||
| 754 | }, | ||
| 755 | }, | ||
| 756 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | ||
| 757 | [PROJECT_DRAFT_KEY]: THREAD_KEY, | ||
| 758 | }, | ||
| 759 | }); | ||
| 760 | } | ||
| 761 | |||
| 762 | function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { | ||
| 763 | const snapshot = createSnapshotForTargetUser({ | ||
| 764 | targetMessageId: "msg-user-plan-target" as MessageId, | ||
| 765 | targetText: "plan thread", | ||
| 766 | }); | ||
| 767 | const planMarkdown = [ | ||
| 768 | "# Ship plan mode follow-up", | ||
| 769 | "", | ||
| 770 | "- Step 1: capture the thread-open trace", | ||
| 771 | "- Step 2: identify the main-thread bottleneck", | ||
| 772 | "- Step 3: keep collapsed cards cheap", | ||
| 773 | "- Step 4: render the full markdown only on demand", | ||
| 774 | "- Step 5: preserve export and save actions", | ||
| 775 | "- Step 6: add regression coverage", | ||
| 776 | "- Step 7: verify route transitions stay responsive", | ||
| 777 | "- Step 8: confirm no server-side work changed", | ||
| 778 | "- Step 9: confirm short plans still render normally", | ||
| 779 | "- Step 10: confirm long plans stay collapsed by default", | ||
| 780 | "- Step 11: confirm preview text is still useful", | ||
| 781 | "- Step 12: confirm plan follow-up flow still works", | ||
| 782 | "- Step 13: confirm timeline virtualization still behaves", | ||
| 783 | "- Step 14: confirm theme styling still looks correct", | ||
| 784 | "- Step 15: confirm save dialog behavior is unchanged", | ||
| 785 | "- Step 16: confirm download behavior is unchanged", | ||
| 786 | "- Step 17: confirm code fences do not parse until expand", | ||
| 787 | "- Step 18: confirm preview truncation ends cleanly", | ||
| 788 | "- Step 19: confirm markdown links still open in editor after expand", | ||
| 789 | "- Step 20: confirm deep hidden detail only appears after expand", | ||
| 790 | "", | ||
| 791 | "```ts", | ||
| 792 | "export const hiddenPlanImplementationDetail = 'deep hidden detail only after expand';", | ||
| 793 | "```", | ||
| 794 | ].join("\n"); | ||
| 795 | |||
| 796 | return { | ||
| 797 | ...snapshot, | ||
| 798 | threads: snapshot.threads.map((thread) => | ||
| 799 | thread.id === THREAD_ID | ||
| 800 | ? Object.assign({}, thread, { | ||
| 801 | proposedPlans: [ | ||
| 802 | { | ||
| 803 | id: "plan-browser-test", | ||
| 804 | turnId: null, | ||
| 805 | planMarkdown, | ||
| 806 | implementedAt: null, | ||
| 807 | implementationThreadId: null, | ||
| 808 | createdAt: isoAt(1_000), | ||
| 809 | updatedAt: isoAt(1_001), | ||
| 810 | }, | ||
| 811 | ], | ||
| 812 | updatedAt: isoAt(1_001), | ||
| 813 | }) | ||
| 814 | : thread, | ||
| 815 | ), | ||
| 816 | }; | ||
| 817 | } | ||
| 818 | |||
| 819 | function createSnapshotWithSecondaryProject(options?: { | ||
| 820 | includeSecondaryThread?: boolean; | ||
| 821 | includeArchivedSecondaryThread?: boolean; | ||
| 822 | }): OrchestrationReadModel { | ||
| 823 | const snapshot = createSnapshotForTargetUser({ | ||
| 824 | targetMessageId: "msg-user-secondary-project-target" as MessageId, | ||
| 825 | targetText: "secondary project", | ||
| 826 | }); | ||
| 827 | const includeSecondaryThread = options?.includeSecondaryThread ?? true; | ||
| 828 | const includeArchivedSecondaryThread = options?.includeArchivedSecondaryThread ?? true; | ||
| 829 | const secondaryThreads: OrchestrationReadModel["threads"] = includeSecondaryThread | ||
| 830 | ? [ | ||
| 831 | { | ||
| 832 | id: "thread-secondary-project" as ThreadId, | ||
| 833 | projectId: SECOND_PROJECT_ID, | ||
| 834 | title: "Release checklist", | ||
| 835 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 836 | interactionMode: "default", | ||
| 837 | runtimeMode: "full-access", | ||
| 838 | branch: "release/docs-portal", | ||
| 839 | worktreePath: null, | ||
| 840 | latestTurn: null, | ||
| 841 | createdAt: isoAt(30), | ||
| 842 | updatedAt: isoAt(31), | ||
| 843 | deletedAt: null, | ||
| 844 | messages: [], | ||
| 845 | activities: [], | ||
| 846 | proposedPlans: [], | ||
| 847 | checkpoints: [], | ||
| 848 | session: { | ||
| 849 | threadId: "thread-secondary-project" as ThreadId, | ||
| 850 | status: "ready", | ||
| 851 | providerName: "codex", | ||
| 852 | runtimeMode: "full-access", | ||
| 853 | activeTurnId: null, | ||
| 854 | lastError: null, | ||
| 855 | updatedAt: isoAt(31), | ||
| 856 | }, | ||
| 857 | archivedAt: null, | ||
| 858 | }, | ||
| 859 | ] | ||
| 860 | : []; | ||
| 861 | const archivedSecondaryThreads: OrchestrationReadModel["threads"] = includeArchivedSecondaryThread | ||
| 862 | ? [ | ||
| 863 | { | ||
| 864 | id: ARCHIVED_SECONDARY_THREAD_ID, | ||
| 865 | projectId: SECOND_PROJECT_ID, | ||
| 866 | title: "Archived Docs Notes", | ||
| 867 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 868 | interactionMode: "default", | ||
| 869 | runtimeMode: "full-access", | ||
| 870 | branch: "release/docs-archive", | ||
| 871 | worktreePath: null, | ||
| 872 | latestTurn: null, | ||
| 873 | createdAt: isoAt(24), | ||
| 874 | updatedAt: isoAt(25), | ||
| 875 | deletedAt: null, | ||
| 876 | messages: [], | ||
| 877 | activities: [], | ||
| 878 | proposedPlans: [], | ||
| 879 | checkpoints: [], | ||
| 880 | session: { | ||
| 881 | threadId: ARCHIVED_SECONDARY_THREAD_ID, | ||
| 882 | status: "ready", | ||
| 883 | providerName: "codex", | ||
| 884 | runtimeMode: "full-access", | ||
| 885 | activeTurnId: null, | ||
| 886 | lastError: null, | ||
| 887 | updatedAt: isoAt(25), | ||
| 888 | }, | ||
| 889 | archivedAt: isoAt(26), | ||
| 890 | }, | ||
| 891 | ] | ||
| 892 | : []; | ||
| 893 | |||
| 894 | return { | ||
| 895 | ...snapshot, | ||
| 896 | projects: [ | ||
| 897 | ...snapshot.projects, | ||
| 898 | { | ||
| 899 | id: SECOND_PROJECT_ID, | ||
| 900 | title: "Docs Portal", | ||
| 901 | workspaceRoot: "/repo/clients/docs-portal", | ||
| 902 | defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 903 | scripts: [], | ||
| 904 | createdAt: NOW_ISO, | ||
| 905 | updatedAt: NOW_ISO, | ||
| 906 | deletedAt: null, | ||
| 907 | }, | ||
| 908 | ], | ||
| 909 | threads: [...snapshot.threads, ...secondaryThreads, ...archivedSecondaryThreads], | ||
| 910 | }; | ||
| 911 | } | ||
| 912 | |||
| 913 | function createSnapshotWithPendingUserInput(): OrchestrationReadModel { | ||
| 914 | const snapshot = createSnapshotForTargetUser({ | ||
| 915 | targetMessageId: "msg-user-pending-input-target" as MessageId, | ||
| 916 | targetText: "question thread", | ||
| 917 | }); | ||
| 918 | |||
| 919 | return { | ||
| 920 | ...snapshot, | ||
| 921 | threads: snapshot.threads.map((thread) => | ||
| 922 | thread.id === THREAD_ID | ||
| 923 | ? Object.assign({}, thread, { | ||
| 924 | interactionMode: "plan", | ||
| 925 | activities: [ | ||
| 926 | { | ||
| 927 | id: EventId.make("activity-user-input-requested"), | ||
| 928 | tone: "info", | ||
| 929 | kind: "user-input.requested", | ||
| 930 | summary: "User input requested", | ||
| 931 | payload: { | ||
| 932 | requestId: "req-browser-user-input", | ||
| 933 | questions: [ | ||
| 934 | { | ||
| 935 | id: "scope", | ||
| 936 | header: "Scope", | ||
| 937 | question: "What should this change cover?", | ||
| 938 | options: [ | ||
| 939 | { | ||
| 940 | label: "Tight", | ||
| 941 | description: "Touch only the footer layout logic.", | ||
| 942 | }, | ||
| 943 | { | ||
| 944 | label: "Broad", | ||
| 945 | description: "Also adjust the related composer controls.", | ||
| 946 | }, | ||
| 947 | ], | ||
| 948 | }, | ||
| 949 | { | ||
| 950 | id: "risk", | ||
| 951 | header: "Risk", | ||
| 952 | question: "How aggressive should the imaginary plan be?", | ||
| 953 | options: [ | ||
| 954 | { | ||
| 955 | label: "Conservative", | ||
| 956 | description: "Favor reliability and low-risk changes.", | ||
| 957 | }, | ||
| 958 | { | ||
| 959 | label: "Balanced", | ||
| 960 | description: "Mix quick wins with one structural improvement.", | ||
| 961 | }, | ||
| 962 | ], | ||
| 963 | }, | ||
| 964 | ], | ||
| 965 | }, | ||
| 966 | turnId: null, | ||
| 967 | sequence: 1, | ||
| 968 | createdAt: isoAt(1_000), | ||
| 969 | }, | ||
| 970 | ], | ||
| 971 | updatedAt: isoAt(1_000), | ||
| 972 | }) | ||
| 973 | : thread, | ||
| 974 | ), | ||
| 975 | }; | ||
| 976 | } | ||
| 977 | |||
| 978 | function createSnapshotWithPlanFollowUpPrompt(options?: { | ||
| 979 | modelSelection?: { instanceId: ProviderInstanceId; model: string }; | ||
| 980 | planMarkdown?: string; | ||
| 981 | }): OrchestrationReadModel { | ||
| 982 | const snapshot = createSnapshotForTargetUser({ | ||
| 983 | targetMessageId: "msg-user-plan-follow-up-target" as MessageId, | ||
| 984 | targetText: "plan follow-up thread", | ||
| 985 | }); | ||
| 986 | const modelSelection = options?.modelSelection ?? { | ||
| 987 | instanceId: ProviderInstanceId.make("codex"), | ||
| 988 | model: "gpt-5", | ||
| 989 | }; | ||
| 990 | const planMarkdown = | ||
| 991 | options?.planMarkdown ?? "# Follow-up plan\n\n- Keep the composer footer stable on resize."; | ||
| 992 | |||
| 993 | return { | ||
| 994 | ...snapshot, | ||
| 995 | projects: snapshot.projects.map((project) => | ||
| 996 | project.id === PROJECT_ID ? { ...project, defaultModelSelection: modelSelection } : project, | ||
| 997 | ), | ||
| 998 | threads: snapshot.threads.map((thread) => | ||
| 999 | thread.id === THREAD_ID | ||
| 1000 | ? Object.assign({}, thread, { | ||
| 1001 | modelSelection, | ||
| 1002 | interactionMode: "plan", | ||
| 1003 | latestTurn: { | ||
| 1004 | turnId: "turn-plan-follow-up" as TurnId, | ||
| 1005 | state: "completed", | ||
| 1006 | requestedAt: isoAt(1_000), | ||
| 1007 | startedAt: isoAt(1_001), | ||
| 1008 | completedAt: isoAt(1_010), | ||
| 1009 | assistantMessageId: null, | ||
| 1010 | }, | ||
| 1011 | proposedPlans: [ | ||
| 1012 | { | ||
| 1013 | id: "plan-follow-up-browser-test", | ||
| 1014 | turnId: "turn-plan-follow-up" as TurnId, | ||
| 1015 | planMarkdown, | ||
| 1016 | implementedAt: null, | ||
| 1017 | implementationThreadId: null, | ||
| 1018 | createdAt: isoAt(1_002), | ||
| 1019 | updatedAt: isoAt(1_003), | ||
| 1020 | }, | ||
| 1021 | ], | ||
| 1022 | session: { | ||
| 1023 | ...thread.session, | ||
| 1024 | status: "ready", | ||
| 1025 | updatedAt: isoAt(1_010), | ||
| 1026 | }, | ||
| 1027 | updatedAt: isoAt(1_010), | ||
| 1028 | }) | ||
| 1029 | : thread, | ||
| 1030 | ), | ||
| 1031 | }; | ||
| 1032 | } | ||
| 1033 | |||
| 1034 | function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | ||
| 1035 | const customResult = customWsRpcResolver?.(body); | ||
| 1036 | if (customResult !== undefined) { | ||
| 1037 | return customResult; | ||
| 1038 | } | ||
| 1039 | const tag = body._tag; | ||
| 1040 | if (tag === WS_METHODS.serverGetConfig) { | ||
| 1041 | return encodeServerConfig(fixture.serverConfig); | ||
| 1042 | } | ||
| 1043 | if (tag === WS_METHODS.serverDiscoverSourceControl) { | ||
| 1044 | return { | ||
| 1045 | versionControlSystems: [], | ||
| 1046 | sourceControlProviders: [ | ||
| 1047 | { | ||
| 1048 | kind: "github", | ||
| 1049 | label: "GitHub", | ||
| 1050 | executable: "gh", | ||
| 1051 | status: "available", | ||
| 1052 | version: Option.some("gh version 2.0.0"), | ||
| 1053 | installHint: "Install GitHub CLI.", | ||
| 1054 | detail: Option.none(), | ||
| 1055 | auth: { | ||
| 1056 | status: "authenticated", | ||
| 1057 | account: Option.some("t3-oss"), | ||
| 1058 | host: Option.some("github.com"), | ||
| 1059 | detail: Option.none(), | ||
| 1060 | }, | ||
| 1061 | }, | ||
| 1062 | { | ||
| 1063 | kind: "gitlab", | ||
| 1064 | label: "GitLab", | ||
| 1065 | executable: "glab", | ||
| 1066 | status: "available", | ||
| 1067 | version: Option.some("glab version 1.0.0"), | ||
| 1068 | installHint: "Install GitLab CLI.", | ||
| 1069 | detail: Option.none(), | ||
| 1070 | auth: { | ||
| 1071 | status: "authenticated", | ||
| 1072 | account: Option.some("t3-oss"), | ||
| 1073 | host: Option.some("gitlab.com"), | ||
| 1074 | detail: Option.none(), | ||
| 1075 | }, | ||
| 1076 | }, | ||
| 1077 | { | ||
| 1078 | kind: "bitbucket", | ||
| 1079 | label: "Bitbucket", | ||
| 1080 | executable: "Bitbucket REST API", | ||
| 1081 | status: "available", | ||
| 1082 | version: Option.none(), | ||
| 1083 | installHint: "Set Bitbucket API token environment variables.", | ||
| 1084 | detail: Option.none(), | ||
| 1085 | auth: { | ||
| 1086 | status: "authenticated", | ||
| 1087 | account: Option.some("t3-oss"), | ||
| 1088 | host: Option.some("bitbucket.org"), | ||
| 1089 | detail: Option.none(), | ||
| 1090 | }, | ||
| 1091 | }, | ||
| 1092 | { | ||
| 1093 | kind: "azure-devops", | ||
| 1094 | label: "Azure DevOps", | ||
| 1095 | executable: "az", | ||
| 1096 | status: "available", | ||
| 1097 | version: Option.some("azure-cli 2.0.0"), | ||
| 1098 | installHint: "Install Azure CLI.", | ||
| 1099 | detail: Option.none(), | ||
| 1100 | auth: { | ||
| 1101 | status: "authenticated", | ||
| 1102 | account: Option.some("t3-oss"), | ||
| 1103 | host: Option.some("dev.azure.com"), | ||
| 1104 | detail: Option.none(), | ||
| 1105 | }, | ||
| 1106 | }, | ||
| 1107 | ], | ||
| 1108 | }; | ||
| 1109 | } | ||
| 1110 | if (tag === WS_METHODS.vcsListRefs) { | ||
| 1111 | return { | ||
| 1112 | isRepo: true, | ||
| 1113 | hasPrimaryRemote: true, | ||
| 1114 | nextCursor: null, | ||
| 1115 | totalCount: 1, | ||
| 1116 | refs: [ | ||
| 1117 | { | ||
| 1118 | name: "main", | ||
| 1119 | current: true, | ||
| 1120 | isDefault: true, | ||
| 1121 | worktreePath: null, | ||
| 1122 | }, | ||
| 1123 | ], | ||
| 1124 | }; | ||
| 1125 | } | ||
| 1126 | if (tag === WS_METHODS.projectsSearchEntries) { | ||
| 1127 | return { | ||
| 1128 | entries: [], | ||
| 1129 | truncated: false, | ||
| 1130 | }; | ||
| 1131 | } | ||
| 1132 | if (tag === WS_METHODS.shellOpenInEditor) { | ||
| 1133 | return null; | ||
| 1134 | } | ||
| 1135 | if (tag === WS_METHODS.terminalOpen) { | ||
| 1136 | return { | ||
| 1137 | threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, | ||
| 1138 | terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", | ||
| 1139 | cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", | ||
| 1140 | worktreePath: | ||
| 1141 | typeof body.worktreePath === "string" | ||
| 1142 | ? body.worktreePath | ||
| 1143 | : body.worktreePath === null | ||
| 1144 | ? null | ||
| 1145 | : null, | ||
| 1146 | status: "running", | ||
| 1147 | pid: 123, | ||
| 1148 | history: "", | ||
| 1149 | exitCode: null, | ||
| 1150 | exitSignal: null, | ||
| 1151 | label: "Terminal 1", | ||
| 1152 | updatedAt: NOW_ISO, | ||
| 1153 | }; | ||
| 1154 | } | ||
| 1155 | return {}; | ||
| 1156 | } | ||
| 1157 | |||
| 1158 | const worker = setupWorker( | ||
| 1159 | wsLink.addEventListener("connection", ({ client }) => { | ||
| 1160 | void rpcHarness.connect(client); | ||
| 1161 | client.addEventListener("message", (event) => { | ||
| 1162 | const rawData = event.data; | ||
| 1163 | if (typeof rawData !== "string") return; | ||
| 1164 | void rpcHarness.onMessage(rawData); | ||
| 1165 | }); | ||
| 1166 | }), | ||
| 1167 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), | ||
| 1168 | http.get("*/api/assets/test/:assetName", () => | ||
| 1169 | HttpResponse.text(ATTACHMENT_SVG, { | ||
| 1170 | headers: { | ||
| 1171 | "Content-Type": "image/svg+xml", | ||
| 1172 | }, | ||
| 1173 | }), | ||
| 1174 | ), | ||
| 1175 | ); | ||
| 1176 | |||
| 1177 | async function nextFrame(): Promise<void> { | ||
| 1178 | await new Promise<void>((resolve) => { | ||
| 1179 | window.requestAnimationFrame(() => resolve()); | ||
| 1180 | }); | ||
| 1181 | } | ||
| 1182 | |||
| 1183 | async function waitForLayout(): Promise<void> { | ||
| 1184 | await nextFrame(); | ||
| 1185 | await nextFrame(); | ||
| 1186 | await nextFrame(); | ||
| 1187 | } | ||
| 1188 | |||
| 1189 | async function setViewport(viewport: ViewportSpec): Promise<void> { | ||
| 1190 | await page.viewport(viewport.width, viewport.height); | ||
| 1191 | await waitForLayout(); | ||
| 1192 | } | ||
| 1193 | |||
| 1194 | async function waitForProductionStyles(): Promise<void> { | ||
| 1195 | await vi.waitFor( | ||
| 1196 | () => { | ||
| 1197 | expect( | ||
| 1198 | getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), | ||
| 1199 | ).not.toBe(""); | ||
| 1200 | expect(getComputedStyle(document.body).marginTop).toBe("0px"); | ||
| 1201 | }, | ||
| 1202 | { | ||
| 1203 | timeout: 4_000, | ||
| 1204 | interval: 16, | ||
| 1205 | }, | ||
| 1206 | ); | ||
| 1207 | } | ||
| 1208 | |||
| 1209 | async function waitForElement<T extends Element>( | ||
| 1210 | query: () => T | null, | ||
| 1211 | errorMessage: string, | ||
| 1212 | ): Promise<T> { | ||
| 1213 | let element: T | null = null; | ||
| 1214 | await vi.waitFor( | ||
| 1215 | () => { | ||
| 1216 | element = query(); | ||
| 1217 | expect(element, errorMessage).toBeTruthy(); | ||
| 1218 | }, | ||
| 1219 | { | ||
| 1220 | timeout: 8_000, | ||
| 1221 | interval: 16, | ||
| 1222 | }, | ||
| 1223 | ); | ||
| 1224 | if (!element) { | ||
| 1225 | throw new Error(errorMessage); | ||
| 1226 | } | ||
| 1227 | return element; | ||
| 1228 | } | ||
| 1229 | |||
| 1230 | async function waitForURL( | ||
| 1231 | router: ReturnType<typeof getRouter>, | ||
| 1232 | predicate: (pathname: string) => boolean, | ||
| 1233 | errorMessage: string, | ||
| 1234 | ): Promise<string> { | ||
| 1235 | let pathname = ""; | ||
| 1236 | await vi.waitFor( | ||
| 1237 | () => { | ||
| 1238 | pathname = router.state.location.pathname; | ||
| 1239 | expect(predicate(pathname), errorMessage).toBe(true); | ||
| 1240 | }, | ||
| 1241 | { timeout: 8_000, interval: 16 }, | ||
| 1242 | ); | ||
| 1243 | return pathname; | ||
| 1244 | } | ||
| 1245 | |||
| 1246 | async function waitForComposerEditor(): Promise<HTMLElement> { | ||
| 1247 | return waitForElement( | ||
| 1248 | () => document.querySelector<HTMLElement>('[contenteditable="true"]'), | ||
| 1249 | "Unable to find composer editor.", | ||
| 1250 | ); | ||
| 1251 | } | ||
| 1252 | |||
| 1253 | async function pressComposerKey(key: string): Promise<void> { | ||
| 1254 | const composerEditor = await waitForComposerEditor(); | ||
| 1255 | composerEditor.focus(); | ||
| 1256 | const keydownEvent = new KeyboardEvent("keydown", { | ||
| 1257 | key, | ||
| 1258 | bubbles: true, | ||
| 1259 | cancelable: true, | ||
| 1260 | }); | ||
| 1261 | composerEditor.dispatchEvent(keydownEvent); | ||
| 1262 | if (keydownEvent.defaultPrevented) { | ||
| 1263 | await waitForLayout(); | ||
| 1264 | return; | ||
| 1265 | } | ||
| 1266 | |||
| 1267 | const beforeInputEvent = new InputEvent("beforeinput", { | ||
| 1268 | data: key, | ||
| 1269 | inputType: "insertText", | ||
| 1270 | bubbles: true, | ||
| 1271 | cancelable: true, | ||
| 1272 | }); | ||
| 1273 | composerEditor.dispatchEvent(beforeInputEvent); | ||
| 1274 | if (beforeInputEvent.defaultPrevented) { | ||
| 1275 | await waitForLayout(); | ||
| 1276 | return; | ||
| 1277 | } | ||
| 1278 | |||
| 1279 | if ( | ||
| 1280 | typeof document.execCommand === "function" && | ||
| 1281 | document.execCommand("insertText", false, key) | ||
| 1282 | ) { | ||
| 1283 | await waitForLayout(); | ||
| 1284 | return; | ||
| 1285 | } | ||
| 1286 | |||
| 1287 | const selection = window.getSelection(); | ||
| 1288 | if (!selection || selection.rangeCount === 0) { | ||
| 1289 | throw new Error("Unable to resolve composer selection for text input."); | ||
| 1290 | } | ||
| 1291 | const range = selection.getRangeAt(0); | ||
| 1292 | range.deleteContents(); | ||
| 1293 | const textNode = document.createTextNode(key); | ||
| 1294 | range.insertNode(textNode); | ||
| 1295 | range.setStartAfter(textNode); | ||
| 1296 | range.collapse(true); | ||
| 1297 | selection.removeAllRanges(); | ||
| 1298 | selection.addRange(range); | ||
| 1299 | composerEditor.dispatchEvent( | ||
| 1300 | new InputEvent("input", { | ||
| 1301 | data: key, | ||
| 1302 | inputType: "insertText", | ||
| 1303 | bubbles: true, | ||
| 1304 | }), | ||
| 1305 | ); | ||
| 1306 | await waitForLayout(); | ||
| 1307 | } | ||
| 1308 | |||
| 1309 | async function pressComposerUndo(): Promise<void> { | ||
| 1310 | const composerEditor = await waitForComposerEditor(); | ||
| 1311 | const useMetaForMod = isMacPlatform(navigator.platform); | ||
| 1312 | composerEditor.focus(); | ||
| 1313 | composerEditor.dispatchEvent( | ||
| 1314 | new KeyboardEvent("keydown", { | ||
| 1315 | key: "z", | ||
| 1316 | metaKey: useMetaForMod, | ||
| 1317 | ctrlKey: !useMetaForMod, | ||
| 1318 | bubbles: true, | ||
| 1319 | cancelable: true, | ||
| 1320 | }), | ||
| 1321 | ); | ||
| 1322 | await waitForLayout(); | ||
| 1323 | } | ||
| 1324 | |||
| 1325 | async function waitForComposerText(expectedText: string): Promise<void> { | ||
| 1326 | await vi.waitFor( | ||
| 1327 | () => { | ||
| 1328 | expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe( | ||
| 1329 | expectedText, | ||
| 1330 | ); | ||
| 1331 | }, | ||
| 1332 | { timeout: 8_000, interval: 16 }, | ||
| 1333 | ); | ||
| 1334 | } | ||
| 1335 | |||
| 1336 | async function setComposerSelectionByTextOffsets(options: { | ||
| 1337 | start: number; | ||
| 1338 | end: number; | ||
| 1339 | direction?: "forward" | "backward"; | ||
| 1340 | }): Promise<void> { | ||
| 1341 | const composerEditor = await waitForComposerEditor(); | ||
| 1342 | composerEditor.focus(); | ||
| 1343 | const resolvePoint = (targetOffset: number) => { | ||
| 1344 | const traversedRef = { value: 0 }; | ||
| 1345 | |||
| 1346 | const visitNode = (node: Node): { node: Node; offset: number } | null => { | ||
| 1347 | if (node.nodeType === Node.TEXT_NODE) { | ||
| 1348 | const textLength = node.textContent?.length ?? 0; | ||
| 1349 | if (targetOffset <= traversedRef.value + textLength) { | ||
| 1350 | return { | ||
| 1351 | node, | ||
| 1352 | offset: Math.max(0, Math.min(targetOffset - traversedRef.value, textLength)), | ||
| 1353 | }; | ||
| 1354 | } | ||
| 1355 | traversedRef.value += textLength; | ||
| 1356 | return null; | ||
| 1357 | } | ||
| 1358 | |||
| 1359 | if (node instanceof HTMLBRElement) { | ||
| 1360 | const parent = node.parentNode; | ||
| 1361 | if (!parent) { | ||
| 1362 | return null; | ||
| 1363 | } | ||
| 1364 | const siblingIndex = Array.prototype.indexOf.call(parent.childNodes, node); | ||
| 1365 | if (targetOffset <= traversedRef.value) { | ||
| 1366 | return { node: parent, offset: siblingIndex }; | ||
| 1367 | } | ||
| 1368 | if (targetOffset <= traversedRef.value + 1) { | ||
| 1369 | return { node: parent, offset: siblingIndex + 1 }; | ||
| 1370 | } | ||
| 1371 | traversedRef.value += 1; | ||
| 1372 | return null; | ||
| 1373 | } | ||
| 1374 | |||
| 1375 | if (node instanceof Element || node instanceof DocumentFragment) { | ||
| 1376 | for (const child of node.childNodes) { | ||
| 1377 | const point = visitNode(child); | ||
| 1378 | if (point) { | ||
| 1379 | return point; | ||
| 1380 | } | ||
| 1381 | } | ||
| 1382 | } | ||
| 1383 | |||
| 1384 | return null; | ||
| 1385 | }; | ||
| 1386 | |||
| 1387 | return ( | ||
| 1388 | visitNode(composerEditor) ?? { | ||
| 1389 | node: composerEditor, | ||
| 1390 | offset: composerEditor.childNodes.length, | ||
| 1391 | } | ||
| 1392 | ); | ||
| 1393 | }; | ||
| 1394 | |||
| 1395 | const startPoint = resolvePoint(options.start); | ||
| 1396 | const endPoint = resolvePoint(options.end); | ||
| 1397 | const selection = window.getSelection(); | ||
| 1398 | if (!selection) { | ||
| 1399 | throw new Error("Unable to resolve window selection."); | ||
| 1400 | } | ||
| 1401 | selection.removeAllRanges(); | ||
| 1402 | |||
| 1403 | if (options.direction === "backward" && "setBaseAndExtent" in selection) { | ||
| 1404 | selection.setBaseAndExtent(endPoint.node, endPoint.offset, startPoint.node, startPoint.offset); | ||
| 1405 | await waitForLayout(); | ||
| 1406 | return; | ||
| 1407 | } | ||
| 1408 | |||
| 1409 | const range = document.createRange(); | ||
| 1410 | range.setStart(startPoint.node, startPoint.offset); | ||
| 1411 | range.setEnd(endPoint.node, endPoint.offset); | ||
| 1412 | selection.addRange(range); | ||
| 1413 | await waitForLayout(); | ||
| 1414 | } | ||
| 1415 | |||
| 1416 | async function selectAllComposerContent(): Promise<void> { | ||
| 1417 | const composerEditor = await waitForComposerEditor(); | ||
| 1418 | composerEditor.focus(); | ||
| 1419 | const selection = window.getSelection(); | ||
| 1420 | if (!selection) { | ||
| 1421 | throw new Error("Unable to resolve window selection."); | ||
| 1422 | } | ||
| 1423 | selection.removeAllRanges(); | ||
| 1424 | const range = document.createRange(); | ||
| 1425 | range.selectNodeContents(composerEditor); | ||
| 1426 | selection.addRange(range); | ||
| 1427 | await waitForLayout(); | ||
| 1428 | } | ||
| 1429 | |||
| 1430 | async function waitForComposerMenuItem(itemId: string): Promise<HTMLElement> { | ||
| 1431 | return waitForElement( | ||
| 1432 | () => document.querySelector<HTMLElement>(`[data-composer-item-id="${itemId}"]`), | ||
| 1433 | `Unable to find composer menu item "${itemId}".`, | ||
| 1434 | ); | ||
| 1435 | } | ||
| 1436 | async function waitForSendButton(): Promise<HTMLButtonElement> { | ||
| 1437 | return waitForElement( | ||
| 1438 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'), | ||
| 1439 | "Unable to find send button.", | ||
| 1440 | ); | ||
| 1441 | } | ||
| 1442 | |||
| 1443 | function findComposerProviderModelPicker(): HTMLButtonElement | null { | ||
| 1444 | return document.querySelector<HTMLButtonElement>('[data-chat-provider-model-picker="true"]'); | ||
| 1445 | } | ||
| 1446 | |||
| 1447 | function findButtonByText(text: string): HTMLButtonElement | null { | ||
| 1448 | return (Array.from(document.querySelectorAll("button")).find( | ||
| 1449 | (button) => button.textContent?.trim() === text, | ||
| 1450 | ) ?? null) as HTMLButtonElement | null; | ||
| 1451 | } | ||
| 1452 | |||
| 1453 | async function waitForButtonByText(text: string): Promise<HTMLButtonElement> { | ||
| 1454 | return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`); | ||
| 1455 | } | ||
| 1456 | |||
| 1457 | function findButtonContainingText(text: string): HTMLElement | null { | ||
| 1458 | return ( | ||
| 1459 | Array.from(document.querySelectorAll<HTMLElement>('button, [role="button"]')).find((button) => | ||
| 1460 | button.textContent?.includes(text), | ||
| 1461 | ) ?? null | ||
| 1462 | ); | ||
| 1463 | } | ||
| 1464 | |||
| 1465 | async function waitForButtonContainingText(text: string): Promise<HTMLElement> { | ||
| 1466 | return waitForElement( | ||
| 1467 | () => findButtonContainingText(text), | ||
| 1468 | `Unable to find button containing "${text}".`, | ||
| 1469 | ); | ||
| 1470 | } | ||
| 1471 | |||
| 1472 | async function waitForSelectItemContainingText(text: string): Promise<HTMLElement> { | ||
| 1473 | return waitForElement( | ||
| 1474 | () => | ||
| 1475 | Array.from(document.querySelectorAll<HTMLElement>('[data-slot="select-item"]')).find((item) => | ||
| 1476 | item.textContent?.includes(text), | ||
| 1477 | ) ?? null, | ||
| 1478 | `Unable to find select item containing "${text}".`, | ||
| 1479 | ); | ||
| 1480 | } | ||
| 1481 | |||
| 1482 | async function expectComposerActionsContained(): Promise<void> { | ||
| 1483 | const footer = await waitForElement( | ||
| 1484 | () => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'), | ||
| 1485 | "Unable to find composer footer.", | ||
| 1486 | ); | ||
| 1487 | const actions = await waitForElement( | ||
| 1488 | () => document.querySelector<HTMLElement>('[data-chat-composer-actions="right"]'), | ||
| 1489 | "Unable to find composer actions container.", | ||
| 1490 | ); | ||
| 1491 | |||
| 1492 | await vi.waitFor( | ||
| 1493 | () => { | ||
| 1494 | const footerRect = footer.getBoundingClientRect(); | ||
| 1495 | const actionButtons = Array.from(actions.querySelectorAll<HTMLButtonElement>("button")); | ||
| 1496 | expect(actionButtons.length).toBeGreaterThanOrEqual(1); | ||
| 1497 | |||
| 1498 | const buttonRects = actionButtons.map((button) => button.getBoundingClientRect()); | ||
| 1499 | const firstTop = buttonRects[0]?.top ?? 0; | ||
| 1500 | |||
| 1501 | for (const rect of buttonRects) { | ||
| 1502 | expect(rect.right).toBeLessThanOrEqual(footerRect.right + 0.5); | ||
| 1503 | expect(rect.bottom).toBeLessThanOrEqual(footerRect.bottom + 0.5); | ||
| 1504 | expect(Math.abs(rect.top - firstTop)).toBeLessThanOrEqual(1.5); | ||
| 1505 | } | ||
| 1506 | }, | ||
| 1507 | { timeout: 8_000, interval: 16 }, | ||
| 1508 | ); | ||
| 1509 | } | ||
| 1510 | |||
| 1511 | async function waitForInteractionModeButton( | ||
| 1512 | expectedLabel: "Build" | "Plan", | ||
| 1513 | ): Promise<HTMLButtonElement> { | ||
| 1514 | return waitForElement( | ||
| 1515 | () => | ||
| 1516 | Array.from(document.querySelectorAll("button")).find( | ||
| 1517 | (button) => button.textContent?.trim() === expectedLabel, | ||
| 1518 | ) as HTMLButtonElement | null, | ||
| 1519 | `Unable to find ${expectedLabel} interaction mode button.`, | ||
| 1520 | ); | ||
| 1521 | } | ||
| 1522 | |||
| 1523 | async function waitForServerConfigToApply(): Promise<void> { | ||
| 1524 | await vi.waitFor( | ||
| 1525 | () => { | ||
| 1526 | expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( | ||
| 1527 | true, | ||
| 1528 | ); | ||
| 1529 | }, | ||
| 1530 | { timeout: 8_000, interval: 16 }, | ||
| 1531 | ); | ||
| 1532 | await waitForLayout(); | ||
| 1533 | } | ||
| 1534 | |||
| 1535 | function dispatchChatNewShortcut(): void { | ||
| 1536 | const useMetaForMod = isMacPlatform(navigator.platform); | ||
| 1537 | window.dispatchEvent( | ||
| 1538 | new KeyboardEvent("keydown", { | ||
| 1539 | key: "o", | ||
| 1540 | shiftKey: true, | ||
| 1541 | metaKey: useMetaForMod, | ||
| 1542 | ctrlKey: !useMetaForMod, | ||
| 1543 | bubbles: true, | ||
| 1544 | cancelable: true, | ||
| 1545 | }), | ||
| 1546 | ); | ||
| 1547 | } | ||
| 1548 | |||
| 1549 | function dispatchConfiguredDiffToggleShortcut(): void { | ||
| 1550 | window.dispatchEvent( | ||
| 1551 | new KeyboardEvent("keydown", { | ||
| 1552 | key: "g", | ||
| 1553 | shiftKey: true, | ||
| 1554 | altKey: true, | ||
| 1555 | bubbles: true, | ||
| 1556 | cancelable: true, | ||
| 1557 | }), | ||
| 1558 | ); | ||
| 1559 | } | ||
| 1560 | |||
| 1561 | function releaseModShortcut(key?: string): void { | ||
| 1562 | window.dispatchEvent( | ||
| 1563 | new KeyboardEvent("keyup", { | ||
| 1564 | key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"), | ||
| 1565 | metaKey: false, | ||
| 1566 | ctrlKey: false, | ||
| 1567 | bubbles: true, | ||
| 1568 | cancelable: true, | ||
| 1569 | }), | ||
| 1570 | ); | ||
| 1571 | } | ||
| 1572 | |||
| 1573 | async function triggerChatNewShortcutUntilPath( | ||
| 1574 | router: ReturnType<typeof getRouter>, | ||
| 1575 | predicate: (pathname: string) => boolean, | ||
| 1576 | errorMessage: string, | ||
| 1577 | ): Promise<string> { | ||
| 1578 | let pathname = router.state.location.pathname; | ||
| 1579 | const deadline = Date.now() + 8_000; | ||
| 1580 | while (Date.now() < deadline) { | ||
| 1581 | dispatchChatNewShortcut(); | ||
| 1582 | await waitForLayout(); | ||
| 1583 | pathname = router.state.location.pathname; | ||
| 1584 | if (predicate(pathname)) { | ||
| 1585 | return pathname; | ||
| 1586 | } | ||
| 1587 | } | ||
| 1588 | throw new Error(`${errorMessage} Last path: ${pathname}`); | ||
| 1589 | } | ||
| 1590 | |||
| 1591 | async function openCommandPaletteFromTrigger(): Promise<void> { | ||
| 1592 | const trigger = page.getByTestId("command-palette-trigger"); | ||
| 1593 | await expect.element(trigger).toBeInTheDocument(); | ||
| 1594 | await trigger.click(); | ||
| 1595 | await waitForElement( | ||
| 1596 | () => document.querySelector('[data-testid="command-palette"]'), | ||
| 1597 | "Command palette should have opened from the sidebar trigger.", | ||
| 1598 | ); | ||
| 1599 | } | ||
| 1600 | |||
| 1601 | async function waitForNewThreadShortcutLabel(): Promise<void> { | ||
| 1602 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 1603 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 1604 | await newThreadButton.hover(); | ||
| 1605 | const shortcutLabel = isMacPlatform(navigator.platform) | ||
| 1606 | ? "New thread (⇧⌘O)" | ||
| 1607 | : "New thread (Ctrl+Shift+O)"; | ||
| 1608 | await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument(); | ||
| 1609 | } | ||
| 1610 | |||
| 1611 | async function waitForCommandPaletteShortcutLabel(): Promise<void> { | ||
| 1612 | await waitForElement( | ||
| 1613 | () => document.querySelector('[data-testid="command-palette-trigger"] kbd'), | ||
| 1614 | "Command palette shortcut label did not render.", | ||
| 1615 | ); | ||
| 1616 | } | ||
| 1617 | |||
| 1618 | async function waitForCommandPaletteInput(placeholder: string): Promise<HTMLInputElement> { | ||
| 1619 | return waitForElement( | ||
| 1620 | () => document.querySelector(`input[placeholder="${placeholder}"]`) as HTMLInputElement | null, | ||
| 1621 | `Command palette input with placeholder "${placeholder}" did not render.`, | ||
| 1622 | ); | ||
| 1623 | } | ||
| 1624 | |||
| 1625 | function getCommandPaletteLegendEntries(): string[] { | ||
| 1626 | const footer = document.querySelector('[data-slot="command-footer"]'); | ||
| 1627 | if (!footer) { | ||
| 1628 | return []; | ||
| 1629 | } | ||
| 1630 | |||
| 1631 | return Array.from(footer.querySelectorAll('[data-slot="kbd-group"]')) | ||
| 1632 | .map((group) => | ||
| 1633 | Array.from(group.children) | ||
| 1634 | .map((child) => child.textContent?.trim() ?? "") | ||
| 1635 | .filter((value) => value.length > 0) | ||
| 1636 | .join(" "), | ||
| 1637 | ) | ||
| 1638 | .filter((value) => value.length > 0); | ||
| 1639 | } | ||
| 1640 | |||
| 1641 | async function dispatchInputKey( | ||
| 1642 | input: HTMLInputElement, | ||
| 1643 | init: Pick<KeyboardEventInit, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">, | ||
| 1644 | ): Promise<void> { | ||
| 1645 | input.focus(); | ||
| 1646 | input.dispatchEvent( | ||
| 1647 | new KeyboardEvent("keydown", { | ||
| 1648 | bubbles: true, | ||
| 1649 | cancelable: true, | ||
| 1650 | ...init, | ||
| 1651 | }), | ||
| 1652 | ); | ||
| 1653 | await waitForLayout(); | ||
| 1654 | } | ||
| 1655 | |||
| 1656 | async function mountChatView(options: { | ||
| 1657 | viewport: ViewportSpec; | ||
| 1658 | snapshot: OrchestrationReadModel; | ||
| 1659 | configureFixture?: (fixture: TestFixture) => void; | ||
| 1660 | resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; | ||
| 1661 | initialPath?: string; | ||
| 1662 | }): Promise<MountedChatView> { | ||
| 1663 | fixture = buildFixture(options.snapshot); | ||
| 1664 | options.configureFixture?.(fixture); | ||
| 1665 | customWsRpcResolver = options.resolveRpc ?? null; | ||
| 1666 | await setViewport(options.viewport); | ||
| 1667 | await waitForProductionStyles(); | ||
| 1668 | |||
| 1669 | const host = document.createElement("div"); | ||
| 1670 | host.style.position = "fixed"; | ||
| 1671 | host.style.top = "0"; | ||
| 1672 | host.style.left = "0"; | ||
| 1673 | host.style.width = "100vw"; | ||
| 1674 | host.style.height = "100vh"; | ||
| 1675 | host.style.display = "grid"; | ||
| 1676 | host.style.overflow = "hidden"; | ||
| 1677 | document.body.append(host); | ||
| 1678 | |||
| 1679 | const router = getRouter( | ||
| 1680 | createMemoryHistory({ | ||
| 1681 | initialEntries: [options.initialPath ?? `/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], | ||
| 1682 | }), | ||
| 1683 | ); | ||
| 1684 | |||
| 1685 | const screen = await render( | ||
| 1686 | <AppAtomRegistryProvider> | ||
| 1687 | <RouterProvider router={router} /> | ||
| 1688 | </AppAtomRegistryProvider>, | ||
| 1689 | { | ||
| 1690 | container: host, | ||
| 1691 | }, | ||
| 1692 | ); | ||
| 1693 | |||
| 1694 | await waitForWsClient(); | ||
| 1695 | await waitForAppBootstrap(); | ||
| 1696 | await waitForLayout(); | ||
| 1697 | |||
| 1698 | const cleanup = async () => { | ||
| 1699 | customWsRpcResolver = null; | ||
| 1700 | await screen.unmount(); | ||
| 1701 | host.remove(); | ||
| 1702 | await waitForLayout(); | ||
| 1703 | }; | ||
| 1704 | |||
| 1705 | return { | ||
| 1706 | [Symbol.asyncDispose]: cleanup, | ||
| 1707 | cleanup, | ||
| 1708 | setViewport: async (viewport: ViewportSpec) => { | ||
| 1709 | await setViewport(viewport); | ||
| 1710 | await waitForProductionStyles(); | ||
| 1711 | }, | ||
| 1712 | setContainerSize: async (viewport) => { | ||
| 1713 | host.style.width = `${viewport.width}px`; | ||
| 1714 | host.style.height = `${viewport.height}px`; | ||
| 1715 | await waitForLayout(); | ||
| 1716 | }, | ||
| 1717 | router, | ||
| 1718 | }; | ||
| 1719 | } | ||
| 1720 | |||
| 1721 | describe("ChatView timeline estimator parity (full app)", () => { | ||
| 1722 | beforeAll(async () => { | ||
| 1723 | fixture = buildFixture( | ||
| 1724 | createSnapshotForTargetUser({ | ||
| 1725 | targetMessageId: "msg-user-bootstrap" as MessageId, | ||
| 1726 | targetText: "bootstrap", | ||
| 1727 | }), | ||
| 1728 | ); | ||
| 1729 | await worker.start({ | ||
| 1730 | onUnhandledRequest: "bypass", | ||
| 1731 | quiet: true, | ||
| 1732 | serviceWorker: { | ||
| 1733 | url: "/mockServiceWorker.js", | ||
| 1734 | }, | ||
| 1735 | }); | ||
| 1736 | }); | ||
| 1737 | |||
| 1738 | afterAll(async () => { | ||
| 1739 | await rpcHarness.disconnect(); | ||
| 1740 | await worker.stop(); | ||
| 1741 | }); | ||
| 1742 | |||
| 1743 | beforeEach(async () => { | ||
| 1744 | await rpcHarness.reset({ | ||
| 1745 | resolveUnary: resolveWsRpc, | ||
| 1746 | getInitialStreamValues: (request) => { | ||
| 1747 | if (request._tag === WS_METHODS.subscribeServerLifecycle) { | ||
| 1748 | return [ | ||
| 1749 | { | ||
| 1750 | version: 1, | ||
| 1751 | sequence: 1, | ||
| 1752 | type: "welcome", | ||
| 1753 | payload: fixture.welcome, | ||
| 1754 | }, | ||
| 1755 | ]; | ||
| 1756 | } | ||
| 1757 | if (request._tag === WS_METHODS.subscribeServerConfig) { | ||
| 1758 | return [ | ||
| 1759 | { | ||
| 1760 | version: 1, | ||
| 1761 | type: "snapshot", | ||
| 1762 | config: encodeServerConfig(fixture.serverConfig), | ||
| 1763 | }, | ||
| 1764 | ]; | ||
| 1765 | } | ||
| 1766 | if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { | ||
| 1767 | return [ | ||
| 1768 | { | ||
| 1769 | kind: "snapshot", | ||
| 1770 | snapshot: toShellSnapshot(fixture.snapshot), | ||
| 1771 | }, | ||
| 1772 | ]; | ||
| 1773 | } | ||
| 1774 | if (request._tag === ORCHESTRATION_WS_METHODS.subscribeThread) { | ||
| 1775 | const thread = fixture.snapshot.threads.find((entry) => entry.id === request.threadId); | ||
| 1776 | return thread | ||
| 1777 | ? [ | ||
| 1778 | { | ||
| 1779 | kind: "snapshot", | ||
| 1780 | snapshot: { | ||
| 1781 | snapshotSequence: fixture.snapshot.snapshotSequence, | ||
| 1782 | thread, | ||
| 1783 | }, | ||
| 1784 | }, | ||
| 1785 | ] | ||
| 1786 | : []; | ||
| 1787 | } | ||
| 1788 | if (request._tag === WS_METHODS.subscribeTerminalMetadata) { | ||
| 1789 | return fixture.terminalMetadataEvents; | ||
| 1790 | } | ||
| 1791 | return []; | ||
| 1792 | }, | ||
| 1793 | }); | ||
| 1794 | await __resetLocalApiForTests(); | ||
| 1795 | await setViewport(DEFAULT_VIEWPORT); | ||
| 1796 | localStorage.clear(); | ||
| 1797 | document.body.innerHTML = ""; | ||
| 1798 | wsRequests.length = 0; | ||
| 1799 | customWsRpcResolver = null; | ||
| 1800 | __resetEnvironmentApiOverridesForTests(); | ||
| 1801 | resetSavedEnvironmentRegistryStoreForTests(); | ||
| 1802 | resetSavedEnvironmentRuntimeStoreForTests(); | ||
| 1803 | Reflect.deleteProperty(window, "desktopBridge"); | ||
| 1804 | useComposerDraftStore.setState({ | ||
| 1805 | draftsByThreadKey: {}, | ||
| 1806 | draftThreadsByThreadKey: {}, | ||
| 1807 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | ||
| 1808 | stickyModelSelectionByProvider: {}, | ||
| 1809 | stickyActiveProvider: null, | ||
| 1810 | }); | ||
| 1811 | useCommandPaletteStore.setState({ | ||
| 1812 | open: false, | ||
| 1813 | openIntent: null, | ||
| 1814 | }); | ||
| 1815 | useStore.setState({ | ||
| 1816 | activeEnvironmentId: null, | ||
| 1817 | environmentStateById: {}, | ||
| 1818 | }); | ||
| 1819 | useUiStateStore.setState({ | ||
| 1820 | projectExpandedById: {}, | ||
| 1821 | projectOrder: [], | ||
| 1822 | threadLastVisitedAtById: {}, | ||
| 1823 | }); | ||
| 1824 | useTerminalUiStateStore.persist.clearStorage(); | ||
| 1825 | useTerminalUiStateStore.setState({ | ||
| 1826 | terminalUiStateByThreadKey: {}, | ||
| 1827 | }); | ||
| 1828 | useRightPanelStore.persist.clearStorage(); | ||
| 1829 | useRightPanelStore.setState({ byThreadKey: {} }); | ||
| 1830 | }); | ||
| 1831 | |||
| 1832 | afterEach(() => { | ||
| 1833 | customWsRpcResolver = null; | ||
| 1834 | document.body.innerHTML = ""; | ||
| 1835 | }); | ||
| 1836 | |||
| 1837 | it("renders locked single-environment mobile run context as a static workspace label", async () => { | ||
| 1838 | const mounted = await mountChatView({ | ||
| 1839 | viewport: COMPACT_FOOTER_VIEWPORT, | ||
| 1840 | snapshot: createSnapshotForTargetUser({ | ||
| 1841 | targetMessageId: "msg-user-mobile-locked-workspace" as MessageId, | ||
| 1842 | targetText: "locked mobile workspace", | ||
| 1843 | }), | ||
| 1844 | }); | ||
| 1845 | |||
| 1846 | try { | ||
| 1847 | await waitForElement( | ||
| 1848 | () => | ||
| 1849 | Array.from(document.querySelectorAll<HTMLElement>("span")).find( | ||
| 1850 | (element) => element.textContent?.trim() === "Local checkout", | ||
| 1851 | ) ?? null, | ||
| 1852 | "Unable to find static mobile workspace label.", | ||
| 1853 | ); | ||
| 1854 | |||
| 1855 | expect(findButtonByText("Local checkout")).toBeNull(); | ||
| 1856 | } finally { | ||
| 1857 | await mounted.cleanup(); | ||
| 1858 | } | ||
| 1859 | }); | ||
| 1860 | |||
| 1861 | it("keeps dismiss-only composer banners aligned on mobile", async () => { | ||
| 1862 | const mounted = await mountChatView({ | ||
| 1863 | viewport: COMPACT_FOOTER_VIEWPORT, | ||
| 1864 | snapshot: createSnapshotForTargetUser({ | ||
| 1865 | targetMessageId: "msg-user-mobile-version-banner" as MessageId, | ||
| 1866 | targetText: "mobile version banner", | ||
| 1867 | }), | ||
| 1868 | configureFixture: (nextFixture) => { | ||
| 1869 | nextFixture.serverConfig = { | ||
| 1870 | ...nextFixture.serverConfig, | ||
| 1871 | environment: { | ||
| 1872 | ...nextFixture.serverConfig.environment, | ||
| 1873 | serverVersion: "9.9.9", | ||
| 1874 | }, | ||
| 1875 | }; | ||
| 1876 | }, | ||
| 1877 | }); | ||
| 1878 | |||
| 1879 | try { | ||
| 1880 | const banner = await waitForElement( | ||
| 1881 | () => | ||
| 1882 | Array.from(document.querySelectorAll<HTMLElement>('[data-slot="alert"]')).find( | ||
| 1883 | (element) => element.textContent?.includes("Client and server versions differ"), | ||
| 1884 | ) ?? null, | ||
| 1885 | "Unable to find version mismatch banner.", | ||
| 1886 | ); | ||
| 1887 | const title = banner.querySelector<HTMLElement>('[data-slot="alert-title"]'); | ||
| 1888 | const description = banner.querySelector<HTMLElement>('[data-slot="alert-description"]'); | ||
| 1889 | const dismissButton = banner.querySelector<HTMLButtonElement>( | ||
| 1890 | 'button[aria-label="Dismiss version mismatch warning"]', | ||
| 1891 | ); | ||
| 1892 | |||
| 1893 | expect(title).toBeTruthy(); | ||
| 1894 | expect(description).toBeTruthy(); | ||
| 1895 | expect(dismissButton).toBeTruthy(); | ||
| 1896 | expect(dismissButton!.getBoundingClientRect().top).toBeLessThan( | ||
| 1897 | description!.getBoundingClientRect().top, | ||
| 1898 | ); | ||
| 1899 | } finally { | ||
| 1900 | await mounted.cleanup(); | ||
| 1901 | } | ||
| 1902 | }); | ||
| 1903 | |||
| 1904 | it("re-expands the bootstrap project using its logical key", async () => { | ||
| 1905 | useUiStateStore.setState({ | ||
| 1906 | projectExpandedById: { | ||
| 1907 | [PROJECT_LOGICAL_KEY]: false, | ||
| 1908 | }, | ||
| 1909 | projectOrder: [PROJECT_LOGICAL_KEY], | ||
| 1910 | threadLastVisitedAtById: {}, | ||
| 1911 | }); | ||
| 1912 | |||
| 1913 | const mounted = await mountChatView({ | ||
| 1914 | viewport: DEFAULT_VIEWPORT, | ||
| 1915 | snapshot: createSnapshotForTargetUser({ | ||
| 1916 | targetMessageId: "msg-user-bootstrap-project-expand" as MessageId, | ||
| 1917 | targetText: "bootstrap project expand", | ||
| 1918 | }), | ||
| 1919 | }); | ||
| 1920 | |||
| 1921 | try { | ||
| 1922 | await vi.waitFor( | ||
| 1923 | () => { | ||
| 1924 | expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); | ||
| 1925 | }, | ||
| 1926 | { timeout: 8_000, interval: 16 }, | ||
| 1927 | ); | ||
| 1928 | } finally { | ||
| 1929 | await mounted.cleanup(); | ||
| 1930 | } | ||
| 1931 | }); | ||
| 1932 | |||
| 1933 | it("shows an explicit empty state for projects without threads in the sidebar", async () => { | ||
| 1934 | const mounted = await mountChatView({ | ||
| 1935 | viewport: DEFAULT_VIEWPORT, | ||
| 1936 | snapshot: createDraftOnlySnapshot(), | ||
| 1937 | }); | ||
| 1938 | |||
| 1939 | try { | ||
| 1940 | await expect.element(page.getByText("No threads yet")).toBeInTheDocument(); | ||
| 1941 | } finally { | ||
| 1942 | await mounted.cleanup(); | ||
| 1943 | } | ||
| 1944 | }); | ||
| 1945 | |||
| 1946 | it("opens the project cwd for draft threads without a worktree path", async () => { | ||
| 1947 | setDraftThreadWithoutWorktree(); | ||
| 1948 | |||
| 1949 | const mounted = await mountChatView({ | ||
| 1950 | viewport: DEFAULT_VIEWPORT, | ||
| 1951 | snapshot: createDraftOnlySnapshot(), | ||
| 1952 | configureFixture: (nextFixture) => { | ||
| 1953 | nextFixture.serverConfig = { | ||
| 1954 | ...nextFixture.serverConfig, | ||
| 1955 | availableEditors: ["vscode"], | ||
| 1956 | }; | ||
| 1957 | }, | ||
| 1958 | }); | ||
| 1959 | |||
| 1960 | try { | ||
| 1961 | await waitForServerConfigToApply(); | ||
| 1962 | const openButton = await waitForElement( | ||
| 1963 | () => | ||
| 1964 | Array.from(document.querySelectorAll("button")).find( | ||
| 1965 | (button) => button.textContent?.trim() === "Open", | ||
| 1966 | ) as HTMLButtonElement | null, | ||
| 1967 | "Unable to find Open button.", | ||
| 1968 | ); | ||
| 1969 | await vi.waitFor(() => { | ||
| 1970 | expect(openButton.disabled).toBe(false); | ||
| 1971 | }); | ||
| 1972 | openButton.click(); | ||
| 1973 | |||
| 1974 | await vi.waitFor( | ||
| 1975 | () => { | ||
| 1976 | const openRequest = wsRequests.find( | ||
| 1977 | (request) => request._tag === WS_METHODS.shellOpenInEditor, | ||
| 1978 | ); | ||
| 1979 | expect(openRequest).toMatchObject({ | ||
| 1980 | _tag: WS_METHODS.shellOpenInEditor, | ||
| 1981 | cwd: "/repo/project", | ||
| 1982 | editor: "vscode", | ||
| 1983 | }); | ||
| 1984 | }, | ||
| 1985 | { timeout: 8_000, interval: 16 }, | ||
| 1986 | ); | ||
| 1987 | } finally { | ||
| 1988 | await mounted.cleanup(); | ||
| 1989 | } | ||
| 1990 | }); | ||
| 1991 | |||
| 1992 | it("does not leak a server worktree path into drawer runtime env when launch context clears it", async () => { | ||
| 1993 | const snapshot = createSnapshotForTargetUser({ | ||
| 1994 | targetMessageId: "msg-user-launch-context-target" as MessageId, | ||
| 1995 | targetText: "launch context worktree override", | ||
| 1996 | }); | ||
| 1997 | const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); | ||
| 1998 | if (targetThread) { | ||
| 1999 | Object.assign(targetThread, { | ||
| 2000 | branch: "feature/branch", | ||
| 2001 | worktreePath: "/repo/worktrees/feature-branch", | ||
| 2002 | }); | ||
| 2003 | } | ||
| 2004 | |||
| 2005 | useTerminalUiStateStore.setState({ | ||
| 2006 | terminalUiStateByThreadKey: { | ||
| 2007 | [THREAD_KEY]: { | ||
| 2008 | terminalOpen: true, | ||
| 2009 | terminalHeight: 280, | ||
| 2010 | terminalIds: ["default"], | ||
| 2011 | activeTerminalId: "default", | ||
| 2012 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | ||
| 2013 | activeTerminalGroupId: "group-default", | ||
| 2014 | }, | ||
| 2015 | }, | ||
| 2016 | }); | ||
| 2017 | |||
| 2018 | const mounted = await mountChatView({ | ||
| 2019 | viewport: DEFAULT_VIEWPORT, | ||
| 2020 | snapshot, | ||
| 2021 | configureFixture: (nextFixture) => { | ||
| 2022 | nextFixture.terminalMetadataEvents = [ | ||
| 2023 | { | ||
| 2024 | type: "upsert", | ||
| 2025 | terminal: { | ||
| 2026 | threadId: THREAD_ID, | ||
| 2027 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 2028 | cwd: "/repo/project", | ||
| 2029 | worktreePath: null, | ||
| 2030 | status: "running", | ||
| 2031 | pid: 123, | ||
| 2032 | exitCode: null, | ||
| 2033 | exitSignal: null, | ||
| 2034 | hasRunningSubprocess: false, | ||
| 2035 | label: "Terminal 1", | ||
| 2036 | updatedAt: isoAt(0), | ||
| 2037 | }, | ||
| 2038 | }, | ||
| 2039 | ]; | ||
| 2040 | }, | ||
| 2041 | }); | ||
| 2042 | |||
| 2043 | try { | ||
| 2044 | await vi.waitFor( | ||
| 2045 | () => { | ||
| 2046 | const attachRequest = wsRequests | ||
| 2047 | .toReversed() | ||
| 2048 | .find((request) => request._tag === WS_METHODS.terminalAttach) as | ||
| 2049 | | { | ||
| 2050 | _tag: string; | ||
| 2051 | cwd?: string; | ||
| 2052 | worktreePath?: string | null; | ||
| 2053 | env?: Record<string, string>; | ||
| 2054 | } | ||
| 2055 | | undefined; | ||
| 2056 | expect(attachRequest).toMatchObject({ | ||
| 2057 | _tag: WS_METHODS.terminalAttach, | ||
| 2058 | cwd: "/repo/project", | ||
| 2059 | worktreePath: null, | ||
| 2060 | env: { | ||
| 2061 | T3CODE_PROJECT_ROOT: "/repo/project", | ||
| 2062 | }, | ||
| 2063 | }); | ||
| 2064 | expect(attachRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); | ||
| 2065 | }, | ||
| 2066 | { timeout: 8_000, interval: 16 }, | ||
| 2067 | ); | ||
| 2068 | } finally { | ||
| 2069 | await mounted.cleanup(); | ||
| 2070 | } | ||
| 2071 | }); | ||
| 2072 | |||
| 2073 | it("attaches the default terminal when opening an empty terminal drawer", async () => { | ||
| 2074 | const mounted = await mountChatView({ | ||
| 2075 | viewport: DEFAULT_VIEWPORT, | ||
| 2076 | snapshot: createSnapshotForTargetUser({ | ||
| 2077 | targetMessageId: "msg-user-open-empty-terminal-drawer" as MessageId, | ||
| 2078 | targetText: "open empty terminal drawer", | ||
| 2079 | }), | ||
| 2080 | }); | ||
| 2081 | |||
| 2082 | try { | ||
| 2083 | const terminalToggle = await waitForElement( | ||
| 2084 | () => | ||
| 2085 | document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), | ||
| 2086 | "Unable to find terminal drawer toggle.", | ||
| 2087 | ); | ||
| 2088 | terminalToggle.click(); | ||
| 2089 | |||
| 2090 | await vi.waitFor( | ||
| 2091 | () => { | ||
| 2092 | const attachRequest = wsRequests.find( | ||
| 2093 | (request) => request._tag === WS_METHODS.terminalAttach, | ||
| 2094 | ); | ||
| 2095 | expect(attachRequest).toMatchObject({ | ||
| 2096 | _tag: WS_METHODS.terminalAttach, | ||
| 2097 | threadId: THREAD_ID, | ||
| 2098 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 2099 | cwd: "/repo/project", | ||
| 2100 | }); | ||
| 2101 | expect( | ||
| 2102 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | ||
| 2103 | .isOpen, | ||
| 2104 | ).toBe(false); | ||
| 2105 | }, | ||
| 2106 | { timeout: 8_000, interval: 16 }, | ||
| 2107 | ); | ||
| 2108 | } finally { | ||
| 2109 | await mounted.cleanup(); | ||
| 2110 | } | ||
| 2111 | }); | ||
| 2112 | |||
| 2113 | it("keeps the compact chat header on one row", async () => { | ||
| 2114 | const mounted = await mountChatView({ | ||
| 2115 | viewport: COMPACT_FOOTER_VIEWPORT, | ||
| 2116 | snapshot: createSnapshotForTargetUser({ | ||
| 2117 | targetMessageId: "msg-user-compact-header" as MessageId, | ||
| 2118 | targetText: "keep the compact header aligned", | ||
| 2119 | }), | ||
| 2120 | }); | ||
| 2121 | |||
| 2122 | try { | ||
| 2123 | const chatHeader = await waitForElement( | ||
| 2124 | () => document.querySelector<HTMLElement>("[data-chat-header]"), | ||
| 2125 | "Unable to find chat header.", | ||
| 2126 | ); | ||
| 2127 | const threadTitle = await waitForElement( | ||
| 2128 | () => chatHeader.querySelector<HTMLElement>("h2"), | ||
| 2129 | "Unable to find thread title.", | ||
| 2130 | ); | ||
| 2131 | const headerActions = await waitForElement( | ||
| 2132 | () => document.querySelector<HTMLElement>("[data-chat-header-actions]"), | ||
| 2133 | "Unable to find chat header actions.", | ||
| 2134 | ); | ||
| 2135 | |||
| 2136 | const headerRect = chatHeader.getBoundingClientRect(); | ||
| 2137 | const titleRect = threadTitle.getBoundingClientRect(); | ||
| 2138 | const actionsRect = headerActions.getBoundingClientRect(); | ||
| 2139 | const headerCenter = headerRect.top + headerRect.height / 2; | ||
| 2140 | |||
| 2141 | expect(headerRect.height).toBe(52); | ||
| 2142 | expect(titleRect.top).toBeGreaterThanOrEqual(headerRect.top); | ||
| 2143 | expect(titleRect.bottom).toBeLessThanOrEqual(headerRect.bottom); | ||
| 2144 | expect(actionsRect.top).toBeGreaterThanOrEqual(headerRect.top); | ||
| 2145 | expect(actionsRect.bottom).toBeLessThanOrEqual(headerRect.bottom); | ||
| 2146 | expect(Math.abs(titleRect.top + titleRect.height / 2 - headerCenter)).toBeLessThanOrEqual(1); | ||
| 2147 | expect(Math.abs(actionsRect.top + actionsRect.height / 2 - headerCenter)).toBeLessThanOrEqual( | ||
| 2148 | 1, | ||
| 2149 | ); | ||
| 2150 | } finally { | ||
| 2151 | await mounted.cleanup(); | ||
| 2152 | } | ||
| 2153 | }); | ||
| 2154 | |||
| 2155 | it("keeps panel toggles fixed and can maximize the right panel", async () => { | ||
| 2156 | const mounted = await mountChatView({ | ||
| 2157 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 2158 | snapshot: createSnapshotForTargetUser({ | ||
| 2159 | targetMessageId: "msg-user-maximize-right-panel" as MessageId, | ||
| 2160 | targetText: "maximize right panel", | ||
| 2161 | }), | ||
| 2162 | }); | ||
| 2163 | |||
| 2164 | try { | ||
| 2165 | const terminalToggle = await waitForElement( | ||
| 2166 | () => | ||
| 2167 | document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), | ||
| 2168 | "Unable to find terminal drawer toggle.", | ||
| 2169 | ); | ||
| 2170 | const rightPanelToggle = await waitForElement( | ||
| 2171 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]'), | ||
| 2172 | "Unable to find right panel toggle.", | ||
| 2173 | ); | ||
| 2174 | const chatHeader = await waitForElement( | ||
| 2175 | () => document.querySelector<HTMLElement>("[data-chat-header]"), | ||
| 2176 | "Unable to find chat header.", | ||
| 2177 | ); | ||
| 2178 | const panelLayoutControls = await waitForElement( | ||
| 2179 | () => document.querySelector<HTMLElement>("[data-panel-layout-controls]"), | ||
| 2180 | "Unable to find panel layout controls.", | ||
| 2181 | ); | ||
| 2182 | expect(chatHeader.getBoundingClientRect().height).toBe(52); | ||
| 2183 | expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); | ||
| 2184 | expect(panelLayoutControls.getBoundingClientRect().top).toBe( | ||
| 2185 | chatHeader.getBoundingClientRect().top, | ||
| 2186 | ); | ||
| 2187 | expect( | ||
| 2188 | window.getComputedStyle(panelLayoutControls).getPropertyValue("-webkit-app-region"), | ||
| 2189 | ).toBe("no-drag"); | ||
| 2190 | expect(chatHeader.classList.contains("drag-region")).toBe(false); | ||
| 2191 | expect(chatHeader.contains(panelLayoutControls)).toBe(true); | ||
| 2192 | expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); | ||
| 2193 | const initialTerminalRect = terminalToggle.getBoundingClientRect(); | ||
| 2194 | const initialRightPanelRect = rightPanelToggle.getBoundingClientRect(); | ||
| 2195 | const initialControlRects = [initialTerminalRect, initialRightPanelRect]; | ||
| 2196 | expect(document.querySelector('button[aria-label="Maximize panel"]')).toBeNull(); | ||
| 2197 | expect(initialControlRects.every((rect) => rect.width === 28 && rect.height === 28)).toBe( | ||
| 2198 | true, | ||
| 2199 | ); | ||
| 2200 | expect(initialControlRects.every((rect) => rect.top === initialControlRects[0]?.top)).toBe( | ||
| 2201 | true, | ||
| 2202 | ); | ||
| 2203 | expect(initialRightPanelRect.left - initialTerminalRect.right).toBe(4); | ||
| 2204 | |||
| 2205 | document.documentElement.classList.add("wco"); | ||
| 2206 | expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); | ||
| 2207 | expect(panelLayoutControls.getBoundingClientRect().top).toBe( | ||
| 2208 | chatHeader.getBoundingClientRect().top, | ||
| 2209 | ); | ||
| 2210 | expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); | ||
| 2211 | document.documentElement.classList.remove("wco"); | ||
| 2212 | |||
| 2213 | rightPanelToggle.click(); | ||
| 2214 | |||
| 2215 | const maximizeButton = await waitForElement( | ||
| 2216 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Maximize panel"]'), | ||
| 2217 | "Unable to find maximize panel button.", | ||
| 2218 | ); | ||
| 2219 | const rightPanelTabbar = await waitForElement( | ||
| 2220 | () => document.querySelector<HTMLElement>("[data-right-panel-tabbar]"), | ||
| 2221 | "Unable to find right panel tab bar.", | ||
| 2222 | ); | ||
| 2223 | const rightPanelTabList = await waitForElement( | ||
| 2224 | () => document.querySelector<HTMLElement>("[data-right-panel-tab-list]"), | ||
| 2225 | "Unable to find right panel tab list.", | ||
| 2226 | ); | ||
| 2227 | const maximizeRect = maximizeButton.getBoundingClientRect(); | ||
| 2228 | const rightPanelTabbarRect = rightPanelTabbar.getBoundingClientRect(); | ||
| 2229 | const openPanelLayoutControls = await waitForElement( | ||
| 2230 | () => document.querySelector<HTMLElement>("[data-panel-layout-controls]"), | ||
| 2231 | "Unable to find open panel layout controls.", | ||
| 2232 | ); | ||
| 2233 | const openTerminalToggle = await waitForElement( | ||
| 2234 | () => | ||
| 2235 | document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), | ||
| 2236 | "Unable to find open panel terminal toggle.", | ||
| 2237 | ); | ||
| 2238 | const openRightPanelToggle = await waitForElement( | ||
| 2239 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]'), | ||
| 2240 | "Unable to find open panel right panel toggle.", | ||
| 2241 | ); | ||
| 2242 | expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); | ||
| 2243 | expect(rightPanelTabbarRect.height).toBe(52); | ||
| 2244 | expect(rightPanelTabbarRect.top).toBe(chatHeader.getBoundingClientRect().top); | ||
| 2245 | expect(chatHeader.contains(openPanelLayoutControls)).toBe(false); | ||
| 2246 | expect( | ||
| 2247 | window.getComputedStyle(rightPanelTabbar).getPropertyValue("-webkit-app-region"), | ||
| 2248 | ).not.toBe("drag"); | ||
| 2249 | expect(rightPanelTabList.classList.contains("drag-region")).toBe(false); | ||
| 2250 | expect(window.getComputedStyle(maximizeButton).getPropertyValue("-webkit-app-region")).toBe( | ||
| 2251 | "no-drag", | ||
| 2252 | ); | ||
| 2253 | expect( | ||
| 2254 | window.getComputedStyle(openTerminalToggle).getPropertyValue("-webkit-app-region"), | ||
| 2255 | ).toBe("no-drag"); | ||
| 2256 | expect( | ||
| 2257 | window.getComputedStyle(openRightPanelToggle).getPropertyValue("-webkit-app-region"), | ||
| 2258 | ).toBe("no-drag"); | ||
| 2259 | expect(maximizeRect.width).toBe(28); | ||
| 2260 | expect(maximizeRect.height).toBe(28); | ||
| 2261 | expect(maximizeRect.top).toBe(initialTerminalRect.top); | ||
| 2262 | expect(initialTerminalRect.left - maximizeRect.right).toBe(4); | ||
| 2263 | expect(openTerminalToggle.getBoundingClientRect().left).toBeCloseTo( | ||
| 2264 | initialTerminalRect.left, | ||
| 2265 | 1, | ||
| 2266 | ); | ||
| 2267 | expect(openRightPanelToggle.getBoundingClientRect().left).toBeCloseTo( | ||
| 2268 | initialRightPanelRect.left, | ||
| 2269 | 1, | ||
| 2270 | ); | ||
| 2271 | |||
| 2272 | useRightPanelStore.getState().openFile(THREAD_REF, "components.json"); | ||
| 2273 | const fileTabIcon = await waitForElement( | ||
| 2274 | () => | ||
| 2275 | document.querySelector<SVGElement>( | ||
| 2276 | '[data-right-panel-tabbar] [data-pierre-icon][data-icon-token="json"]', | ||
| 2277 | ), | ||
| 2278 | "Unable to find the Pierre file icon in the file tab.", | ||
| 2279 | ); | ||
| 2280 | expect(fileTabIcon.closest("button")?.textContent).toContain("components.json"); | ||
| 2281 | |||
| 2282 | document.documentElement.classList.add("wco"); | ||
| 2283 | expect(rightPanelTabbar.getBoundingClientRect().height).toBe( | ||
| 2284 | openPanelLayoutControls.getBoundingClientRect().height, | ||
| 2285 | ); | ||
| 2286 | expect(rightPanelTabbar.getBoundingClientRect().top).toBe( | ||
| 2287 | openPanelLayoutControls.getBoundingClientRect().top, | ||
| 2288 | ); | ||
| 2289 | document.documentElement.classList.remove("wco"); | ||
| 2290 | |||
| 2291 | maximizeButton.click(); | ||
| 2292 | |||
| 2293 | await vi.waitFor(() => { | ||
| 2294 | const chatColumn = document.querySelector<HTMLElement>( | ||
| 2295 | '[data-chat-column-maximized-away="true"]', | ||
| 2296 | ); | ||
| 2297 | const panel = document.querySelector<HTMLElement>( | ||
| 2298 | '[data-preview-panel-mode="inline"][data-preview-panel-maximized="true"]', | ||
| 2299 | ); | ||
| 2300 | expect(chatColumn?.getBoundingClientRect().width).toBe(0); | ||
| 2301 | expect(panel?.getBoundingClientRect().width).toBeGreaterThan(1_000); | ||
| 2302 | expect( | ||
| 2303 | document.querySelector<HTMLButtonElement>('button[aria-label="Restore panel size"]'), | ||
| 2304 | ).not.toBeNull(); | ||
| 2305 | expect( | ||
| 2306 | document | ||
| 2307 | .querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]') | ||
| 2308 | ?.getBoundingClientRect().left, | ||
| 2309 | ).toBeCloseTo(initialTerminalRect.left, 1); | ||
| 2310 | expect( | ||
| 2311 | document | ||
| 2312 | .querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]') | ||
| 2313 | ?.getBoundingClientRect().left, | ||
| 2314 | ).toBeCloseTo(initialRightPanelRect.left, 1); | ||
| 2315 | }); | ||
| 2316 | } finally { | ||
| 2317 | await mounted.cleanup(); | ||
| 2318 | } | ||
| 2319 | }); | ||
| 2320 | |||
| 2321 | it("renders the plan surface in the inline right panel", async () => { | ||
| 2322 | useRightPanelStore.getState().open(THREAD_REF, "plan"); | ||
| 2323 | |||
| 2324 | const mounted = await mountChatView({ | ||
| 2325 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 2326 | snapshot: createSnapshotForTargetUser({ | ||
| 2327 | targetMessageId: "msg-user-inline-plan-panel" as MessageId, | ||
| 2328 | targetText: "show the inline plan panel", | ||
| 2329 | }), | ||
| 2330 | }); | ||
| 2331 | |||
| 2332 | try { | ||
| 2333 | await waitForElement( | ||
| 2334 | () => | ||
| 2335 | Array.from(document.querySelectorAll<HTMLElement>("p")).find( | ||
| 2336 | (element) => element.textContent?.trim() === "No active plan yet.", | ||
| 2337 | ) ?? null, | ||
| 2338 | "Unable to find inline plan panel content.", | ||
| 2339 | ); | ||
| 2340 | |||
| 2341 | expect( | ||
| 2342 | document.querySelector<HTMLElement>("[data-right-panel-tabbar]")?.textContent, | ||
| 2343 | ).toContain("Plan"); | ||
| 2344 | expect(document.body.textContent).toContain("Plans will appear here when generated."); | ||
| 2345 | } finally { | ||
| 2346 | await mounted.cleanup(); | ||
| 2347 | } | ||
| 2348 | }); | ||
| 2349 | |||
| 2350 | it("renders the shared panel toggles in the responsive right-panel sheet", async () => { | ||
| 2351 | useRightPanelStore.getState().open(THREAD_REF, "plan"); | ||
| 2352 | useRightPanelStore.getState().openTerminal(THREAD_REF, DEFAULT_TERMINAL_ID); | ||
| 2353 | useRightPanelStore.getState().activateSurface(THREAD_REF, "plan"); | ||
| 2354 | const baseSnapshot = createSnapshotForTargetUser({ | ||
| 2355 | targetMessageId: "msg-user-responsive-plan-panel-controls" as MessageId, | ||
| 2356 | targetText: "show responsive plan panel controls", | ||
| 2357 | }); | ||
| 2358 | const snapshot: OrchestrationReadModel = { | ||
| 2359 | ...baseSnapshot, | ||
| 2360 | threads: baseSnapshot.threads.map((thread) => | ||
| 2361 | thread.id === THREAD_ID | ||
| 2362 | ? { | ||
| 2363 | ...thread, | ||
| 2364 | activities: [ | ||
| 2365 | { | ||
| 2366 | id: EventId.make("activity-responsive-panel-plan"), | ||
| 2367 | tone: "info", | ||
| 2368 | kind: "turn.plan.updated", | ||
| 2369 | summary: "Plan updated", | ||
| 2370 | payload: { | ||
| 2371 | explanation: "Claude Tasks", | ||
| 2372 | plan: [{ step: "Keep terminal navigation available", status: "inProgress" }], | ||
| 2373 | }, | ||
| 2374 | turnId: null, | ||
| 2375 | sequence: 1, | ||
| 2376 | createdAt: isoAt(1_000), | ||
| 2377 | }, | ||
| 2378 | ], | ||
| 2379 | } | ||
| 2380 | : thread, | ||
| 2381 | ), | ||
| 2382 | }; | ||
| 2383 | |||
| 2384 | const mounted = await mountChatView({ | ||
| 2385 | viewport: COMPACT_FOOTER_VIEWPORT, | ||
| 2386 | snapshot, | ||
| 2387 | }); | ||
| 2388 | |||
| 2389 | try { | ||
| 2390 | const sheet = await waitForElement( | ||
| 2391 | () => document.querySelector<HTMLElement>('[data-slot="sheet-popup"]'), | ||
| 2392 | "Unable to find responsive right-panel sheet.", | ||
| 2393 | ); | ||
| 2394 | const controls = await waitForElement( | ||
| 2395 | () => sheet.querySelector<HTMLElement>("[data-panel-layout-controls]"), | ||
| 2396 | "Unable to find shared controls in the responsive right-panel sheet.", | ||
| 2397 | ); | ||
| 2398 | const tabbar = await waitForElement( | ||
| 2399 | () => sheet.querySelector<HTMLElement>("[data-right-panel-tabbar]"), | ||
| 2400 | "Unable to find responsive right-panel tabbar.", | ||
| 2401 | ); | ||
| 2402 | const controlButtons = Array.from(controls.querySelectorAll<HTMLButtonElement>("button")); | ||
| 2403 | const tabbarRect = tabbar.getBoundingClientRect(); | ||
| 2404 | const controlsRect = controls.getBoundingClientRect(); | ||
| 2405 | |||
| 2406 | expect(controlButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ | ||
| 2407 | "Toggle terminal drawer", | ||
| 2408 | "Toggle right panel", | ||
| 2409 | ]); | ||
| 2410 | expect(tabbarRect.height).toBe(52); | ||
| 2411 | expect(controlsRect.height).toBe(52); | ||
| 2412 | expect(controlsRect.top).toBe(tabbarRect.top); | ||
| 2413 | expect(window.innerWidth - controlsRect.right).toBe(12); | ||
| 2414 | for (const button of controlButtons) { | ||
| 2415 | const rect = button.getBoundingClientRect(); | ||
| 2416 | const buttonCenter = rect.top + rect.height / 2; | ||
| 2417 | const tabbarCenter = tabbarRect.top + tabbarRect.height / 2; | ||
| 2418 | expect(rect.width).toBe(32); | ||
| 2419 | expect(rect.height).toBe(32); | ||
| 2420 | expect(Math.abs(buttonCenter - tabbarCenter)).toBeLessThanOrEqual(1); | ||
| 2421 | } | ||
| 2422 | expect( | ||
| 2423 | controlButtons[1]!.getBoundingClientRect().left - | ||
| 2424 | controlButtons[0]!.getBoundingClientRect().right, | ||
| 2425 | ).toBe(4); | ||
| 2426 | expect(sheet.querySelector('button[aria-label="Maximize panel"]')).toBeNull(); | ||
| 2427 | expect(sheet.querySelector('button[aria-label="Close tasks sidebar"]')).toBeNull(); | ||
| 2428 | |||
| 2429 | const terminalTab = Array.from( | ||
| 2430 | sheet.querySelectorAll<HTMLButtonElement>("[data-right-panel-tab-list] button"), | ||
| 2431 | ).find((button) => button.textContent?.includes("Terminal")); | ||
| 2432 | terminalTab?.click(); | ||
| 2433 | |||
| 2434 | await vi.waitFor(() => { | ||
| 2435 | expect( | ||
| 2436 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | ||
| 2437 | .activeSurfaceId, | ||
| 2438 | ).toBe(`terminal:${DEFAULT_TERMINAL_ID}`); | ||
| 2439 | expect(sheet.querySelector('[data-terminal-owner="right-panel"]')).not.toBeNull(); | ||
| 2440 | expect(sheet.textContent).not.toContain("Claude Tasks"); | ||
| 2441 | }); | ||
| 2442 | |||
| 2443 | sheet.querySelector<HTMLButtonElement>('button[aria-label="Close Plan"]')?.click(); | ||
| 2444 | |||
| 2445 | await vi.waitFor(() => { | ||
| 2446 | const panelState = selectThreadRightPanelState( | ||
| 2447 | useRightPanelStore.getState().byThreadKey, | ||
| 2448 | THREAD_REF, | ||
| 2449 | ); | ||
| 2450 | expect(panelState.surfaces.some((surface) => surface.kind === "plan")).toBe(false); | ||
| 2451 | expect(panelState.activeSurfaceId).toBe(`terminal:${DEFAULT_TERMINAL_ID}`); | ||
| 2452 | }); | ||
| 2453 | |||
| 2454 | controls.querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]')?.click(); | ||
| 2455 | |||
| 2456 | await vi.waitFor(() => { | ||
| 2457 | expect( | ||
| 2458 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF).isOpen, | ||
| 2459 | ).toBe(false); | ||
| 2460 | }); | ||
| 2461 | } finally { | ||
| 2462 | await mounted.cleanup(); | ||
| 2463 | } | ||
| 2464 | }); | ||
| 2465 | |||
| 2466 | it("loads file previews from the active thread worktree", async () => { | ||
| 2467 | const worktreePath = "/repo/worktrees/file-preview-thread"; | ||
| 2468 | const snapshot = createSnapshotForTargetUser({ | ||
| 2469 | targetMessageId: "msg-user-worktree-file-preview" as MessageId, | ||
| 2470 | targetText: "open the worktree file preview", | ||
| 2471 | }); | ||
| 2472 | const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); | ||
| 2473 | if (!targetThread) { | ||
| 2474 | throw new Error("Missing target thread fixture."); | ||
| 2475 | } | ||
| 2476 | |||
| 2477 | const mounted = await mountChatView({ | ||
| 2478 | viewport: DEFAULT_VIEWPORT, | ||
| 2479 | snapshot: { | ||
| 2480 | ...snapshot, | ||
| 2481 | threads: snapshot.threads.map((thread) => | ||
| 2482 | thread.id === THREAD_ID ? { ...thread, worktreePath } : thread, | ||
| 2483 | ), | ||
| 2484 | }, | ||
| 2485 | resolveRpc: (body) => { | ||
| 2486 | if (body._tag === WS_METHODS.projectsListEntries) { | ||
| 2487 | return { entries: [{ path: "src/index.ts", kind: "file" }], truncated: false }; | ||
| 2488 | } | ||
| 2489 | if (body._tag === WS_METHODS.projectsReadFile) { | ||
| 2490 | return { | ||
| 2491 | relativePath: "src/index.ts", | ||
| 2492 | contents: "export const worktree = true;\n", | ||
| 2493 | byteLength: 30, | ||
| 2494 | truncated: false, | ||
| 2495 | }; | ||
| 2496 | } | ||
| 2497 | return undefined; | ||
| 2498 | }, | ||
| 2499 | }); | ||
| 2500 | |||
| 2501 | try { | ||
| 2502 | useRightPanelStore.getState().open(THREAD_REF, "files"); | ||
| 2503 | await waitForElement( | ||
| 2504 | () => document.querySelector<HTMLElement>("[data-file-browser-panel]"), | ||
| 2505 | "Unable to find the worktree file explorer.", | ||
| 2506 | ); | ||
| 2507 | |||
| 2508 | useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); | ||
| 2509 | await waitForElement( | ||
| 2510 | () => document.querySelector<HTMLElement>(".file-preview-virtualizer"), | ||
| 2511 | "Unable to find the worktree file preview.", | ||
| 2512 | ); | ||
| 2513 | |||
| 2514 | const listRequest = wsRequests.find( | ||
| 2515 | (request) => request._tag === WS_METHODS.projectsListEntries, | ||
| 2516 | ); | ||
| 2517 | const readRequest = wsRequests.find( | ||
| 2518 | (request) => request._tag === WS_METHODS.projectsReadFile, | ||
| 2519 | ); | ||
| 2520 | expect(listRequest).toMatchObject({ cwd: worktreePath }); | ||
| 2521 | expect(readRequest).toMatchObject({ cwd: worktreePath, relativePath: "src/index.ts" }); | ||
| 2522 | } finally { | ||
| 2523 | await mounted.cleanup(); | ||
| 2524 | } | ||
| 2525 | }); | ||
| 2526 | |||
| 2527 | it("scrolls file tabs and preserves the workspace explorer across file previews", async () => { | ||
| 2528 | const workspaceEntries = [ | ||
| 2529 | { path: "src", kind: "directory" as const }, | ||
| 2530 | { path: "src/index.ts", kind: "file" as const }, | ||
| 2531 | { path: "src/router.ts", kind: "file" as const }, | ||
| 2532 | { path: "src/store.ts", kind: "file" as const }, | ||
| 2533 | { path: "src/styles.css", kind: "file" as const }, | ||
| 2534 | { path: "src/large.ts", kind: "file" as const }, | ||
| 2535 | { path: "e2e", kind: "directory" as const }, | ||
| 2536 | { path: "e2e/test-results", kind: "directory" as const }, | ||
| 2537 | { | ||
| 2538 | path: "e2e/test-results/playwright-integration-results", | ||
| 2539 | kind: "directory" as const, | ||
| 2540 | }, | ||
| 2541 | { | ||
| 2542 | path: "e2e/test-results/playwright-integration-results/chromium-desktop-project", | ||
| 2543 | kind: "directory" as const, | ||
| 2544 | }, | ||
| 2545 | { | ||
| 2546 | path: "e2e/test-results/playwright-integration-results/chromium-desktop-project/.last-run.json", | ||
| 2547 | kind: "file" as const, | ||
| 2548 | }, | ||
| 2549 | { path: "README.md", kind: "file" as const }, | ||
| 2550 | { path: "AGENTS.md", kind: "file" as const }, | ||
| 2551 | { path: "package.json", kind: "file" as const }, | ||
| 2552 | { path: "tsconfig.json", kind: "file" as const }, | ||
| 2553 | ]; | ||
| 2554 | const mounted = await mountChatView({ | ||
| 2555 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 2556 | snapshot: createSnapshotForTargetUser({ | ||
| 2557 | targetMessageId: "msg-user-file-tabs-and-tree-state" as MessageId, | ||
| 2558 | targetText: "keep file tabs readable and preserve tree state", | ||
| 2559 | }), | ||
| 2560 | resolveRpc: (body) => { | ||
| 2561 | if (body._tag === WS_METHODS.projectsListEntries) { | ||
| 2562 | return { entries: workspaceEntries, truncated: false }; | ||
| 2563 | } | ||
| 2564 | if (body._tag === WS_METHODS.projectsReadFile) { | ||
| 2565 | const relativePath = | ||
| 2566 | typeof body.relativePath === "string" ? body.relativePath : "file.ts"; | ||
| 2567 | const contents = | ||
| 2568 | relativePath === "src/large.ts" | ||
| 2569 | ? Array.from( | ||
| 2570 | { length: 5_000 }, | ||
| 2571 | (_, index) => `export const line${index + 1} = ${index + 1};`, | ||
| 2572 | ).join("\n") | ||
| 2573 | : `// ${relativePath}\n`; | ||
| 2574 | return { | ||
| 2575 | relativePath, | ||
| 2576 | contents, | ||
| 2577 | byteLength: new TextEncoder().encode(contents).byteLength, | ||
| 2578 | truncated: false, | ||
| 2579 | }; | ||
| 2580 | } | ||
| 2581 | return undefined; | ||
| 2582 | }, | ||
| 2583 | }); | ||
| 2584 | |||
| 2585 | try { | ||
| 2586 | useRightPanelStore.getState().open(THREAD_REF, "files"); | ||
| 2587 | |||
| 2588 | const explorer = await waitForElement( | ||
| 2589 | () => document.querySelector<HTMLElement>("[data-file-browser-panel]"), | ||
| 2590 | "Unable to find the workspace file explorer.", | ||
| 2591 | ); | ||
| 2592 | |||
| 2593 | for (const entry of workspaceEntries) { | ||
| 2594 | if (entry.kind === "file") { | ||
| 2595 | useRightPanelStore.getState().openFile(THREAD_REF, entry.path); | ||
| 2596 | } | ||
| 2597 | } | ||
| 2598 | |||
| 2599 | const tabList = await waitForElement( | ||
| 2600 | () => document.querySelector<HTMLElement>("[data-right-panel-tab-list]"), | ||
| 2601 | "Unable to find the right panel tab list.", | ||
| 2602 | ); | ||
| 2603 | const tabViewport = await waitForElement( | ||
| 2604 | () => tabList.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]'), | ||
| 2605 | "Unable to find the right panel tab viewport.", | ||
| 2606 | ); | ||
| 2607 | |||
| 2608 | await vi.waitFor(() => { | ||
| 2609 | const fileTabs = Array.from(tabList.querySelectorAll<HTMLElement>("[data-active-tab]")); | ||
| 2610 | expect(fileTabs.length).toBe( | ||
| 2611 | workspaceEntries.filter((entry) => entry.kind === "file").length, | ||
| 2612 | ); | ||
| 2613 | expect(tabViewport.scrollWidth).toBeGreaterThan(tabViewport.clientWidth); | ||
| 2614 | expect(tabViewport.scrollLeft).toBeGreaterThan(0); | ||
| 2615 | expect(tabList.querySelector('[data-slot="scroll-area-scrollbar"]')).toBeNull(); | ||
| 2616 | expect( | ||
| 2617 | fileTabs.every((tab) => { | ||
| 2618 | const width = tab.getBoundingClientRect().width; | ||
| 2619 | return width >= 100 && width <= 176; | ||
| 2620 | }), | ||
| 2621 | ).toBe(true); | ||
| 2622 | expect(document.querySelector<HTMLElement>("[data-file-browser-panel]")).toBe(explorer); | ||
| 2623 | }); | ||
| 2624 | |||
| 2625 | useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); | ||
| 2626 | await vi.waitFor(() => { | ||
| 2627 | expect(document.querySelector<HTMLElement>("[data-file-browser-panel]")).toBe(explorer); | ||
| 2628 | }); | ||
| 2629 | |||
| 2630 | useRightPanelStore | ||
| 2631 | .getState() | ||
| 2632 | .openFile( | ||
| 2633 | THREAD_REF, | ||
| 2634 | "e2e/test-results/playwright-integration-results/chromium-desktop-project/.last-run.json", | ||
| 2635 | ); | ||
| 2636 | await mounted.setContainerSize({ width: 800, height: WIDE_FOOTER_VIEWPORT.height }); | ||
| 2637 | const breadcrumbs = await waitForElement( | ||
| 2638 | () => document.querySelector<HTMLElement>("[data-file-breadcrumbs]"), | ||
| 2639 | "Unable to find the responsive file breadcrumbs.", | ||
| 2640 | ); | ||
| 2641 | const fileSubheader = breadcrumbs.closest<HTMLElement>("[data-surface-subheader]"); | ||
| 2642 | const breadcrumbViewport = await waitForElement( | ||
| 2643 | () => breadcrumbs.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]'), | ||
| 2644 | "Unable to find the file breadcrumb viewport.", | ||
| 2645 | ); | ||
| 2646 | const currentCrumb = await waitForElement( | ||
| 2647 | () => | ||
| 2648 | Array.from( | ||
| 2649 | breadcrumbs.querySelectorAll<HTMLElement>("[data-current-file-crumb='true']"), | ||
| 2650 | ).find((crumb) => crumb.textContent === ".last-run.json") ?? null, | ||
| 2651 | "Unable to find the current file breadcrumb.", | ||
| 2652 | ); | ||
| 2653 | const explorerToggle = await waitForElement( | ||
| 2654 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Hide file explorer"]'), | ||
| 2655 | "Unable to find the file explorer toggle.", | ||
| 2656 | ); | ||
| 2657 | |||
| 2658 | await vi.waitFor(() => { | ||
| 2659 | const viewportRect = breadcrumbViewport.getBoundingClientRect(); | ||
| 2660 | const currentCrumbRect = currentCrumb.getBoundingClientRect(); | ||
| 2661 | expect(breadcrumbViewport.scrollWidth).toBeGreaterThan(breadcrumbViewport.clientWidth); | ||
| 2662 | expect(breadcrumbViewport.scrollLeft).toBeGreaterThan(0); | ||
| 2663 | expect(breadcrumbs.querySelector('[data-slot="scroll-area-scrollbar"]')).toBeNull(); | ||
| 2664 | expect(currentCrumbRect.right).toBeLessThanOrEqual(viewportRect.right + 1); | ||
| 2665 | expect(viewportRect.right).toBeLessThan(explorerToggle.getBoundingClientRect().left); | ||
| 2666 | expect(explorerToggle.getAttribute("aria-pressed")).toBe("true"); | ||
| 2667 | expect(explorerToggle.getBoundingClientRect().width).toBe(28); | ||
| 2668 | expect(explorerToggle.getBoundingClientRect().height).toBe(28); | ||
| 2669 | expect(fileSubheader?.getBoundingClientRect().height).toBe(40); | ||
| 2670 | expect(window.getComputedStyle(fileSubheader!).borderTopWidth).toBe("0px"); | ||
| 2671 | expect(window.getComputedStyle(fileSubheader!).borderBottomWidth).toBe("1px"); | ||
| 2672 | }); | ||
| 2673 | |||
| 2674 | const fileSearchButton = await waitForElement( | ||
| 2675 | () => | ||
| 2676 | document.querySelector<HTMLButtonElement>('button[aria-label="Search workspace files"]'), | ||
| 2677 | "Unable to find the workspace file search button.", | ||
| 2678 | ); | ||
| 2679 | fileSearchButton.click(); | ||
| 2680 | const fileTree = await waitForElement( | ||
| 2681 | () => document.querySelector<HTMLElement>("file-tree-container"), | ||
| 2682 | "Unable to find the file tree host.", | ||
| 2683 | ); | ||
| 2684 | const fileSearchInput = await waitForElement( | ||
| 2685 | () => | ||
| 2686 | fileTree.shadowRoot?.querySelector<HTMLInputElement>("[data-file-tree-search-input]") ?? | ||
| 2687 | null, | ||
| 2688 | "Unable to find the file tree search input.", | ||
| 2689 | ); | ||
| 2690 | fileSearchInput.focus(); | ||
| 2691 | const searchKeyEvent = new KeyboardEvent("keydown", { | ||
| 2692 | key: "r", | ||
| 2693 | bubbles: true, | ||
| 2694 | cancelable: true, | ||
| 2695 | composed: true, | ||
| 2696 | }); | ||
| 2697 | fileSearchInput.dispatchEvent(searchKeyEvent); | ||
| 2698 | await waitForLayout(); | ||
| 2699 | expect(searchKeyEvent.defaultPrevented).toBe(false); | ||
| 2700 | expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput); | ||
| 2701 | expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); | ||
| 2702 | |||
| 2703 | const previousCodeVirtualizer = document.querySelector<HTMLElement>( | ||
| 2704 | ".file-preview-virtualizer", | ||
| 2705 | ); | ||
| 2706 | useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000); | ||
| 2707 | const codeVirtualizer = await waitForElement(() => { | ||
| 2708 | const current = document.querySelector<HTMLElement>(".file-preview-virtualizer"); | ||
| 2709 | return current !== previousCodeVirtualizer ? current : null; | ||
| 2710 | }, "Unable to find the virtualized file preview."); | ||
| 2711 | expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull(); | ||
| 2712 | expect(codeVirtualizer.classList.contains("overflow-auto")).toBe(true); | ||
| 2713 | await vi.waitFor( | ||
| 2714 | () => { | ||
| 2715 | const fileHost = codeVirtualizer.querySelector<HTMLElement>("diffs-container"); | ||
| 2716 | const targetLine = fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-line="4000"]'); | ||
| 2717 | const targetLineNumber = fileHost?.shadowRoot?.querySelector<HTMLElement>( | ||
| 2718 | '[data-column-number="4000"]', | ||
| 2719 | ); | ||
| 2720 | const previousLine = | ||
| 2721 | fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-line="3999"]'); | ||
| 2722 | const previousLineNumber = fileHost?.shadowRoot?.querySelector<HTMLElement>( | ||
| 2723 | '[data-column-number="3999"]', | ||
| 2724 | ); | ||
| 2725 | expect(codeVirtualizer.scrollTop).toBeGreaterThan(0); | ||
| 2726 | expect(targetLine).not.toBeNull(); | ||
| 2727 | expect(previousLine).not.toBeNull(); | ||
| 2728 | expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true); | ||
| 2729 | expect(targetLineNumber?.hasAttribute("data-file-link-reveal")).toBe(true); | ||
| 2730 | expect(targetLine?.hasAttribute("data-selected-line")).toBe(false); | ||
| 2731 | expect(targetLineNumber?.hasAttribute("data-selected-line")).toBe(false); | ||
| 2732 | expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull(); | ||
| 2733 | expect(window.getComputedStyle(targetLine!).backgroundColor).not.toBe( | ||
| 2734 | window.getComputedStyle(previousLine!).backgroundColor, | ||
| 2735 | ); | ||
| 2736 | expect(window.getComputedStyle(targetLineNumber!).backgroundColor).not.toBe( | ||
| 2737 | window.getComputedStyle(previousLineNumber!).backgroundColor, | ||
| 2738 | ); | ||
| 2739 | |||
| 2740 | const viewportRect = codeVirtualizer.getBoundingClientRect(); | ||
| 2741 | const lineRect = targetLine!.getBoundingClientRect(); | ||
| 2742 | expect(lineRect.top).toBeGreaterThanOrEqual(viewportRect.top); | ||
| 2743 | expect(lineRect.bottom).toBeLessThanOrEqual(viewportRect.bottom); | ||
| 2744 | }, | ||
| 2745 | { timeout: 8_000, interval: 16 }, | ||
| 2746 | ); | ||
| 2747 | |||
| 2748 | const fileHost = codeVirtualizer.querySelector<HTMLElement>("diffs-container"); | ||
| 2749 | const targetLineNumber = | ||
| 2750 | fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-column-number="4000"]') ?? null; | ||
| 2751 | const previousLineNumber = | ||
| 2752 | fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-column-number="3999"]') ?? null; | ||
| 2753 | expect(targetLineNumber).not.toBeNull(); | ||
| 2754 | expect(previousLineNumber).not.toBeNull(); | ||
| 2755 | |||
| 2756 | targetLineNumber!.dispatchEvent( | ||
| 2757 | new PointerEvent("pointermove", { | ||
| 2758 | bubbles: true, | ||
| 2759 | cancelable: true, | ||
| 2760 | composed: true, | ||
| 2761 | pointerType: "mouse", | ||
| 2762 | }), | ||
| 2763 | ); | ||
| 2764 | await vi.waitFor(() => { | ||
| 2765 | expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull(); | ||
| 2766 | }); | ||
| 2767 | |||
| 2768 | previousLineNumber!.dispatchEvent( | ||
| 2769 | new PointerEvent("pointermove", { | ||
| 2770 | bubbles: true, | ||
| 2771 | cancelable: true, | ||
| 2772 | composed: true, | ||
| 2773 | pointerType: "mouse", | ||
| 2774 | }), | ||
| 2775 | ); | ||
| 2776 | await vi.waitFor(() => { | ||
| 2777 | expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull(); | ||
| 2778 | expect(previousLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull(); | ||
| 2779 | }); | ||
| 2780 | |||
| 2781 | codeVirtualizer.scrollTop = 0; | ||
| 2782 | useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000); | ||
| 2783 | await vi.waitFor( | ||
| 2784 | () => { | ||
| 2785 | const fileHost = codeVirtualizer.querySelector<HTMLElement>("diffs-container"); | ||
| 2786 | const targetLine = fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-line="4000"]'); | ||
| 2787 | expect(targetLine).not.toBeNull(); | ||
| 2788 | expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true); | ||
| 2789 | expect(targetLine?.hasAttribute("data-selected-line")).toBe(false); | ||
| 2790 | expect(codeVirtualizer.scrollTop).toBeGreaterThan(0); | ||
| 2791 | }, | ||
| 2792 | { timeout: 8_000, interval: 16 }, | ||
| 2793 | ); | ||
| 2794 | } finally { | ||
| 2795 | await mounted.cleanup(); | ||
| 2796 | } | ||
| 2797 | }); | ||
| 2798 | |||
| 2799 | it("removes persisted file tabs when a draft workspace no longer exists", async () => { | ||
| 2800 | const orphanedDraftId = DraftId.make("draft-orphaned-file-panel"); | ||
| 2801 | const orphanedThreadId = "thread-orphaned-file-panel" as ThreadId; | ||
| 2802 | const orphanedThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, orphanedThreadId); | ||
| 2803 | useComposerDraftStore.getState().setProjectDraftThreadId( | ||
| 2804 | { | ||
| 2805 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 2806 | projectId: "project-deleted" as ProjectId, | ||
| 2807 | }, | ||
| 2808 | orphanedDraftId, | ||
| 2809 | { threadId: orphanedThreadId }, | ||
| 2810 | ); | ||
| 2811 | useRightPanelStore.getState().openFile(orphanedThreadRef, "conductor.json"); | ||
| 2812 | |||
| 2813 | const mounted = await mountChatView({ | ||
| 2814 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 2815 | snapshot: createSnapshotForTargetUser({ | ||
| 2816 | targetMessageId: "msg-user-orphaned-file-panel" as MessageId, | ||
| 2817 | targetText: "orphaned persisted file panel", | ||
| 2818 | }), | ||
| 2819 | initialPath: `/draft/${orphanedDraftId}`, | ||
| 2820 | }); | ||
| 2821 | |||
| 2822 | try { | ||
| 2823 | await vi.waitFor(() => { | ||
| 2824 | expect( | ||
| 2825 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, orphanedThreadRef), | ||
| 2826 | ).toEqual({ | ||
| 2827 | isOpen: false, | ||
| 2828 | activeSurfaceId: null, | ||
| 2829 | surfaces: [], | ||
| 2830 | }); | ||
| 2831 | expect(document.querySelector("[data-right-panel-tabbar]")).toBeNull(); | ||
| 2832 | }); | ||
| 2833 | } finally { | ||
| 2834 | await mounted.cleanup(); | ||
| 2835 | } | ||
| 2836 | }); | ||
| 2837 | |||
| 2838 | it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => { | ||
| 2839 | const mounted = await mountChatView({ | ||
| 2840 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 2841 | snapshot: createSnapshotForTargetUser({ | ||
| 2842 | targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, | ||
| 2843 | targetText: "open inline terminal panel", | ||
| 2844 | }), | ||
| 2845 | }); | ||
| 2846 | |||
| 2847 | try { | ||
| 2848 | const rightPanelToggle = await waitForElement( | ||
| 2849 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]'), | ||
| 2850 | "Unable to find right panel toggle.", | ||
| 2851 | ); | ||
| 2852 | rightPanelToggle.click(); | ||
| 2853 | |||
| 2854 | await vi.waitFor(() => { | ||
| 2855 | expect(document.body.textContent).toContain("Open a surface"); | ||
| 2856 | }); | ||
| 2857 | expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); | ||
| 2858 | expect( | ||
| 2859 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), | ||
| 2860 | ).toEqual({ | ||
| 2861 | isOpen: true, | ||
| 2862 | activeSurfaceId: null, | ||
| 2863 | surfaces: [], | ||
| 2864 | }); | ||
| 2865 | expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false); | ||
| 2866 | |||
| 2867 | const emptyStateTerminalButton = await waitForElement( | ||
| 2868 | () => | ||
| 2869 | Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => | ||
| 2870 | button.textContent?.includes("Start a shell in this workspace."), | ||
| 2871 | ) ?? null, | ||
| 2872 | "Unable to find the empty-state Terminal button.", | ||
| 2873 | ); | ||
| 2874 | emptyStateTerminalButton.click(); | ||
| 2875 | |||
| 2876 | await vi.waitFor(() => { | ||
| 2877 | expect( | ||
| 2878 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | ||
| 2879 | .surfaces.filter((surface) => surface.kind === "terminal") | ||
| 2880 | .map((surface) => surface.resourceId), | ||
| 2881 | ).toEqual(["term-1"]); | ||
| 2882 | }); | ||
| 2883 | |||
| 2884 | const addSurface = await waitForElement( | ||
| 2885 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Add panel surface"]'), | ||
| 2886 | "Unable to find add panel surface button beside the tabs.", | ||
| 2887 | ); | ||
| 2888 | addSurface.click(); | ||
| 2889 | const secondTerminalItem = await waitForElement( | ||
| 2890 | () => | ||
| 2891 | Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find( | ||
| 2892 | (item) => item.textContent?.trim() === "Terminal", | ||
| 2893 | ) ?? null, | ||
| 2894 | "Unable to find Terminal panel menu item.", | ||
| 2895 | ); | ||
| 2896 | secondTerminalItem.click(); | ||
| 2897 | |||
| 2898 | await vi.waitFor( | ||
| 2899 | () => { | ||
| 2900 | expect( | ||
| 2901 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | ||
| 2902 | .surfaces.filter((surface) => surface.kind === "terminal") | ||
| 2903 | .map((surface) => surface.resourceId), | ||
| 2904 | ).toEqual(["term-1", "term-2"]); | ||
| 2905 | expect( | ||
| 2906 | document.querySelector('[data-preview-panel-mode="inline"] .thread-terminal-drawer'), | ||
| 2907 | ).not.toBeNull(); | ||
| 2908 | expect( | ||
| 2909 | wsRequests | ||
| 2910 | .filter((request) => request._tag === WS_METHODS.terminalOpen) | ||
| 2911 | .map((request) => ("terminalId" in request ? request.terminalId : null)), | ||
| 2912 | ).toEqual(expect.arrayContaining(["term-1", "term-2"])); | ||
| 2913 | const attachRequest = wsRequests.find( | ||
| 2914 | (request) => | ||
| 2915 | request._tag === WS_METHODS.terminalAttach && | ||
| 2916 | "terminalId" in request && | ||
| 2917 | request.terminalId === "term-2", | ||
| 2918 | ); | ||
| 2919 | expect(attachRequest).toMatchObject({ | ||
| 2920 | _tag: WS_METHODS.terminalAttach, | ||
| 2921 | threadId: THREAD_ID, | ||
| 2922 | terminalId: "term-2", | ||
| 2923 | cwd: "/repo/project", | ||
| 2924 | }); | ||
| 2925 | }, | ||
| 2926 | { timeout: 8_000, interval: 16 }, | ||
| 2927 | ); | ||
| 2928 | |||
| 2929 | const drawerToggle = await waitForElement( | ||
| 2930 | () => | ||
| 2931 | document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), | ||
| 2932 | "Unable to find terminal drawer toggle.", | ||
| 2933 | ); | ||
| 2934 | drawerToggle.click(); | ||
| 2935 | |||
| 2936 | await vi.waitFor(() => { | ||
| 2937 | expect( | ||
| 2938 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey[THREAD_KEY], | ||
| 2939 | ).toMatchObject({ | ||
| 2940 | terminalOpen: true, | ||
| 2941 | terminalIds: ["term-3"], | ||
| 2942 | }); | ||
| 2943 | expect( | ||
| 2944 | wsRequests.some( | ||
| 2945 | (request) => | ||
| 2946 | request._tag === WS_METHODS.terminalAttach && | ||
| 2947 | "terminalId" in request && | ||
| 2948 | request.terminalId === "term-3", | ||
| 2949 | ), | ||
| 2950 | ).toBe(true); | ||
| 2951 | }); | ||
| 2952 | } finally { | ||
| 2953 | await mounted.cleanup(); | ||
| 2954 | } | ||
| 2955 | }); | ||
| 2956 | |||
| 2957 | it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { | ||
| 2958 | setDraftThreadWithoutWorktree(); | ||
| 2959 | |||
| 2960 | const mounted = await mountChatView({ | ||
| 2961 | viewport: DEFAULT_VIEWPORT, | ||
| 2962 | snapshot: createDraftOnlySnapshot(), | ||
| 2963 | configureFixture: (nextFixture) => { | ||
| 2964 | nextFixture.serverConfig = { | ||
| 2965 | ...nextFixture.serverConfig, | ||
| 2966 | availableEditors: ["vscode-insiders"], | ||
| 2967 | }; | ||
| 2968 | }, | ||
| 2969 | }); | ||
| 2970 | |||
| 2971 | try { | ||
| 2972 | await waitForServerConfigToApply(); | ||
| 2973 | const openButton = await waitForElement( | ||
| 2974 | () => | ||
| 2975 | Array.from(document.querySelectorAll("button")).find( | ||
| 2976 | (button) => button.textContent?.trim() === "Open", | ||
| 2977 | ) as HTMLButtonElement | null, | ||
| 2978 | "Unable to find Open button.", | ||
| 2979 | ); | ||
| 2980 | await vi.waitFor(() => { | ||
| 2981 | expect(openButton.disabled).toBe(false); | ||
| 2982 | }); | ||
| 2983 | openButton.click(); | ||
| 2984 | |||
| 2985 | await vi.waitFor( | ||
| 2986 | () => { | ||
| 2987 | const openRequest = wsRequests.find( | ||
| 2988 | (request) => request._tag === WS_METHODS.shellOpenInEditor, | ||
| 2989 | ); | ||
| 2990 | expect(openRequest).toMatchObject({ | ||
| 2991 | _tag: WS_METHODS.shellOpenInEditor, | ||
| 2992 | cwd: "/repo/project", | ||
| 2993 | editor: "vscode-insiders", | ||
| 2994 | }); | ||
| 2995 | }, | ||
| 2996 | { timeout: 8_000, interval: 16 }, | ||
| 2997 | ); | ||
| 2998 | } finally { | ||
| 2999 | await mounted.cleanup(); | ||
| 3000 | } | ||
| 3001 | }); | ||
| 3002 | |||
| 3003 | it("opens the project cwd with Trae when it is the only available editor", async () => { | ||
| 3004 | setDraftThreadWithoutWorktree(); | ||
| 3005 | |||
| 3006 | const mounted = await mountChatView({ | ||
| 3007 | viewport: DEFAULT_VIEWPORT, | ||
| 3008 | snapshot: createDraftOnlySnapshot(), | ||
| 3009 | configureFixture: (nextFixture) => { | ||
| 3010 | nextFixture.serverConfig = { | ||
| 3011 | ...nextFixture.serverConfig, | ||
| 3012 | availableEditors: ["trae"], | ||
| 3013 | }; | ||
| 3014 | }, | ||
| 3015 | }); | ||
| 3016 | |||
| 3017 | try { | ||
| 3018 | await waitForServerConfigToApply(); | ||
| 3019 | const openButton = await waitForElement( | ||
| 3020 | () => | ||
| 3021 | Array.from(document.querySelectorAll("button")).find( | ||
| 3022 | (button) => button.textContent?.trim() === "Open", | ||
| 3023 | ) as HTMLButtonElement | null, | ||
| 3024 | "Unable to find Open button.", | ||
| 3025 | ); | ||
| 3026 | await vi.waitFor(() => { | ||
| 3027 | expect(openButton.disabled).toBe(false); | ||
| 3028 | }); | ||
| 3029 | openButton.click(); | ||
| 3030 | |||
| 3031 | await vi.waitFor( | ||
| 3032 | () => { | ||
| 3033 | const openRequest = wsRequests.find( | ||
| 3034 | (request) => request._tag === WS_METHODS.shellOpenInEditor, | ||
| 3035 | ); | ||
| 3036 | expect(openRequest).toMatchObject({ | ||
| 3037 | _tag: WS_METHODS.shellOpenInEditor, | ||
| 3038 | cwd: "/repo/project", | ||
| 3039 | editor: "trae", | ||
| 3040 | }); | ||
| 3041 | }, | ||
| 3042 | { timeout: 8_000, interval: 16 }, | ||
| 3043 | ); | ||
| 3044 | } finally { | ||
| 3045 | await mounted.cleanup(); | ||
| 3046 | } | ||
| 3047 | }); | ||
| 3048 | |||
| 3049 | it("shows Kiro in the open picker menu and opens the project cwd with it", async () => { | ||
| 3050 | setDraftThreadWithoutWorktree(); | ||
| 3051 | |||
| 3052 | const mounted = await mountChatView({ | ||
| 3053 | viewport: DEFAULT_VIEWPORT, | ||
| 3054 | snapshot: createDraftOnlySnapshot(), | ||
| 3055 | configureFixture: (nextFixture) => { | ||
| 3056 | nextFixture.serverConfig = { | ||
| 3057 | ...nextFixture.serverConfig, | ||
| 3058 | availableEditors: ["kiro"], | ||
| 3059 | }; | ||
| 3060 | }, | ||
| 3061 | }); | ||
| 3062 | |||
| 3063 | try { | ||
| 3064 | await waitForServerConfigToApply(); | ||
| 3065 | const menuButton = await waitForElement( | ||
| 3066 | () => document.querySelector('button[aria-label="Copy options"]'), | ||
| 3067 | "Unable to find Open picker button.", | ||
| 3068 | ); | ||
| 3069 | (menuButton as HTMLButtonElement).click(); | ||
| 3070 | |||
| 3071 | const kiroItem = await waitForElement( | ||
| 3072 | () => | ||
| 3073 | Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => | ||
| 3074 | item.textContent?.includes("Kiro"), | ||
| 3075 | ) ?? null, | ||
| 3076 | "Unable to find Kiro menu item.", | ||
| 3077 | ); | ||
| 3078 | (kiroItem as HTMLElement).click(); | ||
| 3079 | |||
| 3080 | await vi.waitFor( | ||
| 3081 | () => { | ||
| 3082 | const openRequest = wsRequests.find( | ||
| 3083 | (request) => request._tag === WS_METHODS.shellOpenInEditor, | ||
| 3084 | ); | ||
| 3085 | expect(openRequest).toMatchObject({ | ||
| 3086 | _tag: WS_METHODS.shellOpenInEditor, | ||
| 3087 | cwd: "/repo/project", | ||
| 3088 | editor: "kiro", | ||
| 3089 | }); | ||
| 3090 | }, | ||
| 3091 | { timeout: 8_000, interval: 16 }, | ||
| 3092 | ); | ||
| 3093 | } finally { | ||
| 3094 | await mounted.cleanup(); | ||
| 3095 | } | ||
| 3096 | }); | ||
| 3097 | |||
| 3098 | it("filters the open picker menu and opens VSCodium from the menu", async () => { | ||
| 3099 | setDraftThreadWithoutWorktree(); | ||
| 3100 | |||
| 3101 | const mounted = await mountChatView({ | ||
| 3102 | viewport: DEFAULT_VIEWPORT, | ||
| 3103 | snapshot: createDraftOnlySnapshot(), | ||
| 3104 | configureFixture: (nextFixture) => { | ||
| 3105 | nextFixture.serverConfig = { | ||
| 3106 | ...nextFixture.serverConfig, | ||
| 3107 | availableEditors: ["vscode-insiders", "vscodium"], | ||
| 3108 | }; | ||
| 3109 | }, | ||
| 3110 | }); | ||
| 3111 | |||
| 3112 | try { | ||
| 3113 | await waitForServerConfigToApply(); | ||
| 3114 | const menuButton = await waitForElement( | ||
| 3115 | () => document.querySelector('button[aria-label="Copy options"]'), | ||
| 3116 | "Unable to find Open picker button.", | ||
| 3117 | ); | ||
| 3118 | (menuButton as HTMLButtonElement).click(); | ||
| 3119 | |||
| 3120 | await waitForElement( | ||
| 3121 | () => | ||
| 3122 | Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => | ||
| 3123 | item.textContent?.includes("VS Code Insiders"), | ||
| 3124 | ) ?? null, | ||
| 3125 | "Unable to find VS Code Insiders menu item.", | ||
| 3126 | ); | ||
| 3127 | |||
| 3128 | expect( | ||
| 3129 | Array.from(document.querySelectorAll('[data-slot="menu-item"]')).some((item) => | ||
| 3130 | item.textContent?.includes("Zed"), | ||
| 3131 | ), | ||
| 3132 | ).toBe(false); | ||
| 3133 | |||
| 3134 | const vscodiumItem = await waitForElement( | ||
| 3135 | () => | ||
| 3136 | Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => | ||
| 3137 | item.textContent?.includes("VSCodium"), | ||
| 3138 | ) ?? null, | ||
| 3139 | "Unable to find VSCodium menu item.", | ||
| 3140 | ); | ||
| 3141 | (vscodiumItem as HTMLElement).click(); | ||
| 3142 | |||
| 3143 | await vi.waitFor( | ||
| 3144 | () => { | ||
| 3145 | const openRequest = wsRequests.find( | ||
| 3146 | (request) => request._tag === WS_METHODS.shellOpenInEditor, | ||
| 3147 | ); | ||
| 3148 | expect(openRequest).toMatchObject({ | ||
| 3149 | _tag: WS_METHODS.shellOpenInEditor, | ||
| 3150 | cwd: "/repo/project", | ||
| 3151 | editor: "vscodium", | ||
| 3152 | }); | ||
| 3153 | }, | ||
| 3154 | { timeout: 8_000, interval: 16 }, | ||
| 3155 | ); | ||
| 3156 | } finally { | ||
| 3157 | await mounted.cleanup(); | ||
| 3158 | } | ||
| 3159 | }); | ||
| 3160 | |||
| 3161 | it("falls back to the first installed editor when the stored favorite is unavailable", async () => { | ||
| 3162 | localStorage.setItem("t3code:last-editor", JSON.stringify("vscodium")); | ||
| 3163 | setDraftThreadWithoutWorktree(); | ||
| 3164 | |||
| 3165 | const mounted = await mountChatView({ | ||
| 3166 | viewport: DEFAULT_VIEWPORT, | ||
| 3167 | snapshot: createDraftOnlySnapshot(), | ||
| 3168 | configureFixture: (nextFixture) => { | ||
| 3169 | nextFixture.serverConfig = { | ||
| 3170 | ...nextFixture.serverConfig, | ||
| 3171 | availableEditors: ["vscode-insiders"], | ||
| 3172 | }; | ||
| 3173 | }, | ||
| 3174 | }); | ||
| 3175 | |||
| 3176 | try { | ||
| 3177 | await waitForServerConfigToApply(); | ||
| 3178 | const openButton = await waitForElement( | ||
| 3179 | () => | ||
| 3180 | Array.from(document.querySelectorAll("button")).find( | ||
| 3181 | (button) => button.textContent?.trim() === "Open", | ||
| 3182 | ) as HTMLButtonElement | null, | ||
| 3183 | "Unable to find Open button.", | ||
| 3184 | ); | ||
| 3185 | await vi.waitFor(() => { | ||
| 3186 | expect(openButton.disabled).toBe(false); | ||
| 3187 | }); | ||
| 3188 | openButton.click(); | ||
| 3189 | |||
| 3190 | await vi.waitFor( | ||
| 3191 | () => { | ||
| 3192 | const openRequest = wsRequests.find( | ||
| 3193 | (request) => request._tag === WS_METHODS.shellOpenInEditor, | ||
| 3194 | ); | ||
| 3195 | expect(openRequest).toMatchObject({ | ||
| 3196 | _tag: WS_METHODS.shellOpenInEditor, | ||
| 3197 | cwd: "/repo/project", | ||
| 3198 | editor: "vscode-insiders", | ||
| 3199 | }); | ||
| 3200 | }, | ||
| 3201 | { timeout: 8_000, interval: 16 }, | ||
| 3202 | ); | ||
| 3203 | } finally { | ||
| 3204 | await mounted.cleanup(); | ||
| 3205 | } | ||
| 3206 | }); | ||
| 3207 | |||
| 3208 | it("runs project scripts from local draft threads at the project cwd", async () => { | ||
| 3209 | useComposerDraftStore.setState({ | ||
| 3210 | draftThreadsByThreadKey: { | ||
| 3211 | [THREAD_KEY]: { | ||
| 3212 | threadId: THREAD_ID, | ||
| 3213 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 3214 | projectId: PROJECT_ID, | ||
| 3215 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 3216 | createdAt: NOW_ISO, | ||
| 3217 | runtimeMode: "full-access", | ||
| 3218 | interactionMode: "default", | ||
| 3219 | branch: null, | ||
| 3220 | worktreePath: null, | ||
| 3221 | envMode: "local", | ||
| 3222 | }, | ||
| 3223 | }, | ||
| 3224 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | ||
| 3225 | [PROJECT_DRAFT_KEY]: THREAD_KEY, | ||
| 3226 | }, | ||
| 3227 | }); | ||
| 3228 | |||
| 3229 | const mounted = await mountChatView({ | ||
| 3230 | viewport: DEFAULT_VIEWPORT, | ||
| 3231 | snapshot: withProjectScripts(createDraftOnlySnapshot(), [ | ||
| 3232 | { | ||
| 3233 | id: "lint", | ||
| 3234 | name: "Lint", | ||
| 3235 | command: "bun run lint", | ||
| 3236 | icon: "lint", | ||
| 3237 | runOnWorktreeCreate: false, | ||
| 3238 | }, | ||
| 3239 | ]), | ||
| 3240 | }); | ||
| 3241 | |||
| 3242 | try { | ||
| 3243 | const runButton = await waitForElement( | ||
| 3244 | () => | ||
| 3245 | Array.from(document.querySelectorAll("button")).find( | ||
| 3246 | (button) => button.getAttribute("aria-label") === "Run Lint", | ||
| 3247 | ) as HTMLButtonElement | null, | ||
| 3248 | "Unable to find Run Lint button.", | ||
| 3249 | ); | ||
| 3250 | runButton.click(); | ||
| 3251 | |||
| 3252 | await vi.waitFor( | ||
| 3253 | () => { | ||
| 3254 | const openRequest = wsRequests.find( | ||
| 3255 | (request) => request._tag === WS_METHODS.terminalOpen, | ||
| 3256 | ); | ||
| 3257 | expect(openRequest).toMatchObject({ | ||
| 3258 | _tag: WS_METHODS.terminalOpen, | ||
| 3259 | threadId: THREAD_ID, | ||
| 3260 | cwd: "/repo/project", | ||
| 3261 | env: { | ||
| 3262 | T3CODE_PROJECT_ROOT: "/repo/project", | ||
| 3263 | }, | ||
| 3264 | }); | ||
| 3265 | }, | ||
| 3266 | { timeout: 8_000, interval: 16 }, | ||
| 3267 | ); | ||
| 3268 | |||
| 3269 | await vi.waitFor( | ||
| 3270 | () => { | ||
| 3271 | const writeRequest = wsRequests.find( | ||
| 3272 | (request) => request._tag === WS_METHODS.terminalWrite, | ||
| 3273 | ); | ||
| 3274 | expect(writeRequest).toMatchObject({ | ||
| 3275 | _tag: WS_METHODS.terminalWrite, | ||
| 3276 | threadId: THREAD_ID, | ||
| 3277 | data: "bun run lint\r", | ||
| 3278 | }); | ||
| 3279 | }, | ||
| 3280 | { timeout: 8_000, interval: 16 }, | ||
| 3281 | ); | ||
| 3282 | } finally { | ||
| 3283 | await mounted.cleanup(); | ||
| 3284 | } | ||
| 3285 | }); | ||
| 3286 | |||
| 3287 | it("runs project scripts from worktree draft threads at the worktree cwd", async () => { | ||
| 3288 | useComposerDraftStore.setState({ | ||
| 3289 | draftThreadsByThreadKey: { | ||
| 3290 | [THREAD_KEY]: { | ||
| 3291 | threadId: THREAD_ID, | ||
| 3292 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 3293 | projectId: PROJECT_ID, | ||
| 3294 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 3295 | createdAt: NOW_ISO, | ||
| 3296 | runtimeMode: "full-access", | ||
| 3297 | interactionMode: "default", | ||
| 3298 | branch: "feature/draft", | ||
| 3299 | worktreePath: "/repo/worktrees/feature-draft", | ||
| 3300 | envMode: "worktree", | ||
| 3301 | }, | ||
| 3302 | }, | ||
| 3303 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | ||
| 3304 | [PROJECT_DRAFT_KEY]: THREAD_KEY, | ||
| 3305 | }, | ||
| 3306 | }); | ||
| 3307 | |||
| 3308 | const mounted = await mountChatView({ | ||
| 3309 | viewport: DEFAULT_VIEWPORT, | ||
| 3310 | snapshot: withProjectScripts(createDraftOnlySnapshot(), [ | ||
| 3311 | { | ||
| 3312 | id: "test", | ||
| 3313 | name: "Test", | ||
| 3314 | command: "bun run test", | ||
| 3315 | icon: "test", | ||
| 3316 | runOnWorktreeCreate: false, | ||
| 3317 | }, | ||
| 3318 | ]), | ||
| 3319 | }); | ||
| 3320 | |||
| 3321 | try { | ||
| 3322 | const runButton = await waitForElement( | ||
| 3323 | () => | ||
| 3324 | Array.from(document.querySelectorAll("button")).find( | ||
| 3325 | (button) => button.getAttribute("aria-label") === "Run Test", | ||
| 3326 | ) as HTMLButtonElement | null, | ||
| 3327 | "Unable to find Run Test button.", | ||
| 3328 | ); | ||
| 3329 | runButton.click(); | ||
| 3330 | |||
| 3331 | await vi.waitFor( | ||
| 3332 | () => { | ||
| 3333 | const openRequest = wsRequests.find( | ||
| 3334 | (request) => request._tag === WS_METHODS.terminalOpen, | ||
| 3335 | ); | ||
| 3336 | expect(openRequest).toMatchObject({ | ||
| 3337 | _tag: WS_METHODS.terminalOpen, | ||
| 3338 | threadId: THREAD_ID, | ||
| 3339 | cwd: "/repo/worktrees/feature-draft", | ||
| 3340 | env: { | ||
| 3341 | T3CODE_PROJECT_ROOT: "/repo/project", | ||
| 3342 | T3CODE_WORKTREE_PATH: "/repo/worktrees/feature-draft", | ||
| 3343 | }, | ||
| 3344 | }); | ||
| 3345 | }, | ||
| 3346 | { timeout: 8_000, interval: 16 }, | ||
| 3347 | ); | ||
| 3348 | } finally { | ||
| 3349 | await mounted.cleanup(); | ||
| 3350 | } | ||
| 3351 | }); | ||
| 3352 | |||
| 3353 | it("lets the server own setup after preparing a pull request worktree thread", async () => { | ||
| 3354 | useComposerDraftStore.setState({ | ||
| 3355 | draftThreadsByThreadKey: { | ||
| 3356 | [THREAD_KEY]: { | ||
| 3357 | threadId: THREAD_ID, | ||
| 3358 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 3359 | projectId: PROJECT_ID, | ||
| 3360 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 3361 | createdAt: NOW_ISO, | ||
| 3362 | runtimeMode: "full-access", | ||
| 3363 | interactionMode: "default", | ||
| 3364 | branch: null, | ||
| 3365 | worktreePath: null, | ||
| 3366 | envMode: "local", | ||
| 3367 | }, | ||
| 3368 | }, | ||
| 3369 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | ||
| 3370 | [PROJECT_DRAFT_KEY]: THREAD_KEY, | ||
| 3371 | }, | ||
| 3372 | }); | ||
| 3373 | |||
| 3374 | const mounted = await mountChatView({ | ||
| 3375 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 3376 | snapshot: withProjectScripts(createDraftOnlySnapshot(), [ | ||
| 3377 | { | ||
| 3378 | id: "setup", | ||
| 3379 | name: "Setup", | ||
| 3380 | command: "bun install", | ||
| 3381 | icon: "configure", | ||
| 3382 | runOnWorktreeCreate: true, | ||
| 3383 | }, | ||
| 3384 | ]), | ||
| 3385 | resolveRpc: (body) => { | ||
| 3386 | if (body._tag === WS_METHODS.gitResolvePullRequest) { | ||
| 3387 | return { | ||
| 3388 | pullRequest: { | ||
| 3389 | number: 1359, | ||
| 3390 | title: "Add thread archiving and settings navigation", | ||
| 3391 | url: "https://github.com/pingdotgg/t3code/pull/1359", | ||
| 3392 | baseBranch: "main", | ||
| 3393 | headBranch: "archive-settings-overhaul", | ||
| 3394 | state: "open", | ||
| 3395 | }, | ||
| 3396 | }; | ||
| 3397 | } | ||
| 3398 | if (body._tag === WS_METHODS.gitPreparePullRequestThread) { | ||
| 3399 | return { | ||
| 3400 | pullRequest: { | ||
| 3401 | number: 1359, | ||
| 3402 | title: "Add thread archiving and settings navigation", | ||
| 3403 | url: "https://github.com/pingdotgg/t3code/pull/1359", | ||
| 3404 | baseBranch: "main", | ||
| 3405 | headBranch: "archive-settings-overhaul", | ||
| 3406 | state: "open", | ||
| 3407 | }, | ||
| 3408 | branch: "archive-settings-overhaul", | ||
| 3409 | worktreePath: "/repo/worktrees/pr-1359", | ||
| 3410 | }; | ||
| 3411 | } | ||
| 3412 | return undefined; | ||
| 3413 | }, | ||
| 3414 | }); | ||
| 3415 | |||
| 3416 | try { | ||
| 3417 | const branchButton = await waitForElement( | ||
| 3418 | () => | ||
| 3419 | Array.from(document.querySelectorAll("button")).find( | ||
| 3420 | (button) => button.textContent?.trim() === "main", | ||
| 3421 | ) as HTMLButtonElement | null, | ||
| 3422 | "Unable to find branch selector button.", | ||
| 3423 | ); | ||
| 3424 | branchButton.click(); | ||
| 3425 | |||
| 3426 | const branchInput = await waitForElement( | ||
| 3427 | () => document.querySelector<HTMLInputElement>('input[placeholder="Search refs..."]'), | ||
| 3428 | "Unable to find ref search input.", | ||
| 3429 | ); | ||
| 3430 | branchInput.focus(); | ||
| 3431 | await page.getByPlaceholder("Search refs...").fill("1359"); | ||
| 3432 | |||
| 3433 | const checkoutItem = await waitForElement( | ||
| 3434 | () => | ||
| 3435 | Array.from(document.querySelectorAll("span")).find( | ||
| 3436 | (element) => element.textContent?.trim() === "Checkout pull request", | ||
| 3437 | ) as HTMLSpanElement | null, | ||
| 3438 | "Unable to find checkout pull request option.", | ||
| 3439 | ); | ||
| 3440 | checkoutItem.click(); | ||
| 3441 | |||
| 3442 | const worktreeButton = await waitForElement( | ||
| 3443 | () => | ||
| 3444 | Array.from(document.querySelectorAll("button")).find( | ||
| 3445 | (button) => button.textContent?.trim() === "Worktree", | ||
| 3446 | ) as HTMLButtonElement | null, | ||
| 3447 | "Unable to find Worktree button.", | ||
| 3448 | ); | ||
| 3449 | worktreeButton.click(); | ||
| 3450 | |||
| 3451 | await vi.waitFor( | ||
| 3452 | () => { | ||
| 3453 | const prepareRequest = wsRequests.find( | ||
| 3454 | (request) => request._tag === WS_METHODS.gitPreparePullRequestThread, | ||
| 3455 | ); | ||
| 3456 | expect(prepareRequest).toMatchObject({ | ||
| 3457 | _tag: WS_METHODS.gitPreparePullRequestThread, | ||
| 3458 | cwd: "/repo/project", | ||
| 3459 | reference: "1359", | ||
| 3460 | mode: "worktree", | ||
| 3461 | threadId: THREAD_ID, | ||
| 3462 | }); | ||
| 3463 | }, | ||
| 3464 | { timeout: 8_000, interval: 16 }, | ||
| 3465 | ); | ||
| 3466 | |||
| 3467 | expect( | ||
| 3468 | wsRequests.some( | ||
| 3469 | (request) => | ||
| 3470 | request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", | ||
| 3471 | ), | ||
| 3472 | ).toBe(false); | ||
| 3473 | } finally { | ||
| 3474 | await mounted.cleanup(); | ||
| 3475 | } | ||
| 3476 | }); | ||
| 3477 | |||
| 3478 | it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { | ||
| 3479 | useTerminalUiStateStore.setState({ | ||
| 3480 | terminalUiStateByThreadKey: {}, | ||
| 3481 | }); | ||
| 3482 | useComposerDraftStore.setState({ | ||
| 3483 | draftThreadsByThreadKey: { | ||
| 3484 | [THREAD_KEY]: { | ||
| 3485 | threadId: THREAD_ID, | ||
| 3486 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 3487 | projectId: PROJECT_ID, | ||
| 3488 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 3489 | createdAt: NOW_ISO, | ||
| 3490 | runtimeMode: "full-access", | ||
| 3491 | interactionMode: "default", | ||
| 3492 | branch: "main", | ||
| 3493 | worktreePath: null, | ||
| 3494 | envMode: "worktree", | ||
| 3495 | }, | ||
| 3496 | }, | ||
| 3497 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | ||
| 3498 | [PROJECT_DRAFT_KEY]: THREAD_KEY, | ||
| 3499 | }, | ||
| 3500 | }); | ||
| 3501 | |||
| 3502 | const mounted = await mountChatView({ | ||
| 3503 | viewport: DEFAULT_VIEWPORT, | ||
| 3504 | snapshot: withProjectScripts(createDraftOnlySnapshot(), [ | ||
| 3505 | { | ||
| 3506 | id: "setup", | ||
| 3507 | name: "Setup", | ||
| 3508 | command: "bun install", | ||
| 3509 | icon: "configure", | ||
| 3510 | runOnWorktreeCreate: true, | ||
| 3511 | }, | ||
| 3512 | ]), | ||
| 3513 | resolveRpc: (body) => { | ||
| 3514 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 3515 | return { | ||
| 3516 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 3517 | }; | ||
| 3518 | } | ||
| 3519 | return undefined; | ||
| 3520 | }, | ||
| 3521 | }); | ||
| 3522 | |||
| 3523 | try { | ||
| 3524 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); | ||
| 3525 | await waitForLayout(); | ||
| 3526 | |||
| 3527 | const sendButton = await waitForSendButton(); | ||
| 3528 | expect(sendButton.disabled).toBe(false); | ||
| 3529 | sendButton.click(); | ||
| 3530 | |||
| 3531 | await vi.waitFor( | ||
| 3532 | () => { | ||
| 3533 | const dispatchRequest = wsRequests.find( | ||
| 3534 | (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, | ||
| 3535 | ) as | ||
| 3536 | | { | ||
| 3537 | _tag: string; | ||
| 3538 | type?: string; | ||
| 3539 | bootstrap?: { | ||
| 3540 | createThread?: { projectId?: string }; | ||
| 3541 | prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; | ||
| 3542 | runSetupScript?: boolean; | ||
| 3543 | }; | ||
| 3544 | } | ||
| 3545 | | undefined; | ||
| 3546 | expect(dispatchRequest).toMatchObject({ | ||
| 3547 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, | ||
| 3548 | type: "thread.turn.start", | ||
| 3549 | bootstrap: { | ||
| 3550 | createThread: { | ||
| 3551 | projectId: PROJECT_ID, | ||
| 3552 | }, | ||
| 3553 | prepareWorktree: { | ||
| 3554 | projectCwd: "/repo/project", | ||
| 3555 | baseBranch: "main", | ||
| 3556 | branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), | ||
| 3557 | }, | ||
| 3558 | runSetupScript: true, | ||
| 3559 | }, | ||
| 3560 | }); | ||
| 3561 | }, | ||
| 3562 | { timeout: 8_000, interval: 16 }, | ||
| 3563 | ); | ||
| 3564 | |||
| 3565 | expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe( | ||
| 3566 | false, | ||
| 3567 | ); | ||
| 3568 | expect( | ||
| 3569 | wsRequests.some( | ||
| 3570 | (request) => | ||
| 3571 | request._tag === WS_METHODS.terminalWrite && | ||
| 3572 | request.threadId === THREAD_ID && | ||
| 3573 | request.data === "bun install\r", | ||
| 3574 | ), | ||
| 3575 | ).toBe(false); | ||
| 3576 | } finally { | ||
| 3577 | await mounted.cleanup(); | ||
| 3578 | } | ||
| 3579 | }); | ||
| 3580 | |||
| 3581 | it("keeps custom provider instance ids when bootstrapping a local draft thread", async () => { | ||
| 3582 | setDraftThreadWithoutWorktree(); | ||
| 3583 | const openRouterInstanceId = ProviderInstanceId.make("claude_openrouter"); | ||
| 3584 | const openRouterSelection = createModelSelection(openRouterInstanceId, "openai/gpt-5.5"); | ||
| 3585 | useComposerDraftStore.getState().setModelSelection(THREAD_REF, openRouterSelection); | ||
| 3586 | |||
| 3587 | const mounted = await mountChatView({ | ||
| 3588 | viewport: DEFAULT_VIEWPORT, | ||
| 3589 | snapshot: createDraftOnlySnapshot(), | ||
| 3590 | configureFixture: (nextFixture) => { | ||
| 3591 | nextFixture.serverConfig = { | ||
| 3592 | ...nextFixture.serverConfig, | ||
| 3593 | providers: [ | ||
| 3594 | ...nextFixture.serverConfig.providers, | ||
| 3595 | { | ||
| 3596 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 3597 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 3598 | enabled: true, | ||
| 3599 | installed: true, | ||
| 3600 | version: "2.1.117", | ||
| 3601 | status: "ready", | ||
| 3602 | auth: { status: "authenticated" }, | ||
| 3603 | checkedAt: NOW_ISO, | ||
| 3604 | models: [ | ||
| 3605 | { | ||
| 3606 | slug: "claude-opus-4-7", | ||
| 3607 | name: "Claude Opus 4.7", | ||
| 3608 | isCustom: false, | ||
| 3609 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 3610 | }, | ||
| 3611 | ], | ||
| 3612 | slashCommands: [], | ||
| 3613 | skills: [], | ||
| 3614 | }, | ||
| 3615 | { | ||
| 3616 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 3617 | instanceId: openRouterInstanceId, | ||
| 3618 | displayName: "Claude OpenRouter", | ||
| 3619 | enabled: true, | ||
| 3620 | installed: true, | ||
| 3621 | version: "2.1.117", | ||
| 3622 | status: "ready", | ||
| 3623 | auth: { status: "authenticated" }, | ||
| 3624 | checkedAt: NOW_ISO, | ||
| 3625 | models: [ | ||
| 3626 | { | ||
| 3627 | slug: "claude-opus-4-7", | ||
| 3628 | name: "Claude Opus 4.7", | ||
| 3629 | isCustom: false, | ||
| 3630 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 3631 | }, | ||
| 3632 | ], | ||
| 3633 | slashCommands: [], | ||
| 3634 | skills: [], | ||
| 3635 | }, | ||
| 3636 | ], | ||
| 3637 | settings: { | ||
| 3638 | ...nextFixture.serverConfig.settings, | ||
| 3639 | providerInstances: { | ||
| 3640 | ...nextFixture.serverConfig.settings.providerInstances, | ||
| 3641 | [openRouterInstanceId]: { | ||
| 3642 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 3643 | displayName: "Claude OpenRouter", | ||
| 3644 | config: { customModels: ["openai/gpt-5.5"] }, | ||
| 3645 | }, | ||
| 3646 | }, | ||
| 3647 | }, | ||
| 3648 | }; | ||
| 3649 | }, | ||
| 3650 | resolveRpc: (body) => { | ||
| 3651 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 3652 | return { | ||
| 3653 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 3654 | }; | ||
| 3655 | } | ||
| 3656 | return undefined; | ||
| 3657 | }, | ||
| 3658 | }); | ||
| 3659 | |||
| 3660 | try { | ||
| 3661 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "Hello there"); | ||
| 3662 | await waitForLayout(); | ||
| 3663 | |||
| 3664 | const sendButton = await waitForSendButton(); | ||
| 3665 | expect(sendButton.disabled).toBe(false); | ||
| 3666 | sendButton.click(); | ||
| 3667 | |||
| 3668 | await vi.waitFor( | ||
| 3669 | () => { | ||
| 3670 | const turnStartRequest = wsRequests.find( | ||
| 3671 | (request) => | ||
| 3672 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 3673 | request.type === "thread.turn.start", | ||
| 3674 | ) as | ||
| 3675 | | { | ||
| 3676 | modelSelection?: { instanceId?: string; model?: string }; | ||
| 3677 | bootstrap?: { | ||
| 3678 | createThread?: { | ||
| 3679 | modelSelection?: { instanceId?: string; model?: string }; | ||
| 3680 | }; | ||
| 3681 | }; | ||
| 3682 | } | ||
| 3683 | | undefined; | ||
| 3684 | |||
| 3685 | expect(turnStartRequest?.modelSelection).toMatchObject({ | ||
| 3686 | instanceId: openRouterInstanceId, | ||
| 3687 | model: "openai/gpt-5.5", | ||
| 3688 | }); | ||
| 3689 | expect(turnStartRequest?.bootstrap?.createThread?.modelSelection).toMatchObject({ | ||
| 3690 | instanceId: openRouterInstanceId, | ||
| 3691 | model: "openai/gpt-5.5", | ||
| 3692 | }); | ||
| 3693 | }, | ||
| 3694 | { timeout: 8_000, interval: 16 }, | ||
| 3695 | ); | ||
| 3696 | } finally { | ||
| 3697 | await mounted.cleanup(); | ||
| 3698 | } | ||
| 3699 | }); | ||
| 3700 | |||
| 3701 | it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => { | ||
| 3702 | const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); | ||
| 3703 | const mounted = await mountChatView({ | ||
| 3704 | viewport: DEFAULT_VIEWPORT, | ||
| 3705 | snapshot: { | ||
| 3706 | ...snapshot, | ||
| 3707 | threads: snapshot.threads.map((thread) => | ||
| 3708 | thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread, | ||
| 3709 | ), | ||
| 3710 | }, | ||
| 3711 | resolveRpc: (body) => { | ||
| 3712 | if (body._tag === WS_METHODS.vcsListRefs) { | ||
| 3713 | return { | ||
| 3714 | isRepo: true, | ||
| 3715 | hasPrimaryRemote: true, | ||
| 3716 | nextCursor: null, | ||
| 3717 | totalCount: 1, | ||
| 3718 | refs: [ | ||
| 3719 | { | ||
| 3720 | name: "main", | ||
| 3721 | current: true, | ||
| 3722 | isDefault: true, | ||
| 3723 | worktreePath: null, | ||
| 3724 | }, | ||
| 3725 | ], | ||
| 3726 | }; | ||
| 3727 | } | ||
| 3728 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 3729 | return { | ||
| 3730 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 3731 | }; | ||
| 3732 | } | ||
| 3733 | return undefined; | ||
| 3734 | }, | ||
| 3735 | }); | ||
| 3736 | |||
| 3737 | try { | ||
| 3738 | (await waitForButtonByText("Current checkout")).click(); | ||
| 3739 | await page.getByText("New worktree", { exact: true }).click(); | ||
| 3740 | |||
| 3741 | await vi.waitFor( | ||
| 3742 | () => { | ||
| 3743 | expect(findButtonByText("New worktree")).toBeTruthy(); | ||
| 3744 | }, | ||
| 3745 | { timeout: 8_000, interval: 16 }, | ||
| 3746 | ); | ||
| 3747 | |||
| 3748 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); | ||
| 3749 | await waitForLayout(); | ||
| 3750 | |||
| 3751 | const sendButton = await waitForSendButton(); | ||
| 3752 | expect(sendButton.disabled).toBe(false); | ||
| 3753 | sendButton.click(); | ||
| 3754 | |||
| 3755 | await vi.waitFor( | ||
| 3756 | () => { | ||
| 3757 | const turnStartRequest = wsRequests.find( | ||
| 3758 | (request) => | ||
| 3759 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 3760 | request.type === "thread.turn.start", | ||
| 3761 | ) as | ||
| 3762 | | { | ||
| 3763 | _tag: string; | ||
| 3764 | type?: string; | ||
| 3765 | bootstrap?: { | ||
| 3766 | createThread?: { projectId?: string }; | ||
| 3767 | prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; | ||
| 3768 | runSetupScript?: boolean; | ||
| 3769 | }; | ||
| 3770 | } | ||
| 3771 | | undefined; | ||
| 3772 | |||
| 3773 | expect(turnStartRequest).toMatchObject({ | ||
| 3774 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, | ||
| 3775 | type: "thread.turn.start", | ||
| 3776 | bootstrap: { | ||
| 3777 | prepareWorktree: { | ||
| 3778 | projectCwd: "/repo/project", | ||
| 3779 | baseBranch: "main", | ||
| 3780 | branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), | ||
| 3781 | }, | ||
| 3782 | runSetupScript: true, | ||
| 3783 | }, | ||
| 3784 | }); | ||
| 3785 | expect(turnStartRequest?.bootstrap?.createThread).toBeUndefined(); | ||
| 3786 | }, | ||
| 3787 | { timeout: 8_000, interval: 16 }, | ||
| 3788 | ); | ||
| 3789 | } finally { | ||
| 3790 | await mounted.cleanup(); | ||
| 3791 | } | ||
| 3792 | }); | ||
| 3793 | |||
| 3794 | it("updates the selected worktree base branch on empty server threads", async () => { | ||
| 3795 | const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); | ||
| 3796 | const mounted = await mountChatView({ | ||
| 3797 | viewport: DEFAULT_VIEWPORT, | ||
| 3798 | snapshot: { | ||
| 3799 | ...snapshot, | ||
| 3800 | threads: snapshot.threads.map((thread) => | ||
| 3801 | thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread, | ||
| 3802 | ), | ||
| 3803 | }, | ||
| 3804 | resolveRpc: (body) => { | ||
| 3805 | if (body._tag === WS_METHODS.vcsListRefs) { | ||
| 3806 | return { | ||
| 3807 | isRepo: true, | ||
| 3808 | hasPrimaryRemote: true, | ||
| 3809 | nextCursor: null, | ||
| 3810 | totalCount: 2, | ||
| 3811 | refs: [ | ||
| 3812 | { | ||
| 3813 | name: "main", | ||
| 3814 | current: true, | ||
| 3815 | isDefault: true, | ||
| 3816 | worktreePath: null, | ||
| 3817 | }, | ||
| 3818 | { | ||
| 3819 | name: "release/next", | ||
| 3820 | current: false, | ||
| 3821 | isDefault: false, | ||
| 3822 | worktreePath: null, | ||
| 3823 | }, | ||
| 3824 | ], | ||
| 3825 | }; | ||
| 3826 | } | ||
| 3827 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 3828 | return { | ||
| 3829 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 3830 | }; | ||
| 3831 | } | ||
| 3832 | return undefined; | ||
| 3833 | }, | ||
| 3834 | }); | ||
| 3835 | |||
| 3836 | try { | ||
| 3837 | (await waitForButtonByText("Current checkout")).click(); | ||
| 3838 | await page.getByText("New worktree", { exact: true }).click(); | ||
| 3839 | await page.getByText("From main", { exact: true }).click(); | ||
| 3840 | await page.getByText("release/next", { exact: true }).click(); | ||
| 3841 | |||
| 3842 | await vi.waitFor( | ||
| 3843 | () => { | ||
| 3844 | expect(findButtonByText("From release/next")).toBeTruthy(); | ||
| 3845 | }, | ||
| 3846 | { timeout: 8_000, interval: 16 }, | ||
| 3847 | ); | ||
| 3848 | |||
| 3849 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); | ||
| 3850 | await waitForLayout(); | ||
| 3851 | |||
| 3852 | const sendButton = await waitForSendButton(); | ||
| 3853 | expect(sendButton.disabled).toBe(false); | ||
| 3854 | sendButton.click(); | ||
| 3855 | |||
| 3856 | await vi.waitFor( | ||
| 3857 | () => { | ||
| 3858 | const turnStartRequest = wsRequests.find( | ||
| 3859 | (request) => | ||
| 3860 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 3861 | request.type === "thread.turn.start", | ||
| 3862 | ) as | ||
| 3863 | | { | ||
| 3864 | _tag: string; | ||
| 3865 | type?: string; | ||
| 3866 | bootstrap?: { | ||
| 3867 | prepareWorktree?: { baseBranch?: string }; | ||
| 3868 | }; | ||
| 3869 | } | ||
| 3870 | | undefined; | ||
| 3871 | |||
| 3872 | expect(turnStartRequest?.bootstrap?.prepareWorktree?.baseBranch).toBe("release/next"); | ||
| 3873 | }, | ||
| 3874 | { timeout: 8_000, interval: 16 }, | ||
| 3875 | ); | ||
| 3876 | } finally { | ||
| 3877 | await mounted.cleanup(); | ||
| 3878 | } | ||
| 3879 | }); | ||
| 3880 | |||
| 3881 | it("clears pending worktree overrides when switching empty server threads", async () => { | ||
| 3882 | const secondThreadId = "thread-browser-test-second" as ThreadId; | ||
| 3883 | const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); | ||
| 3884 | const snapshotWithSecondThread = addThreadToSnapshot(snapshot, secondThreadId); | ||
| 3885 | const snapshotWithTwoThreads = { | ||
| 3886 | ...snapshotWithSecondThread, | ||
| 3887 | threads: snapshotWithSecondThread.threads.map((thread) => { | ||
| 3888 | if (thread.id === THREAD_ID) { | ||
| 3889 | return Object.assign({}, thread, { session: null, title: "Thread alpha" }); | ||
| 3890 | } | ||
| 3891 | if (thread.id === secondThreadId) { | ||
| 3892 | return Object.assign({}, thread, { session: null, title: "Thread beta" }); | ||
| 3893 | } | ||
| 3894 | return thread; | ||
| 3895 | }), | ||
| 3896 | }; | ||
| 3897 | const mounted = await mountChatView({ | ||
| 3898 | viewport: DEFAULT_VIEWPORT, | ||
| 3899 | snapshot: snapshotWithTwoThreads, | ||
| 3900 | resolveRpc: (body) => { | ||
| 3901 | if (body._tag === WS_METHODS.vcsListRefs) { | ||
| 3902 | return { | ||
| 3903 | isRepo: true, | ||
| 3904 | hasPrimaryRemote: true, | ||
| 3905 | nextCursor: null, | ||
| 3906 | totalCount: 2, | ||
| 3907 | refs: [ | ||
| 3908 | { | ||
| 3909 | name: "main", | ||
| 3910 | current: true, | ||
| 3911 | isDefault: true, | ||
| 3912 | worktreePath: null, | ||
| 3913 | }, | ||
| 3914 | { | ||
| 3915 | name: "release/next", | ||
| 3916 | current: false, | ||
| 3917 | isDefault: false, | ||
| 3918 | worktreePath: null, | ||
| 3919 | }, | ||
| 3920 | ], | ||
| 3921 | }; | ||
| 3922 | } | ||
| 3923 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 3924 | return { | ||
| 3925 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 3926 | }; | ||
| 3927 | } | ||
| 3928 | return undefined; | ||
| 3929 | }, | ||
| 3930 | }); | ||
| 3931 | |||
| 3932 | try { | ||
| 3933 | (await waitForButtonByText("Current checkout")).click(); | ||
| 3934 | await page.getByText("New worktree", { exact: true }).click(); | ||
| 3935 | await page.getByText("From main", { exact: true }).click(); | ||
| 3936 | await page.getByText("release/next", { exact: true }).click(); | ||
| 3937 | |||
| 3938 | await vi.waitFor( | ||
| 3939 | () => { | ||
| 3940 | expect(findButtonByText("From release/next")).toBeTruthy(); | ||
| 3941 | }, | ||
| 3942 | { timeout: 8_000, interval: 16 }, | ||
| 3943 | ); | ||
| 3944 | |||
| 3945 | await mounted.router.navigate({ | ||
| 3946 | to: "/$environmentId/$threadId", | ||
| 3947 | params: { | ||
| 3948 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 3949 | threadId: secondThreadId, | ||
| 3950 | }, | ||
| 3951 | }); | ||
| 3952 | |||
| 3953 | await waitForURL( | ||
| 3954 | mounted.router, | ||
| 3955 | (path) => path === serverThreadPath(secondThreadId), | ||
| 3956 | "Route should switch to the second empty server thread.", | ||
| 3957 | ); | ||
| 3958 | |||
| 3959 | await vi.waitFor( | ||
| 3960 | () => { | ||
| 3961 | expect(findButtonByText("Current checkout")).toBeTruthy(); | ||
| 3962 | expect(findButtonByText("From release/next")).toBeNull(); | ||
| 3963 | }, | ||
| 3964 | { timeout: 8_000, interval: 16 }, | ||
| 3965 | ); | ||
| 3966 | |||
| 3967 | (await waitForButtonByText("Current checkout")).click(); | ||
| 3968 | await page.getByText("New worktree", { exact: true }).click(); | ||
| 3969 | |||
| 3970 | await vi.waitFor( | ||
| 3971 | () => { | ||
| 3972 | expect(findButtonByText("From main")).toBeTruthy(); | ||
| 3973 | expect(findButtonByText("From release/next")).toBeNull(); | ||
| 3974 | }, | ||
| 3975 | { timeout: 8_000, interval: 16 }, | ||
| 3976 | ); | ||
| 3977 | } finally { | ||
| 3978 | await mounted.cleanup(); | ||
| 3979 | } | ||
| 3980 | }); | ||
| 3981 | |||
| 3982 | it("shows the send state once bootstrap dispatch is in flight", async () => { | ||
| 3983 | useTerminalUiStateStore.setState({ | ||
| 3984 | terminalUiStateByThreadKey: {}, | ||
| 3985 | }); | ||
| 3986 | useComposerDraftStore.setState({ | ||
| 3987 | draftThreadsByThreadKey: { | ||
| 3988 | [THREAD_KEY]: { | ||
| 3989 | threadId: THREAD_ID, | ||
| 3990 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 3991 | projectId: PROJECT_ID, | ||
| 3992 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 3993 | createdAt: NOW_ISO, | ||
| 3994 | runtimeMode: "full-access", | ||
| 3995 | interactionMode: "default", | ||
| 3996 | branch: "main", | ||
| 3997 | worktreePath: null, | ||
| 3998 | envMode: "worktree", | ||
| 3999 | }, | ||
| 4000 | }, | ||
| 4001 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | ||
| 4002 | [PROJECT_DRAFT_KEY]: THREAD_KEY, | ||
| 4003 | }, | ||
| 4004 | }); | ||
| 4005 | |||
| 4006 | let resolveDispatch!: (value: { sequence: number }) => void; | ||
| 4007 | const dispatchPromise = new Promise<{ sequence: number }>((resolve) => { | ||
| 4008 | resolveDispatch = resolve; | ||
| 4009 | }); | ||
| 4010 | |||
| 4011 | const mounted = await mountChatView({ | ||
| 4012 | viewport: DEFAULT_VIEWPORT, | ||
| 4013 | snapshot: withProjectScripts(createDraftOnlySnapshot(), [ | ||
| 4014 | { | ||
| 4015 | id: "setup", | ||
| 4016 | name: "Setup", | ||
| 4017 | command: "bun install", | ||
| 4018 | icon: "configure", | ||
| 4019 | runOnWorktreeCreate: true, | ||
| 4020 | }, | ||
| 4021 | ]), | ||
| 4022 | resolveRpc: (body) => { | ||
| 4023 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 4024 | return dispatchPromise; | ||
| 4025 | } | ||
| 4026 | return undefined; | ||
| 4027 | }, | ||
| 4028 | }); | ||
| 4029 | |||
| 4030 | try { | ||
| 4031 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); | ||
| 4032 | await waitForLayout(); | ||
| 4033 | |||
| 4034 | const sendButton = await waitForSendButton(); | ||
| 4035 | expect(sendButton.disabled).toBe(false); | ||
| 4036 | sendButton.click(); | ||
| 4037 | |||
| 4038 | await vi.waitFor( | ||
| 4039 | () => { | ||
| 4040 | expect( | ||
| 4041 | wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand), | ||
| 4042 | ).toBe(true); | ||
| 4043 | expect(document.querySelector('button[aria-label="Sending"]')).toBeTruthy(); | ||
| 4044 | expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull(); | ||
| 4045 | }, | ||
| 4046 | { timeout: 8_000, interval: 16 }, | ||
| 4047 | ); | ||
| 4048 | } finally { | ||
| 4049 | resolveDispatch({ sequence: fixture.snapshot.snapshotSequence + 1 }); | ||
| 4050 | await mounted.cleanup(); | ||
| 4051 | } | ||
| 4052 | }); | ||
| 4053 | |||
| 4054 | it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { | ||
| 4055 | const mounted = await mountChatView({ | ||
| 4056 | viewport: DEFAULT_VIEWPORT, | ||
| 4057 | snapshot: createSnapshotForTargetUser({ | ||
| 4058 | targetMessageId: "msg-user-target-hotkey" as MessageId, | ||
| 4059 | targetText: "hotkey target", | ||
| 4060 | }), | ||
| 4061 | }); | ||
| 4062 | |||
| 4063 | try { | ||
| 4064 | const initialModeButton = await waitForInteractionModeButton("Build"); | ||
| 4065 | expect(initialModeButton.getAttribute("aria-label")).toContain("enter plan mode"); | ||
| 4066 | expect(initialModeButton.hasAttribute("title")).toBe(false); | ||
| 4067 | |||
| 4068 | window.dispatchEvent( | ||
| 4069 | new KeyboardEvent("keydown", { | ||
| 4070 | key: "Tab", | ||
| 4071 | shiftKey: true, | ||
| 4072 | bubbles: true, | ||
| 4073 | cancelable: true, | ||
| 4074 | }), | ||
| 4075 | ); | ||
| 4076 | await waitForLayout(); | ||
| 4077 | |||
| 4078 | expect((await waitForInteractionModeButton("Build")).getAttribute("aria-label")).toContain( | ||
| 4079 | "enter plan mode", | ||
| 4080 | ); | ||
| 4081 | |||
| 4082 | const composerEditor = await waitForComposerEditor(); | ||
| 4083 | composerEditor.focus(); | ||
| 4084 | composerEditor.dispatchEvent( | ||
| 4085 | new KeyboardEvent("keydown", { | ||
| 4086 | key: "Tab", | ||
| 4087 | shiftKey: true, | ||
| 4088 | bubbles: true, | ||
| 4089 | cancelable: true, | ||
| 4090 | }), | ||
| 4091 | ); | ||
| 4092 | |||
| 4093 | await vi.waitFor( | ||
| 4094 | async () => { | ||
| 4095 | expect((await waitForInteractionModeButton("Plan")).getAttribute("aria-label")).toContain( | ||
| 4096 | "return to normal build mode", | ||
| 4097 | ); | ||
| 4098 | }, | ||
| 4099 | { timeout: 8_000, interval: 16 }, | ||
| 4100 | ); | ||
| 4101 | |||
| 4102 | composerEditor.dispatchEvent( | ||
| 4103 | new KeyboardEvent("keydown", { | ||
| 4104 | key: "Tab", | ||
| 4105 | shiftKey: true, | ||
| 4106 | bubbles: true, | ||
| 4107 | cancelable: true, | ||
| 4108 | }), | ||
| 4109 | ); | ||
| 4110 | |||
| 4111 | await vi.waitFor( | ||
| 4112 | async () => { | ||
| 4113 | expect( | ||
| 4114 | (await waitForInteractionModeButton("Build")).getAttribute("aria-label"), | ||
| 4115 | ).toContain("enter plan mode"); | ||
| 4116 | }, | ||
| 4117 | { timeout: 8_000, interval: 16 }, | ||
| 4118 | ); | ||
| 4119 | } finally { | ||
| 4120 | await mounted.cleanup(); | ||
| 4121 | } | ||
| 4122 | }); | ||
| 4123 | |||
| 4124 | it("uses the configured diff toggle binding without discarding its surface", async () => { | ||
| 4125 | const mounted = await mountChatView({ | ||
| 4126 | viewport: DEFAULT_VIEWPORT, | ||
| 4127 | snapshot: createSnapshotForTargetUser({ | ||
| 4128 | targetMessageId: "msg-user-target-diff-hotkey" as MessageId, | ||
| 4129 | targetText: "diff hotkey target", | ||
| 4130 | }), | ||
| 4131 | configureFixture: (nextFixture) => { | ||
| 4132 | nextFixture.serverConfig = { | ||
| 4133 | ...nextFixture.serverConfig, | ||
| 4134 | keybindings: [ | ||
| 4135 | { | ||
| 4136 | command: "diff.toggle", | ||
| 4137 | shortcut: { | ||
| 4138 | key: "g", | ||
| 4139 | metaKey: false, | ||
| 4140 | ctrlKey: false, | ||
| 4141 | shiftKey: true, | ||
| 4142 | altKey: true, | ||
| 4143 | modKey: false, | ||
| 4144 | }, | ||
| 4145 | whenAst: { | ||
| 4146 | type: "not", | ||
| 4147 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 4148 | }, | ||
| 4149 | }, | ||
| 4150 | ], | ||
| 4151 | }; | ||
| 4152 | }, | ||
| 4153 | }); | ||
| 4154 | |||
| 4155 | try { | ||
| 4156 | await waitForServerConfigToApply(); | ||
| 4157 | dispatchConfiguredDiffToggleShortcut(); | ||
| 4158 | await vi.waitFor(() => { | ||
| 4159 | expect( | ||
| 4160 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), | ||
| 4161 | ).toEqual({ | ||
| 4162 | isOpen: true, | ||
| 4163 | activeSurfaceId: "diff", | ||
| 4164 | surfaces: [{ id: "diff", kind: "diff" }], | ||
| 4165 | }); | ||
| 4166 | }); | ||
| 4167 | |||
| 4168 | dispatchConfiguredDiffToggleShortcut(); | ||
| 4169 | await vi.waitFor(() => { | ||
| 4170 | expect( | ||
| 4171 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), | ||
| 4172 | ).toEqual({ | ||
| 4173 | isOpen: false, | ||
| 4174 | activeSurfaceId: "diff", | ||
| 4175 | surfaces: [{ id: "diff", kind: "diff" }], | ||
| 4176 | }); | ||
| 4177 | }); | ||
| 4178 | |||
| 4179 | dispatchConfiguredDiffToggleShortcut(); | ||
| 4180 | await vi.waitFor(() => { | ||
| 4181 | expect( | ||
| 4182 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), | ||
| 4183 | ).toEqual({ | ||
| 4184 | isOpen: true, | ||
| 4185 | activeSurfaceId: "diff", | ||
| 4186 | surfaces: [{ id: "diff", kind: "diff" }], | ||
| 4187 | }); | ||
| 4188 | }); | ||
| 4189 | } finally { | ||
| 4190 | await mounted.cleanup(); | ||
| 4191 | } | ||
| 4192 | }); | ||
| 4193 | |||
| 4194 | it("focuses the composer and inserts printable text typed from the page background", async () => { | ||
| 4195 | const mounted = await mountChatView({ | ||
| 4196 | viewport: DEFAULT_VIEWPORT, | ||
| 4197 | snapshot: createSnapshotForTargetUser({ | ||
| 4198 | targetMessageId: "msg-user-target-type-to-focus" as MessageId, | ||
| 4199 | targetText: "type-to-focus target", | ||
| 4200 | }), | ||
| 4201 | }); | ||
| 4202 | |||
| 4203 | const backgroundTarget = document.createElement("div"); | ||
| 4204 | backgroundTarget.tabIndex = -1; | ||
| 4205 | document.body.append(backgroundTarget); | ||
| 4206 | |||
| 4207 | try { | ||
| 4208 | const composerEditor = await waitForComposerEditor(); | ||
| 4209 | backgroundTarget.focus(); | ||
| 4210 | expect(document.activeElement).not.toBe(composerEditor); | ||
| 4211 | |||
| 4212 | const event = new KeyboardEvent("keydown", { | ||
| 4213 | key: "h", | ||
| 4214 | bubbles: true, | ||
| 4215 | cancelable: true, | ||
| 4216 | }); | ||
| 4217 | backgroundTarget.dispatchEvent(event); | ||
| 4218 | |||
| 4219 | await waitForComposerText("h"); | ||
| 4220 | expect(event.defaultPrevented).toBe(true); | ||
| 4221 | expect(document.activeElement).toBe(composerEditor); | ||
| 4222 | |||
| 4223 | window.dispatchEvent( | ||
| 4224 | new KeyboardEvent("keydown", { | ||
| 4225 | key: "i", | ||
| 4226 | bubbles: true, | ||
| 4227 | cancelable: true, | ||
| 4228 | }), | ||
| 4229 | ); | ||
| 4230 | |||
| 4231 | await waitForComposerText("hi"); | ||
| 4232 | } finally { | ||
| 4233 | backgroundTarget.remove(); | ||
| 4234 | await mounted.cleanup(); | ||
| 4235 | } | ||
| 4236 | }); | ||
| 4237 | |||
| 4238 | it("does not steal printable keys from editable targets or shortcut modifiers", async () => { | ||
| 4239 | const mounted = await mountChatView({ | ||
| 4240 | viewport: DEFAULT_VIEWPORT, | ||
| 4241 | snapshot: createSnapshotForTargetUser({ | ||
| 4242 | targetMessageId: "msg-user-target-type-to-focus-guards" as MessageId, | ||
| 4243 | targetText: "type-to-focus guards target", | ||
| 4244 | }), | ||
| 4245 | }); | ||
| 4246 | const input = document.createElement("input"); | ||
| 4247 | document.body.append(input); | ||
| 4248 | |||
| 4249 | try { | ||
| 4250 | input.focus(); | ||
| 4251 | input.dispatchEvent( | ||
| 4252 | new KeyboardEvent("keydown", { | ||
| 4253 | key: "x", | ||
| 4254 | bubbles: true, | ||
| 4255 | cancelable: true, | ||
| 4256 | }), | ||
| 4257 | ); | ||
| 4258 | await waitForLayout(); | ||
| 4259 | expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); | ||
| 4260 | |||
| 4261 | window.dispatchEvent( | ||
| 4262 | new KeyboardEvent("keydown", { | ||
| 4263 | key: "k", | ||
| 4264 | metaKey: true, | ||
| 4265 | bubbles: true, | ||
| 4266 | cancelable: true, | ||
| 4267 | }), | ||
| 4268 | ); | ||
| 4269 | await waitForLayout(); | ||
| 4270 | expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); | ||
| 4271 | } finally { | ||
| 4272 | input.remove(); | ||
| 4273 | await mounted.cleanup(); | ||
| 4274 | } | ||
| 4275 | }); | ||
| 4276 | |||
| 4277 | it("uses the active draft route session when changing the base branch", async () => { | ||
| 4278 | const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); | ||
| 4279 | const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); | ||
| 4280 | |||
| 4281 | useComposerDraftStore.setState({ | ||
| 4282 | draftThreadsByThreadKey: { | ||
| 4283 | [staleDraftId]: { | ||
| 4284 | threadId: THREAD_ID, | ||
| 4285 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 4286 | projectId: PROJECT_ID, | ||
| 4287 | logicalProjectKey: `${PROJECT_DRAFT_KEY}:stale`, | ||
| 4288 | createdAt: NOW_ISO, | ||
| 4289 | runtimeMode: "full-access", | ||
| 4290 | interactionMode: "default", | ||
| 4291 | branch: "main", | ||
| 4292 | worktreePath: null, | ||
| 4293 | envMode: "worktree", | ||
| 4294 | }, | ||
| 4295 | [activeDraftId]: { | ||
| 4296 | threadId: THREAD_ID, | ||
| 4297 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 4298 | projectId: PROJECT_ID, | ||
| 4299 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 4300 | createdAt: NOW_ISO, | ||
| 4301 | runtimeMode: "full-access", | ||
| 4302 | interactionMode: "default", | ||
| 4303 | branch: "main", | ||
| 4304 | worktreePath: null, | ||
| 4305 | envMode: "worktree", | ||
| 4306 | }, | ||
| 4307 | }, | ||
| 4308 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | ||
| 4309 | [`${PROJECT_DRAFT_KEY}:stale`]: staleDraftId, | ||
| 4310 | [PROJECT_DRAFT_KEY]: activeDraftId, | ||
| 4311 | }, | ||
| 4312 | }); | ||
| 4313 | |||
| 4314 | const mounted = await mountChatView({ | ||
| 4315 | viewport: DEFAULT_VIEWPORT, | ||
| 4316 | snapshot: createDraftOnlySnapshot(), | ||
| 4317 | initialPath: `/draft/${activeDraftId}`, | ||
| 4318 | resolveRpc: (body) => { | ||
| 4319 | if (body._tag === WS_METHODS.vcsListRefs) { | ||
| 4320 | return { | ||
| 4321 | isRepo: true, | ||
| 4322 | hasPrimaryRemote: true, | ||
| 4323 | nextCursor: null, | ||
| 4324 | totalCount: 2, | ||
| 4325 | refs: [ | ||
| 4326 | { | ||
| 4327 | name: "main", | ||
| 4328 | current: true, | ||
| 4329 | isDefault: true, | ||
| 4330 | worktreePath: null, | ||
| 4331 | }, | ||
| 4332 | { | ||
| 4333 | name: "release/next", | ||
| 4334 | current: false, | ||
| 4335 | isDefault: false, | ||
| 4336 | worktreePath: null, | ||
| 4337 | }, | ||
| 4338 | ], | ||
| 4339 | }; | ||
| 4340 | } | ||
| 4341 | return undefined; | ||
| 4342 | }, | ||
| 4343 | }); | ||
| 4344 | |||
| 4345 | try { | ||
| 4346 | const branchButton = await waitForElement( | ||
| 4347 | () => | ||
| 4348 | Array.from(document.querySelectorAll("button")).find( | ||
| 4349 | (button) => button.textContent?.trim() === "From main", | ||
| 4350 | ) as HTMLButtonElement | null, | ||
| 4351 | 'Unable to find branch selector button with "From main".', | ||
| 4352 | ); | ||
| 4353 | branchButton.click(); | ||
| 4354 | |||
| 4355 | const branchOption = await waitForElement( | ||
| 4356 | () => | ||
| 4357 | Array.from(document.querySelectorAll("span")).find( | ||
| 4358 | (element) => element.textContent?.trim() === "release/next", | ||
| 4359 | ) as HTMLSpanElement | null, | ||
| 4360 | 'Unable to find the "release/next" branch option.', | ||
| 4361 | ); | ||
| 4362 | branchOption.click(); | ||
| 4363 | |||
| 4364 | await vi.waitFor( | ||
| 4365 | () => { | ||
| 4366 | expect(useComposerDraftStore.getState().getDraftSession(activeDraftId)?.branch).toBe( | ||
| 4367 | "release/next", | ||
| 4368 | ); | ||
| 4369 | expect(useComposerDraftStore.getState().getDraftSession(staleDraftId)?.branch).toBe( | ||
| 4370 | "main", | ||
| 4371 | ); | ||
| 4372 | }, | ||
| 4373 | { timeout: 8_000, interval: 16 }, | ||
| 4374 | ); | ||
| 4375 | |||
| 4376 | await vi.waitFor( | ||
| 4377 | () => { | ||
| 4378 | const updatedButton = Array.from(document.querySelectorAll("button")).find((button) => | ||
| 4379 | button.textContent?.trim().includes("From release/next"), | ||
| 4380 | ); | ||
| 4381 | expect(updatedButton).toBeTruthy(); | ||
| 4382 | }, | ||
| 4383 | { timeout: 8_000, interval: 16 }, | ||
| 4384 | ); | ||
| 4385 | } finally { | ||
| 4386 | await mounted.cleanup(); | ||
| 4387 | } | ||
| 4388 | }); | ||
| 4389 | |||
| 4390 | it("keeps the new worktree branch picker anchored at the top when opening with a preselected branch", async () => { | ||
| 4391 | const draftId = DraftId.make("draft-branch-picker-scroll-regression"); | ||
| 4392 | const branches = [ | ||
| 4393 | { | ||
| 4394 | name: "feature/current", | ||
| 4395 | current: true, | ||
| 4396 | isDefault: false, | ||
| 4397 | worktreePath: null, | ||
| 4398 | }, | ||
| 4399 | { | ||
| 4400 | name: "main", | ||
| 4401 | current: false, | ||
| 4402 | isDefault: true, | ||
| 4403 | worktreePath: null, | ||
| 4404 | }, | ||
| 4405 | ...Array.from({ length: 48 }, (_, index) => ({ | ||
| 4406 | name: `feature/${String(index).padStart(2, "0")}`, | ||
| 4407 | current: false, | ||
| 4408 | isDefault: false, | ||
| 4409 | worktreePath: null, | ||
| 4410 | })), | ||
| 4411 | { | ||
| 4412 | name: "feature/selected", | ||
| 4413 | current: false, | ||
| 4414 | isDefault: false, | ||
| 4415 | worktreePath: null, | ||
| 4416 | }, | ||
| 4417 | ]; | ||
| 4418 | |||
| 4419 | useComposerDraftStore.setState({ | ||
| 4420 | draftThreadsByThreadKey: { | ||
| 4421 | [draftId]: { | ||
| 4422 | threadId: THREAD_ID, | ||
| 4423 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 4424 | projectId: PROJECT_ID, | ||
| 4425 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 4426 | createdAt: NOW_ISO, | ||
| 4427 | runtimeMode: "full-access", | ||
| 4428 | interactionMode: "default", | ||
| 4429 | branch: "feature/selected", | ||
| 4430 | worktreePath: null, | ||
| 4431 | envMode: "worktree", | ||
| 4432 | }, | ||
| 4433 | }, | ||
| 4434 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | ||
| 4435 | [PROJECT_DRAFT_KEY]: draftId, | ||
| 4436 | }, | ||
| 4437 | }); | ||
| 4438 | |||
| 4439 | const mounted = await mountChatView({ | ||
| 4440 | viewport: DEFAULT_VIEWPORT, | ||
| 4441 | snapshot: createDraftOnlySnapshot(), | ||
| 4442 | initialPath: `/draft/${draftId}`, | ||
| 4443 | resolveRpc: (body) => { | ||
| 4444 | if (body._tag === WS_METHODS.vcsListRefs) { | ||
| 4445 | return { | ||
| 4446 | isRepo: true, | ||
| 4447 | hasPrimaryRemote: true, | ||
| 4448 | nextCursor: null, | ||
| 4449 | totalCount: branches.length, | ||
| 4450 | refs: branches, | ||
| 4451 | }; | ||
| 4452 | } | ||
| 4453 | return undefined; | ||
| 4454 | }, | ||
| 4455 | }); | ||
| 4456 | |||
| 4457 | try { | ||
| 4458 | const branchButton = await waitForElement( | ||
| 4459 | () => | ||
| 4460 | Array.from(document.querySelectorAll("button")).find( | ||
| 4461 | (button) => button.textContent?.trim() === "From feature/selected", | ||
| 4462 | ) as HTMLButtonElement | null, | ||
| 4463 | 'Unable to find branch selector button with "From feature/selected".', | ||
| 4464 | ); | ||
| 4465 | branchButton.click(); | ||
| 4466 | |||
| 4467 | await waitForElement( | ||
| 4468 | () => document.querySelector<HTMLInputElement>('input[placeholder="Search refs..."]'), | ||
| 4469 | "Unable to find ref search input.", | ||
| 4470 | ); | ||
| 4471 | |||
| 4472 | const popup = await waitForElement( | ||
| 4473 | () => document.querySelector<HTMLElement>('[data-slot="combobox-popup"]'), | ||
| 4474 | "Unable to find the branch picker popup.", | ||
| 4475 | ); | ||
| 4476 | |||
| 4477 | await vi.waitFor( | ||
| 4478 | () => { | ||
| 4479 | const popupSpans = Array.from(popup.querySelectorAll("span")); | ||
| 4480 | expect( | ||
| 4481 | popupSpans.some((element) => element.textContent?.trim() === "feature/current"), | ||
| 4482 | ).toBe(true); | ||
| 4483 | expect(popupSpans.some((element) => element.textContent?.trim() === "main")).toBe(true); | ||
| 4484 | }, | ||
| 4485 | { timeout: 8_000, interval: 16 }, | ||
| 4486 | ); | ||
| 4487 | } finally { | ||
| 4488 | await mounted.cleanup(); | ||
| 4489 | } | ||
| 4490 | }); | ||
| 4491 | |||
| 4492 | it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { | ||
| 4493 | const mounted = await mountChatView({ | ||
| 4494 | viewport: DEFAULT_VIEWPORT, | ||
| 4495 | snapshot: createSnapshotForTargetUser({ | ||
| 4496 | targetMessageId: "msg-user-surround-basic" as MessageId, | ||
| 4497 | targetText: "surround basic", | ||
| 4498 | }), | ||
| 4499 | }); | ||
| 4500 | |||
| 4501 | try { | ||
| 4502 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); | ||
| 4503 | await waitForComposerText("selected"); | ||
| 4504 | await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length }); | ||
| 4505 | await pressComposerKey("("); | ||
| 4506 | await waitForComposerText("(selected)"); | ||
| 4507 | |||
| 4508 | await pressComposerKey("["); | ||
| 4509 | await waitForComposerText("([selected])"); | ||
| 4510 | } finally { | ||
| 4511 | await mounted.cleanup(); | ||
| 4512 | } | ||
| 4513 | }); | ||
| 4514 | |||
| 4515 | it("leaves collapsed-caret typing unchanged for surround symbols", async () => { | ||
| 4516 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); | ||
| 4517 | |||
| 4518 | const mounted = await mountChatView({ | ||
| 4519 | viewport: DEFAULT_VIEWPORT, | ||
| 4520 | snapshot: createSnapshotForTargetUser({ | ||
| 4521 | targetMessageId: "msg-user-surround-collapsed" as MessageId, | ||
| 4522 | targetText: "surround collapsed", | ||
| 4523 | }), | ||
| 4524 | }); | ||
| 4525 | |||
| 4526 | try { | ||
| 4527 | await waitForComposerText("selected"); | ||
| 4528 | await setComposerSelectionByTextOffsets({ | ||
| 4529 | start: "selected".length, | ||
| 4530 | end: "selected".length, | ||
| 4531 | }); | ||
| 4532 | await pressComposerKey("("); | ||
| 4533 | await waitForComposerText("selected("); | ||
| 4534 | } finally { | ||
| 4535 | await mounted.cleanup(); | ||
| 4536 | } | ||
| 4537 | }); | ||
| 4538 | |||
| 4539 | it("supports symmetric and backward-selection surrounds", async () => { | ||
| 4540 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "backward"); | ||
| 4541 | |||
| 4542 | const mounted = await mountChatView({ | ||
| 4543 | viewport: DEFAULT_VIEWPORT, | ||
| 4544 | snapshot: createSnapshotForTargetUser({ | ||
| 4545 | targetMessageId: "msg-user-surround-backward" as MessageId, | ||
| 4546 | targetText: "surround backward", | ||
| 4547 | }), | ||
| 4548 | }); | ||
| 4549 | |||
| 4550 | try { | ||
| 4551 | await waitForComposerText("backward"); | ||
| 4552 | await setComposerSelectionByTextOffsets({ | ||
| 4553 | start: 0, | ||
| 4554 | end: "backward".length, | ||
| 4555 | direction: "backward", | ||
| 4556 | }); | ||
| 4557 | await pressComposerKey("*"); | ||
| 4558 | await waitForComposerText("*backward*"); | ||
| 4559 | } finally { | ||
| 4560 | await mounted.cleanup(); | ||
| 4561 | } | ||
| 4562 | }); | ||
| 4563 | |||
| 4564 | it("supports option-produced surround symbols like guillemets", async () => { | ||
| 4565 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); | ||
| 4566 | |||
| 4567 | const mounted = await mountChatView({ | ||
| 4568 | viewport: DEFAULT_VIEWPORT, | ||
| 4569 | snapshot: createSnapshotForTargetUser({ | ||
| 4570 | targetMessageId: "msg-user-surround-guillemet" as MessageId, | ||
| 4571 | targetText: "surround guillemet", | ||
| 4572 | }), | ||
| 4573 | }); | ||
| 4574 | |||
| 4575 | try { | ||
| 4576 | await waitForComposerText("quoted"); | ||
| 4577 | await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); | ||
| 4578 | await pressComposerKey("«"); | ||
| 4579 | await waitForComposerText("«quoted»"); | ||
| 4580 | } finally { | ||
| 4581 | await mounted.cleanup(); | ||
| 4582 | } | ||
| 4583 | }); | ||
| 4584 | |||
| 4585 | it("supports dead-key composition that resolves to another surround symbol without an extra undo step", async () => { | ||
| 4586 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); | ||
| 4587 | |||
| 4588 | const mounted = await mountChatView({ | ||
| 4589 | viewport: DEFAULT_VIEWPORT, | ||
| 4590 | snapshot: createSnapshotForTargetUser({ | ||
| 4591 | targetMessageId: "msg-user-surround-dead-quote" as MessageId, | ||
| 4592 | targetText: "surround dead quote", | ||
| 4593 | }), | ||
| 4594 | }); | ||
| 4595 | |||
| 4596 | try { | ||
| 4597 | await waitForComposerText("quoted"); | ||
| 4598 | await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); | ||
| 4599 | const composerEditor = await waitForComposerEditor(); | ||
| 4600 | composerEditor.focus(); | ||
| 4601 | composerEditor.dispatchEvent( | ||
| 4602 | new KeyboardEvent("keydown", { | ||
| 4603 | key: "Dead", | ||
| 4604 | bubbles: true, | ||
| 4605 | cancelable: true, | ||
| 4606 | }), | ||
| 4607 | ); | ||
| 4608 | composerEditor.dispatchEvent( | ||
| 4609 | new InputEvent("beforeinput", { | ||
| 4610 | data: "'", | ||
| 4611 | inputType: "insertCompositionText", | ||
| 4612 | bubbles: true, | ||
| 4613 | cancelable: true, | ||
| 4614 | }), | ||
| 4615 | ); | ||
| 4616 | const resolvedInputEvent = new InputEvent("beforeinput", { | ||
| 4617 | data: "'", | ||
| 4618 | inputType: "insertText", | ||
| 4619 | bubbles: true, | ||
| 4620 | cancelable: true, | ||
| 4621 | }); | ||
| 4622 | composerEditor.dispatchEvent(resolvedInputEvent); | ||
| 4623 | expect(resolvedInputEvent.defaultPrevented).toBe(true); | ||
| 4624 | await waitForComposerText("'quoted'"); | ||
| 4625 | await pressComposerUndo(); | ||
| 4626 | await waitForComposerText("quoted"); | ||
| 4627 | } finally { | ||
| 4628 | await mounted.cleanup(); | ||
| 4629 | } | ||
| 4630 | }); | ||
| 4631 | |||
| 4632 | it("surrounds text after a mention using the correct expanded offsets", async () => { | ||
| 4633 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there"); | ||
| 4634 | |||
| 4635 | const mounted = await mountChatView({ | ||
| 4636 | viewport: DEFAULT_VIEWPORT, | ||
| 4637 | snapshot: createSnapshotForTargetUser({ | ||
| 4638 | targetMessageId: "msg-user-surround-after-mention" as MessageId, | ||
| 4639 | targetText: "surround after mention", | ||
| 4640 | }), | ||
| 4641 | }); | ||
| 4642 | |||
| 4643 | try { | ||
| 4644 | await vi.waitFor( | ||
| 4645 | () => { | ||
| 4646 | expect(document.body.textContent).toContain("package.json"); | ||
| 4647 | }, | ||
| 4648 | { timeout: 8_000, interval: 16 }, | ||
| 4649 | ); | ||
| 4650 | await waitForComposerText("hi [package.json](package.json) there"); | ||
| 4651 | await setComposerSelectionByTextOffsets({ | ||
| 4652 | start: "hi package.json ".length, | ||
| 4653 | end: "hi package.json there".length, | ||
| 4654 | }); | ||
| 4655 | await pressComposerKey("("); | ||
| 4656 | await waitForComposerText("hi [package.json](package.json) (there)"); | ||
| 4657 | } finally { | ||
| 4658 | await mounted.cleanup(); | ||
| 4659 | } | ||
| 4660 | }); | ||
| 4661 | |||
| 4662 | it("falls back to normal replacement when the selection includes a mention token", async () => { | ||
| 4663 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there "); | ||
| 4664 | |||
| 4665 | const mounted = await mountChatView({ | ||
| 4666 | viewport: DEFAULT_VIEWPORT, | ||
| 4667 | snapshot: createSnapshotForTargetUser({ | ||
| 4668 | targetMessageId: "msg-user-surround-token" as MessageId, | ||
| 4669 | targetText: "surround token", | ||
| 4670 | }), | ||
| 4671 | }); | ||
| 4672 | |||
| 4673 | try { | ||
| 4674 | await vi.waitFor( | ||
| 4675 | () => { | ||
| 4676 | expect(document.body.textContent).toContain("package.json"); | ||
| 4677 | }, | ||
| 4678 | { timeout: 8_000, interval: 16 }, | ||
| 4679 | ); | ||
| 4680 | await selectAllComposerContent(); | ||
| 4681 | await pressComposerKey("("); | ||
| 4682 | await waitForComposerText("("); | ||
| 4683 | } finally { | ||
| 4684 | await mounted.cleanup(); | ||
| 4685 | } | ||
| 4686 | }); | ||
| 4687 | |||
| 4688 | it("stores selected file tags as markdown links while keeping the composer chip", async () => { | ||
| 4689 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "@pack"); | ||
| 4690 | |||
| 4691 | const mounted = await mountChatView({ | ||
| 4692 | viewport: DEFAULT_VIEWPORT, | ||
| 4693 | snapshot: createSnapshotForTargetUser({ | ||
| 4694 | targetMessageId: "msg-user-file-tag-encoding" as MessageId, | ||
| 4695 | targetText: "file tag encoding", | ||
| 4696 | }), | ||
| 4697 | resolveRpc: (body) => { | ||
| 4698 | if (body._tag !== WS_METHODS.projectsSearchEntries) { | ||
| 4699 | return undefined; | ||
| 4700 | } | ||
| 4701 | return { | ||
| 4702 | entries: [ | ||
| 4703 | { | ||
| 4704 | path: "path/to/package.json", | ||
| 4705 | kind: "file", | ||
| 4706 | }, | ||
| 4707 | ], | ||
| 4708 | truncated: false, | ||
| 4709 | }; | ||
| 4710 | }, | ||
| 4711 | }); | ||
| 4712 | |||
| 4713 | try { | ||
| 4714 | const item = await waitForComposerMenuItem("path:file:path/to/package.json"); | ||
| 4715 | item.click(); | ||
| 4716 | |||
| 4717 | await waitForComposerText("[package.json](path/to/package.json) "); | ||
| 4718 | const chip = await waitForElement( | ||
| 4719 | () => document.querySelector<HTMLElement>('[data-composer-mention-chip="true"]'), | ||
| 4720 | "Unable to find rendered composer file chip.", | ||
| 4721 | ); | ||
| 4722 | expect(chip.textContent).toContain("package.json"); | ||
| 4723 | } finally { | ||
| 4724 | await mounted.cleanup(); | ||
| 4725 | } | ||
| 4726 | }); | ||
| 4727 | |||
| 4728 | it("shows runtime mode descriptions in the desktop composer access select", async () => { | ||
| 4729 | setDraftThreadWithoutWorktree(); | ||
| 4730 | |||
| 4731 | const mounted = await mountChatView({ | ||
| 4732 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 4733 | snapshot: createDraftOnlySnapshot(), | ||
| 4734 | }); | ||
| 4735 | |||
| 4736 | try { | ||
| 4737 | const runtimeModeSelect = await waitForButtonByText("Full access"); | ||
| 4738 | runtimeModeSelect.click(); | ||
| 4739 | |||
| 4740 | expect((await waitForSelectItemContainingText("Supervised")).textContent).toContain( | ||
| 4741 | "Ask before commands and file changes", | ||
| 4742 | ); | ||
| 4743 | |||
| 4744 | const autoAcceptItem = await waitForSelectItemContainingText("Auto-accept edits"); | ||
| 4745 | expect(autoAcceptItem.textContent).toContain("Auto-approve edits"); | ||
| 4746 | expect((await waitForSelectItemContainingText("Full access")).textContent).toContain( | ||
| 4747 | "Allow commands and edits without prompts", | ||
| 4748 | ); | ||
| 4749 | } finally { | ||
| 4750 | await mounted.cleanup(); | ||
| 4751 | } | ||
| 4752 | }); | ||
| 4753 | |||
| 4754 | it("keeps removed terminal context pills removed when a new one is added", async () => { | ||
| 4755 | const removedLabel = "Terminal 1 lines 1-2"; | ||
| 4756 | const addedLabel = "Terminal 2 lines 9-10"; | ||
| 4757 | useComposerDraftStore.getState().addTerminalContext( | ||
| 4758 | THREAD_REF, | ||
| 4759 | createTerminalContext({ | ||
| 4760 | id: "ctx-removed", | ||
| 4761 | terminalLabel: "Terminal 1", | ||
| 4762 | lineStart: 1, | ||
| 4763 | lineEnd: 2, | ||
| 4764 | text: "bun i\nno changes", | ||
| 4765 | }), | ||
| 4766 | ); | ||
| 4767 | |||
| 4768 | const mounted = await mountChatView({ | ||
| 4769 | viewport: DEFAULT_VIEWPORT, | ||
| 4770 | snapshot: createSnapshotForTargetUser({ | ||
| 4771 | targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, | ||
| 4772 | targetText: "terminal pill backspace target", | ||
| 4773 | }), | ||
| 4774 | }); | ||
| 4775 | |||
| 4776 | try { | ||
| 4777 | await vi.waitFor( | ||
| 4778 | () => { | ||
| 4779 | expect(document.body.textContent).toContain(removedLabel); | ||
| 4780 | }, | ||
| 4781 | { timeout: 8_000, interval: 16 }, | ||
| 4782 | ); | ||
| 4783 | |||
| 4784 | const store = useComposerDraftStore.getState(); | ||
| 4785 | const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; | ||
| 4786 | const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); | ||
| 4787 | store.setPrompt(THREAD_REF, nextPrompt.prompt); | ||
| 4788 | store.removeTerminalContext(THREAD_REF, "ctx-removed"); | ||
| 4789 | |||
| 4790 | await vi.waitFor( | ||
| 4791 | () => { | ||
| 4792 | expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); | ||
| 4793 | expect(document.body.textContent).not.toContain(removedLabel); | ||
| 4794 | }, | ||
| 4795 | { timeout: 8_000, interval: 16 }, | ||
| 4796 | ); | ||
| 4797 | |||
| 4798 | useComposerDraftStore.getState().addTerminalContext( | ||
| 4799 | THREAD_REF, | ||
| 4800 | createTerminalContext({ | ||
| 4801 | id: "ctx-added", | ||
| 4802 | terminalLabel: "Terminal 2", | ||
| 4803 | lineStart: 9, | ||
| 4804 | lineEnd: 10, | ||
| 4805 | text: "git status\nOn branch main", | ||
| 4806 | }), | ||
| 4807 | ); | ||
| 4808 | |||
| 4809 | await vi.waitFor( | ||
| 4810 | () => { | ||
| 4811 | const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; | ||
| 4812 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); | ||
| 4813 | expect(document.body.textContent).toContain(addedLabel); | ||
| 4814 | expect(document.body.textContent).not.toContain(removedLabel); | ||
| 4815 | }, | ||
| 4816 | { timeout: 8_000, interval: 16 }, | ||
| 4817 | ); | ||
| 4818 | } finally { | ||
| 4819 | await mounted.cleanup(); | ||
| 4820 | } | ||
| 4821 | }); | ||
| 4822 | |||
| 4823 | it("disables send when the composer only contains an expired terminal pill", async () => { | ||
| 4824 | const expiredLabel = "Terminal 1 line 4"; | ||
| 4825 | useComposerDraftStore.getState().addTerminalContext( | ||
| 4826 | THREAD_REF, | ||
| 4827 | createTerminalContext({ | ||
| 4828 | id: "ctx-expired-only", | ||
| 4829 | terminalLabel: "Terminal 1", | ||
| 4830 | lineStart: 4, | ||
| 4831 | lineEnd: 4, | ||
| 4832 | text: "", | ||
| 4833 | }), | ||
| 4834 | ); | ||
| 4835 | |||
| 4836 | const mounted = await mountChatView({ | ||
| 4837 | viewport: DEFAULT_VIEWPORT, | ||
| 4838 | snapshot: createSnapshotForTargetUser({ | ||
| 4839 | targetMessageId: "msg-user-expired-pill-disabled" as MessageId, | ||
| 4840 | targetText: "expired pill disabled target", | ||
| 4841 | }), | ||
| 4842 | }); | ||
| 4843 | |||
| 4844 | try { | ||
| 4845 | await vi.waitFor( | ||
| 4846 | () => { | ||
| 4847 | expect(document.body.textContent).toContain(expiredLabel); | ||
| 4848 | }, | ||
| 4849 | { timeout: 8_000, interval: 16 }, | ||
| 4850 | ); | ||
| 4851 | |||
| 4852 | const sendButton = await waitForSendButton(); | ||
| 4853 | expect(sendButton.disabled).toBe(true); | ||
| 4854 | } finally { | ||
| 4855 | await mounted.cleanup(); | ||
| 4856 | } | ||
| 4857 | }); | ||
| 4858 | |||
| 4859 | it("warns when sending text while omitting expired terminal pills", async () => { | ||
| 4860 | const expiredLabel = "Terminal 1 line 4"; | ||
| 4861 | useComposerDraftStore.getState().addTerminalContext( | ||
| 4862 | THREAD_REF, | ||
| 4863 | createTerminalContext({ | ||
| 4864 | id: "ctx-expired-send-warning", | ||
| 4865 | terminalLabel: "Terminal 1", | ||
| 4866 | lineStart: 4, | ||
| 4867 | lineEnd: 4, | ||
| 4868 | text: "", | ||
| 4869 | }), | ||
| 4870 | ); | ||
| 4871 | useComposerDraftStore | ||
| 4872 | .getState() | ||
| 4873 | .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); | ||
| 4874 | |||
| 4875 | const mounted = await mountChatView({ | ||
| 4876 | viewport: DEFAULT_VIEWPORT, | ||
| 4877 | snapshot: createSnapshotForTargetUser({ | ||
| 4878 | targetMessageId: "msg-user-expired-pill-warning" as MessageId, | ||
| 4879 | targetText: "expired pill warning target", | ||
| 4880 | }), | ||
| 4881 | }); | ||
| 4882 | |||
| 4883 | try { | ||
| 4884 | await vi.waitFor( | ||
| 4885 | () => { | ||
| 4886 | expect(document.body.textContent).toContain(expiredLabel); | ||
| 4887 | }, | ||
| 4888 | { timeout: 8_000, interval: 16 }, | ||
| 4889 | ); | ||
| 4890 | |||
| 4891 | const sendButton = await waitForSendButton(); | ||
| 4892 | expect(sendButton.disabled).toBe(false); | ||
| 4893 | sendButton.click(); | ||
| 4894 | |||
| 4895 | await vi.waitFor( | ||
| 4896 | () => { | ||
| 4897 | expect(document.body.textContent).toContain( | ||
| 4898 | "Expired terminal context omitted from message", | ||
| 4899 | ); | ||
| 4900 | expect(document.body.textContent).not.toContain(expiredLabel); | ||
| 4901 | expect(document.body.textContent).toContain("yoowaddup"); | ||
| 4902 | }, | ||
| 4903 | { timeout: 8_000, interval: 16 }, | ||
| 4904 | ); | ||
| 4905 | } finally { | ||
| 4906 | await mounted.cleanup(); | ||
| 4907 | } | ||
| 4908 | }); | ||
| 4909 | |||
| 4910 | it("shows a pointer cursor for the running stop button", async () => { | ||
| 4911 | const mounted = await mountChatView({ | ||
| 4912 | viewport: DEFAULT_VIEWPORT, | ||
| 4913 | snapshot: createSnapshotForTargetUser({ | ||
| 4914 | targetMessageId: "msg-user-stop-button-cursor" as MessageId, | ||
| 4915 | targetText: "stop button cursor target", | ||
| 4916 | sessionStatus: "running", | ||
| 4917 | }), | ||
| 4918 | }); | ||
| 4919 | |||
| 4920 | try { | ||
| 4921 | const stopButton = await waitForElement( | ||
| 4922 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Stop generation"]'), | ||
| 4923 | "Unable to find stop generation button.", | ||
| 4924 | ); | ||
| 4925 | |||
| 4926 | expect(getComputedStyle(stopButton).cursor).toBe("pointer"); | ||
| 4927 | } finally { | ||
| 4928 | await mounted.cleanup(); | ||
| 4929 | } | ||
| 4930 | }); | ||
| 4931 | |||
| 4932 | it("hides the archive action when the pointer leaves a thread row", async () => { | ||
| 4933 | const mounted = await mountChatView({ | ||
| 4934 | viewport: DEFAULT_VIEWPORT, | ||
| 4935 | snapshot: createSnapshotForTargetUser({ | ||
| 4936 | targetMessageId: "msg-user-archive-hover-test" as MessageId, | ||
| 4937 | targetText: "archive hover target", | ||
| 4938 | }), | ||
| 4939 | }); | ||
| 4940 | |||
| 4941 | try { | ||
| 4942 | const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); | ||
| 4943 | |||
| 4944 | await expect.element(threadRow).toBeInTheDocument(); | ||
| 4945 | const archiveButton = await waitForElement( | ||
| 4946 | () => | ||
| 4947 | document.querySelector<HTMLButtonElement>(`[data-testid="thread-archive-${THREAD_ID}"]`), | ||
| 4948 | "Unable to find archive button.", | ||
| 4949 | ); | ||
| 4950 | const archiveAction = archiveButton.parentElement; | ||
| 4951 | expect( | ||
| 4952 | archiveAction, | ||
| 4953 | "Archive button should render inside a visibility wrapper.", | ||
| 4954 | ).not.toBeNull(); | ||
| 4955 | expect(getComputedStyle(archiveAction!).opacity).toBe("0"); | ||
| 4956 | |||
| 4957 | await threadRow.hover(); | ||
| 4958 | await vi.waitFor( | ||
| 4959 | () => { | ||
| 4960 | expect(getComputedStyle(archiveAction!).opacity).toBe("1"); | ||
| 4961 | }, | ||
| 4962 | { timeout: 4_000, interval: 16 }, | ||
| 4963 | ); | ||
| 4964 | |||
| 4965 | await page.getByTestId("composer-editor").hover(); | ||
| 4966 | await vi.waitFor( | ||
| 4967 | () => { | ||
| 4968 | expect(getComputedStyle(archiveAction!).opacity).toBe("0"); | ||
| 4969 | }, | ||
| 4970 | { timeout: 4_000, interval: 16 }, | ||
| 4971 | ); | ||
| 4972 | } finally { | ||
| 4973 | await mounted.cleanup(); | ||
| 4974 | } | ||
| 4975 | }); | ||
| 4976 | |||
| 4977 | it("exposes the full thread title on the sidebar row tooltip", async () => { | ||
| 4978 | const mounted = await mountChatView({ | ||
| 4979 | viewport: DEFAULT_VIEWPORT, | ||
| 4980 | snapshot: createSnapshotForTargetUser({ | ||
| 4981 | targetMessageId: "msg-user-thread-tooltip-target" as MessageId, | ||
| 4982 | targetText: "thread tooltip target", | ||
| 4983 | }), | ||
| 4984 | }); | ||
| 4985 | |||
| 4986 | try { | ||
| 4987 | const threadTitle = page.getByTestId(`thread-title-${THREAD_ID}`); | ||
| 4988 | |||
| 4989 | await expect.element(threadTitle).toBeInTheDocument(); | ||
| 4990 | await threadTitle.hover(); | ||
| 4991 | |||
| 4992 | await vi.waitFor( | ||
| 4993 | () => { | ||
| 4994 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | ||
| 4995 | expect(tooltip).not.toBeNull(); | ||
| 4996 | expect(tooltip?.textContent).toContain(THREAD_TITLE); | ||
| 4997 | }, | ||
| 4998 | { timeout: 8_000, interval: 16 }, | ||
| 4999 | ); | ||
| 5000 | } finally { | ||
| 5001 | await mounted.cleanup(); | ||
| 5002 | } | ||
| 5003 | }); | ||
| 5004 | |||
| 5005 | it("shows the sidebar terminal indicator from terminal metadata activity", async () => { | ||
| 5006 | const mounted = await mountChatView({ | ||
| 5007 | viewport: DEFAULT_VIEWPORT, | ||
| 5008 | snapshot: createSnapshotForTargetUser({ | ||
| 5009 | targetMessageId: "msg-user-terminal-metadata-indicator" as MessageId, | ||
| 5010 | targetText: "terminal metadata indicator target", | ||
| 5011 | }), | ||
| 5012 | configureFixture: (nextFixture) => { | ||
| 5013 | nextFixture.terminalMetadataEvents = [ | ||
| 5014 | { | ||
| 5015 | type: "upsert", | ||
| 5016 | terminal: { | ||
| 5017 | threadId: THREAD_ID, | ||
| 5018 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 5019 | cwd: "/repo/project", | ||
| 5020 | worktreePath: null, | ||
| 5021 | status: "running", | ||
| 5022 | pid: 123, | ||
| 5023 | exitCode: null, | ||
| 5024 | exitSignal: null, | ||
| 5025 | hasRunningSubprocess: true, | ||
| 5026 | label: "Terminal 1", | ||
| 5027 | updatedAt: isoAt(1_200), | ||
| 5028 | }, | ||
| 5029 | }, | ||
| 5030 | ]; | ||
| 5031 | }, | ||
| 5032 | }); | ||
| 5033 | |||
| 5034 | try { | ||
| 5035 | await vi.waitFor( | ||
| 5036 | () => { | ||
| 5037 | expect( | ||
| 5038 | terminalSessionManager.listSessions({ | ||
| 5039 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 5040 | threadId: THREAD_ID, | ||
| 5041 | }), | ||
| 5042 | ).toMatchObject([ | ||
| 5043 | { | ||
| 5044 | state: { | ||
| 5045 | hasRunningSubprocess: true, | ||
| 5046 | }, | ||
| 5047 | }, | ||
| 5048 | ]); | ||
| 5049 | }, | ||
| 5050 | { timeout: 8_000, interval: 16 }, | ||
| 5051 | ); | ||
| 5052 | |||
| 5053 | await vi.waitFor( | ||
| 5054 | () => { | ||
| 5055 | const terminalIndicator = document.querySelector<HTMLElement>( | ||
| 5056 | '[aria-label="Terminal process running"]', | ||
| 5057 | ); | ||
| 5058 | expect(terminalIndicator).not.toBeNull(); | ||
| 5059 | }, | ||
| 5060 | { timeout: 8_000, interval: 16 }, | ||
| 5061 | ); | ||
| 5062 | } finally { | ||
| 5063 | await mounted.cleanup(); | ||
| 5064 | } | ||
| 5065 | }); | ||
| 5066 | |||
| 5067 | it("shows the confirm archive action after clicking the archive button", async () => { | ||
| 5068 | localStorage.setItem( | ||
| 5069 | "t3code:client-settings:v1", | ||
| 5070 | JSON.stringify({ | ||
| 5071 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 5072 | confirmThreadArchive: true, | ||
| 5073 | }), | ||
| 5074 | ); | ||
| 5075 | |||
| 5076 | const mounted = await mountChatView({ | ||
| 5077 | viewport: DEFAULT_VIEWPORT, | ||
| 5078 | snapshot: createSnapshotForTargetUser({ | ||
| 5079 | targetMessageId: "msg-user-archive-confirm-test" as MessageId, | ||
| 5080 | targetText: "archive confirm target", | ||
| 5081 | }), | ||
| 5082 | }); | ||
| 5083 | |||
| 5084 | try { | ||
| 5085 | const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); | ||
| 5086 | |||
| 5087 | await expect.element(threadRow).toBeInTheDocument(); | ||
| 5088 | await threadRow.hover(); | ||
| 5089 | |||
| 5090 | const archiveButton = page.getByTestId(`thread-archive-${THREAD_ID}`); | ||
| 5091 | await expect.element(archiveButton).toBeInTheDocument(); | ||
| 5092 | await archiveButton.click(); | ||
| 5093 | |||
| 5094 | const confirmButton = page.getByTestId(`thread-archive-confirm-${THREAD_ID}`); | ||
| 5095 | await expect.element(confirmButton).toBeInTheDocument(); | ||
| 5096 | await expect.element(confirmButton).toBeVisible(); | ||
| 5097 | } finally { | ||
| 5098 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 5099 | await mounted.cleanup(); | ||
| 5100 | } | ||
| 5101 | }); | ||
| 5102 | |||
| 5103 | it("canonicalizes promoted draft threads to the server thread route", async () => { | ||
| 5104 | const mounted = await mountChatView({ | ||
| 5105 | viewport: DEFAULT_VIEWPORT, | ||
| 5106 | snapshot: createSnapshotForTargetUser({ | ||
| 5107 | targetMessageId: "msg-user-new-thread-test" as MessageId, | ||
| 5108 | targetText: "new thread selection test", | ||
| 5109 | }), | ||
| 5110 | }); | ||
| 5111 | |||
| 5112 | try { | ||
| 5113 | // Wait for the sidebar to render with the project. | ||
| 5114 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 5115 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 5116 | |||
| 5117 | await newThreadButton.click(); | ||
| 5118 | |||
| 5119 | // The route should change to a new draft thread ID. | ||
| 5120 | const newThreadPath = await waitForURL( | ||
| 5121 | mounted.router, | ||
| 5122 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5123 | "Route should have changed to a new draft thread UUID.", | ||
| 5124 | ); | ||
| 5125 | const newDraftId = draftIdFromPath(newThreadPath); | ||
| 5126 | const newThreadId = draftThreadIdFor(newDraftId); | ||
| 5127 | |||
| 5128 | // The composer editor should be present for the new draft thread. | ||
| 5129 | await waitForComposerEditor(); | ||
| 5130 | |||
| 5131 | // `thread.created` should only mark the draft as promoting; it should | ||
| 5132 | // not navigate away until the server thread has actual runtime state. | ||
| 5133 | await materializePromotedDraftThreadViaDomainEvent(newThreadId); | ||
| 5134 | expect(mounted.router.state.location.pathname).toBe(newThreadPath); | ||
| 5135 | await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); | ||
| 5136 | |||
| 5137 | // Once the server thread starts, the route should canonicalize. | ||
| 5138 | await startPromotedServerThreadViaDomainEvent(newThreadId); | ||
| 5139 | await vi.waitFor( | ||
| 5140 | () => { | ||
| 5141 | expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe( | ||
| 5142 | undefined, | ||
| 5143 | ); | ||
| 5144 | }, | ||
| 5145 | { timeout: 8_000, interval: 16 }, | ||
| 5146 | ); | ||
| 5147 | |||
| 5148 | // The route should switch to the canonical server thread path. | ||
| 5149 | await waitForURL( | ||
| 5150 | mounted.router, | ||
| 5151 | (path) => path === serverThreadPath(newThreadId), | ||
| 5152 | "Promoted drafts should canonicalize to the server thread route.", | ||
| 5153 | ); | ||
| 5154 | |||
| 5155 | // The composer should remain usable after canonicalization, regardless of | ||
| 5156 | // whether the promoted thread is still visibly empty or has already | ||
| 5157 | // entered the running state. | ||
| 5158 | await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); | ||
| 5159 | } finally { | ||
| 5160 | await mounted.cleanup(); | ||
| 5161 | } | ||
| 5162 | }); | ||
| 5163 | |||
| 5164 | it("canonicalizes stale promoted draft routes to the server thread route", async () => { | ||
| 5165 | const mounted = await mountChatView({ | ||
| 5166 | viewport: DEFAULT_VIEWPORT, | ||
| 5167 | snapshot: createSnapshotForTargetUser({ | ||
| 5168 | targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, | ||
| 5169 | targetText: "draft hydration race test", | ||
| 5170 | }), | ||
| 5171 | }); | ||
| 5172 | |||
| 5173 | try { | ||
| 5174 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 5175 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 5176 | |||
| 5177 | await newThreadButton.click(); | ||
| 5178 | |||
| 5179 | const newThreadPath = await waitForURL( | ||
| 5180 | mounted.router, | ||
| 5181 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5182 | "Route should have changed to a new draft thread UUID.", | ||
| 5183 | ); | ||
| 5184 | const newDraftId = draftIdFromPath(newThreadPath); | ||
| 5185 | const newThreadId = draftThreadIdFor(newDraftId); | ||
| 5186 | |||
| 5187 | await promoteDraftThreadViaDomainEvent(newThreadId); | ||
| 5188 | |||
| 5189 | await mounted.router.navigate({ | ||
| 5190 | to: "/draft/$draftId", | ||
| 5191 | params: { draftId: newDraftId }, | ||
| 5192 | }); | ||
| 5193 | |||
| 5194 | await waitForURL( | ||
| 5195 | mounted.router, | ||
| 5196 | (path) => path === serverThreadPath(newThreadId), | ||
| 5197 | "Stale promoted draft routes should canonicalize to the server thread path.", | ||
| 5198 | ); | ||
| 5199 | |||
| 5200 | await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); | ||
| 5201 | } finally { | ||
| 5202 | await mounted.cleanup(); | ||
| 5203 | } | ||
| 5204 | }); | ||
| 5205 | |||
| 5206 | it("creates a fresh worktree draft from an existing worktree thread when the default mode is worktree", async () => { | ||
| 5207 | const mounted = await mountChatView({ | ||
| 5208 | viewport: DEFAULT_VIEWPORT, | ||
| 5209 | snapshot: { | ||
| 5210 | ...createSnapshotForTargetUser({ | ||
| 5211 | targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, | ||
| 5212 | targetText: "new thread worktree default test", | ||
| 5213 | }), | ||
| 5214 | threads: createSnapshotForTargetUser({ | ||
| 5215 | targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, | ||
| 5216 | targetText: "new thread worktree default test", | ||
| 5217 | }).threads.map((thread) => | ||
| 5218 | thread.id === THREAD_ID | ||
| 5219 | ? Object.assign({}, thread, { | ||
| 5220 | branch: "feature/existing", | ||
| 5221 | worktreePath: "/repo/.t3/worktrees/existing", | ||
| 5222 | }) | ||
| 5223 | : thread, | ||
| 5224 | ), | ||
| 5225 | }, | ||
| 5226 | configureFixture: (nextFixture) => { | ||
| 5227 | nextFixture.serverConfig = { | ||
| 5228 | ...nextFixture.serverConfig, | ||
| 5229 | settings: { | ||
| 5230 | ...nextFixture.serverConfig.settings, | ||
| 5231 | defaultThreadEnvMode: "worktree", | ||
| 5232 | }, | ||
| 5233 | }; | ||
| 5234 | }, | ||
| 5235 | }); | ||
| 5236 | |||
| 5237 | try { | ||
| 5238 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 5239 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 5240 | |||
| 5241 | await newThreadButton.click(); | ||
| 5242 | |||
| 5243 | const newThreadPath = await waitForURL( | ||
| 5244 | mounted.router, | ||
| 5245 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5246 | "Route should change to a new draft thread.", | ||
| 5247 | ); | ||
| 5248 | const newDraftId = draftIdFromPath(newThreadPath); | ||
| 5249 | |||
| 5250 | expect(useComposerDraftStore.getState().getDraftSession(newDraftId)).toMatchObject({ | ||
| 5251 | envMode: "worktree", | ||
| 5252 | worktreePath: null, | ||
| 5253 | }); | ||
| 5254 | } finally { | ||
| 5255 | await mounted.cleanup(); | ||
| 5256 | } | ||
| 5257 | }); | ||
| 5258 | |||
| 5259 | it("creates a new draft instead of reusing a promoting draft thread", async () => { | ||
| 5260 | const mounted = await mountChatView({ | ||
| 5261 | viewport: DEFAULT_VIEWPORT, | ||
| 5262 | snapshot: createSnapshotForTargetUser({ | ||
| 5263 | targetMessageId: "msg-user-promoting-draft-new-thread-test" as MessageId, | ||
| 5264 | targetText: "promoting draft new thread test", | ||
| 5265 | }), | ||
| 5266 | }); | ||
| 5267 | |||
| 5268 | try { | ||
| 5269 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 5270 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 5271 | |||
| 5272 | await newThreadButton.click(); | ||
| 5273 | |||
| 5274 | const firstDraftPath = await waitForURL( | ||
| 5275 | mounted.router, | ||
| 5276 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5277 | "Route should change to the first draft thread.", | ||
| 5278 | ); | ||
| 5279 | const firstDraftId = draftIdFromPath(firstDraftPath); | ||
| 5280 | const firstThreadId = draftThreadIdFor(firstDraftId); | ||
| 5281 | |||
| 5282 | await materializePromotedDraftThreadViaDomainEvent(firstThreadId); | ||
| 5283 | expect(mounted.router.state.location.pathname).toBe(firstDraftPath); | ||
| 5284 | |||
| 5285 | await newThreadButton.click(); | ||
| 5286 | |||
| 5287 | const secondDraftPath = await waitForURL( | ||
| 5288 | mounted.router, | ||
| 5289 | (path) => UUID_ROUTE_RE.test(path) && path !== firstDraftPath, | ||
| 5290 | "Route should change to a second draft thread instead of reusing the promoting draft.", | ||
| 5291 | ); | ||
| 5292 | expect(draftIdFromPath(secondDraftPath)).not.toBe(firstDraftId); | ||
| 5293 | } finally { | ||
| 5294 | await mounted.cleanup(); | ||
| 5295 | } | ||
| 5296 | }); | ||
| 5297 | |||
| 5298 | it("snapshots sticky codex settings into a new draft thread", async () => { | ||
| 5299 | useComposerDraftStore.setState({ | ||
| 5300 | stickyModelSelectionByProvider: { | ||
| 5301 | [ProviderInstanceId.make("codex")]: createModelSelection( | ||
| 5302 | ProviderInstanceId.make("codex"), | ||
| 5303 | "gpt-5.3-codex", | ||
| 5304 | [ | ||
| 5305 | { id: "reasoningEffort", value: "medium" }, | ||
| 5306 | { id: "fastMode", value: true }, | ||
| 5307 | ], | ||
| 5308 | ), | ||
| 5309 | }, | ||
| 5310 | stickyActiveProvider: ProviderInstanceId.make("codex"), | ||
| 5311 | }); | ||
| 5312 | |||
| 5313 | const mounted = await mountChatView({ | ||
| 5314 | viewport: DEFAULT_VIEWPORT, | ||
| 5315 | snapshot: createSnapshotForTargetUser({ | ||
| 5316 | targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, | ||
| 5317 | targetText: "sticky codex traits test", | ||
| 5318 | }), | ||
| 5319 | }); | ||
| 5320 | |||
| 5321 | try { | ||
| 5322 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 5323 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 5324 | |||
| 5325 | await newThreadButton.click(); | ||
| 5326 | |||
| 5327 | const newThreadPath = await waitForURL( | ||
| 5328 | mounted.router, | ||
| 5329 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5330 | "Route should have changed to a new draft thread UUID.", | ||
| 5331 | ); | ||
| 5332 | const newDraftId = draftIdFromPath(newThreadPath); | ||
| 5333 | |||
| 5334 | // `toMatchObject` matches objects loosely (extras ignored) but compares | ||
| 5335 | // arrays strictly, so wrap `options` in `arrayContaining` to keep the | ||
| 5336 | // assertion focused on sticky `fastMode` carrying over without asserting | ||
| 5337 | // on exactly which other options are preserved. | ||
| 5338 | expect(composerDraftFor(newDraftId)).toMatchObject({ | ||
| 5339 | modelSelectionByProvider: { | ||
| 5340 | codex: { | ||
| 5341 | instanceId: ProviderInstanceId.make("codex"), | ||
| 5342 | model: "gpt-5.3-codex", | ||
| 5343 | options: expect.arrayContaining([{ id: "fastMode", value: true }]), | ||
| 5344 | }, | ||
| 5345 | }, | ||
| 5346 | activeProvider: "codex", | ||
| 5347 | }); | ||
| 5348 | } finally { | ||
| 5349 | await mounted.cleanup(); | ||
| 5350 | } | ||
| 5351 | }); | ||
| 5352 | |||
| 5353 | it("hydrates the provider alongside a sticky claude model", async () => { | ||
| 5354 | useComposerDraftStore.setState({ | ||
| 5355 | stickyModelSelectionByProvider: { | ||
| 5356 | [ProviderInstanceId.make("claudeAgent")]: createModelSelection( | ||
| 5357 | ProviderInstanceId.make("claudeAgent"), | ||
| 5358 | "claude-opus-4-6", | ||
| 5359 | [ | ||
| 5360 | { id: "effort", value: "max" }, | ||
| 5361 | { id: "fastMode", value: true }, | ||
| 5362 | ], | ||
| 5363 | ), | ||
| 5364 | }, | ||
| 5365 | stickyActiveProvider: ProviderInstanceId.make("claudeAgent"), | ||
| 5366 | }); | ||
| 5367 | |||
| 5368 | const mounted = await mountChatView({ | ||
| 5369 | viewport: DEFAULT_VIEWPORT, | ||
| 5370 | snapshot: createSnapshotForTargetUser({ | ||
| 5371 | targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, | ||
| 5372 | targetText: "sticky claude model test", | ||
| 5373 | }), | ||
| 5374 | }); | ||
| 5375 | |||
| 5376 | try { | ||
| 5377 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 5378 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 5379 | |||
| 5380 | await newThreadButton.click(); | ||
| 5381 | |||
| 5382 | const newThreadPath = await waitForURL( | ||
| 5383 | mounted.router, | ||
| 5384 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5385 | "Route should have changed to a new sticky claude draft thread UUID.", | ||
| 5386 | ); | ||
| 5387 | const newDraftId = draftIdFromPath(newThreadPath); | ||
| 5388 | |||
| 5389 | expect(composerDraftFor(newDraftId)).toMatchObject({ | ||
| 5390 | modelSelectionByProvider: { | ||
| 5391 | claudeAgent: createModelSelection( | ||
| 5392 | ProviderInstanceId.make("claudeAgent"), | ||
| 5393 | "claude-opus-4-6", | ||
| 5394 | [ | ||
| 5395 | { id: "effort", value: "max" }, | ||
| 5396 | { id: "fastMode", value: true }, | ||
| 5397 | ], | ||
| 5398 | ), | ||
| 5399 | }, | ||
| 5400 | activeProvider: "claudeAgent", | ||
| 5401 | }); | ||
| 5402 | } finally { | ||
| 5403 | await mounted.cleanup(); | ||
| 5404 | } | ||
| 5405 | }); | ||
| 5406 | |||
| 5407 | it("falls back to defaults when no sticky composer settings exist", async () => { | ||
| 5408 | const mounted = await mountChatView({ | ||
| 5409 | viewport: DEFAULT_VIEWPORT, | ||
| 5410 | snapshot: createSnapshotForTargetUser({ | ||
| 5411 | targetMessageId: "msg-user-default-codex-traits-test" as MessageId, | ||
| 5412 | targetText: "default codex traits test", | ||
| 5413 | }), | ||
| 5414 | }); | ||
| 5415 | |||
| 5416 | try { | ||
| 5417 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 5418 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 5419 | |||
| 5420 | await newThreadButton.click(); | ||
| 5421 | |||
| 5422 | const newThreadPath = await waitForURL( | ||
| 5423 | mounted.router, | ||
| 5424 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5425 | "Route should have changed to a new draft thread UUID.", | ||
| 5426 | ); | ||
| 5427 | const newDraftId = draftIdFromPath(newThreadPath); | ||
| 5428 | |||
| 5429 | expect(composerDraftFor(newDraftId)).toBe(undefined); | ||
| 5430 | } finally { | ||
| 5431 | await mounted.cleanup(); | ||
| 5432 | } | ||
| 5433 | }); | ||
| 5434 | |||
| 5435 | it("prefers draft state over sticky composer settings and defaults", async () => { | ||
| 5436 | useComposerDraftStore.setState({ | ||
| 5437 | stickyModelSelectionByProvider: { | ||
| 5438 | [ProviderInstanceId.make("codex")]: createModelSelection( | ||
| 5439 | ProviderInstanceId.make("codex"), | ||
| 5440 | "gpt-5.3-codex", | ||
| 5441 | [ | ||
| 5442 | { id: "reasoningEffort", value: "medium" }, | ||
| 5443 | { id: "fastMode", value: true }, | ||
| 5444 | ], | ||
| 5445 | ), | ||
| 5446 | }, | ||
| 5447 | stickyActiveProvider: ProviderInstanceId.make("codex"), | ||
| 5448 | }); | ||
| 5449 | |||
| 5450 | const mounted = await mountChatView({ | ||
| 5451 | viewport: DEFAULT_VIEWPORT, | ||
| 5452 | snapshot: createSnapshotForTargetUser({ | ||
| 5453 | targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, | ||
| 5454 | targetText: "draft codex traits precedence test", | ||
| 5455 | }), | ||
| 5456 | }); | ||
| 5457 | |||
| 5458 | try { | ||
| 5459 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 5460 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 5461 | |||
| 5462 | await newThreadButton.click(); | ||
| 5463 | |||
| 5464 | const threadPath = await waitForURL( | ||
| 5465 | mounted.router, | ||
| 5466 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5467 | "Route should have changed to a sticky draft thread UUID.", | ||
| 5468 | ); | ||
| 5469 | const draftId = draftIdFromPath(threadPath); | ||
| 5470 | |||
| 5471 | // See the note on the sibling sticky-codex test: arrays match strictly | ||
| 5472 | // under `toMatchObject`, so use `arrayContaining` to keep the assertion | ||
| 5473 | // scoped to the sticky trait (`fastMode`) that must carry over. | ||
| 5474 | expect(composerDraftFor(draftId)).toMatchObject({ | ||
| 5475 | modelSelectionByProvider: { | ||
| 5476 | codex: { | ||
| 5477 | instanceId: ProviderInstanceId.make("codex"), | ||
| 5478 | model: "gpt-5.3-codex", | ||
| 5479 | options: expect.arrayContaining([{ id: "fastMode", value: true }]), | ||
| 5480 | }, | ||
| 5481 | }, | ||
| 5482 | activeProvider: "codex", | ||
| 5483 | }); | ||
| 5484 | |||
| 5485 | useComposerDraftStore.getState().setModelSelection( | ||
| 5486 | draftId, | ||
| 5487 | createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ | ||
| 5488 | { id: "reasoningEffort", value: "low" }, | ||
| 5489 | { id: "fastMode", value: true }, | ||
| 5490 | ]), | ||
| 5491 | ); | ||
| 5492 | |||
| 5493 | await newThreadButton.click(); | ||
| 5494 | |||
| 5495 | await waitForURL( | ||
| 5496 | mounted.router, | ||
| 5497 | (path) => path === threadPath, | ||
| 5498 | "New-thread should reuse the existing project draft thread.", | ||
| 5499 | ); | ||
| 5500 | expect(composerDraftFor(draftId)).toMatchObject({ | ||
| 5501 | modelSelectionByProvider: { | ||
| 5502 | codex: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ | ||
| 5503 | { id: "reasoningEffort", value: "low" }, | ||
| 5504 | { id: "fastMode", value: true }, | ||
| 5505 | ]), | ||
| 5506 | }, | ||
| 5507 | activeProvider: "codex", | ||
| 5508 | }); | ||
| 5509 | } finally { | ||
| 5510 | await mounted.cleanup(); | ||
| 5511 | } | ||
| 5512 | }); | ||
| 5513 | |||
| 5514 | it("creates a new thread from the global chat.new shortcut", async () => { | ||
| 5515 | const mounted = await mountChatView({ | ||
| 5516 | viewport: DEFAULT_VIEWPORT, | ||
| 5517 | snapshot: createSnapshotForTargetUser({ | ||
| 5518 | targetMessageId: "msg-user-chat-shortcut-test" as MessageId, | ||
| 5519 | targetText: "chat shortcut test", | ||
| 5520 | }), | ||
| 5521 | configureFixture: (nextFixture) => { | ||
| 5522 | nextFixture.serverConfig = { | ||
| 5523 | ...nextFixture.serverConfig, | ||
| 5524 | keybindings: [ | ||
| 5525 | { | ||
| 5526 | command: "chat.new", | ||
| 5527 | shortcut: { | ||
| 5528 | key: "o", | ||
| 5529 | metaKey: false, | ||
| 5530 | ctrlKey: false, | ||
| 5531 | shiftKey: true, | ||
| 5532 | altKey: false, | ||
| 5533 | modKey: true, | ||
| 5534 | }, | ||
| 5535 | whenAst: { | ||
| 5536 | type: "not", | ||
| 5537 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 5538 | }, | ||
| 5539 | }, | ||
| 5540 | { | ||
| 5541 | command: "thread.jump.1", | ||
| 5542 | shortcut: { | ||
| 5543 | key: "1", | ||
| 5544 | metaKey: true, | ||
| 5545 | ctrlKey: false, | ||
| 5546 | shiftKey: false, | ||
| 5547 | altKey: false, | ||
| 5548 | modKey: false, | ||
| 5549 | }, | ||
| 5550 | }, | ||
| 5551 | { | ||
| 5552 | command: "modelPicker.jump.1", | ||
| 5553 | shortcut: { | ||
| 5554 | key: "1", | ||
| 5555 | metaKey: true, | ||
| 5556 | ctrlKey: false, | ||
| 5557 | shiftKey: false, | ||
| 5558 | altKey: false, | ||
| 5559 | modKey: false, | ||
| 5560 | }, | ||
| 5561 | whenAst: { type: "identifier", name: "modelPickerOpen" }, | ||
| 5562 | }, | ||
| 5563 | ], | ||
| 5564 | }; | ||
| 5565 | }, | ||
| 5566 | }); | ||
| 5567 | |||
| 5568 | try { | ||
| 5569 | await waitForNewThreadShortcutLabel(); | ||
| 5570 | await waitForServerConfigToApply(); | ||
| 5571 | const composerEditor = await waitForComposerEditor(); | ||
| 5572 | composerEditor.focus(); | ||
| 5573 | await waitForLayout(); | ||
| 5574 | await triggerChatNewShortcutUntilPath( | ||
| 5575 | mounted.router, | ||
| 5576 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5577 | "Route should have changed to a new draft thread UUID from the shortcut.", | ||
| 5578 | ); | ||
| 5579 | } finally { | ||
| 5580 | await mounted.cleanup(); | ||
| 5581 | } | ||
| 5582 | }); | ||
| 5583 | |||
| 5584 | it("does not consume chat.new when there is no project context", async () => { | ||
| 5585 | const mounted = await mountChatView({ | ||
| 5586 | viewport: DEFAULT_VIEWPORT, | ||
| 5587 | snapshot: createProjectlessSnapshot(), | ||
| 5588 | configureFixture: (nextFixture) => { | ||
| 5589 | nextFixture.serverConfig = { | ||
| 5590 | ...nextFixture.serverConfig, | ||
| 5591 | keybindings: [ | ||
| 5592 | { | ||
| 5593 | command: "chat.new", | ||
| 5594 | shortcut: { | ||
| 5595 | key: "o", | ||
| 5596 | metaKey: false, | ||
| 5597 | ctrlKey: false, | ||
| 5598 | shiftKey: true, | ||
| 5599 | altKey: false, | ||
| 5600 | modKey: true, | ||
| 5601 | }, | ||
| 5602 | whenAst: { | ||
| 5603 | type: "not", | ||
| 5604 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 5605 | }, | ||
| 5606 | }, | ||
| 5607 | ], | ||
| 5608 | }; | ||
| 5609 | }, | ||
| 5610 | }); | ||
| 5611 | |||
| 5612 | try { | ||
| 5613 | await waitForServerConfigToApply(); | ||
| 5614 | dispatchChatNewShortcut(); | ||
| 5615 | await waitForLayout(); | ||
| 5616 | |||
| 5617 | expect(mounted.router.state.location.pathname).toBe(serverThreadPath(THREAD_ID)); | ||
| 5618 | expect(Object.keys(useComposerDraftStore.getState().draftThreadsByThreadKey)).toHaveLength(0); | ||
| 5619 | } finally { | ||
| 5620 | await mounted.cleanup(); | ||
| 5621 | } | ||
| 5622 | }); | ||
| 5623 | |||
| 5624 | it("renders the configurable shortcut and runs a command from the sidebar trigger", async () => { | ||
| 5625 | const mounted = await mountChatView({ | ||
| 5626 | viewport: DEFAULT_VIEWPORT, | ||
| 5627 | snapshot: createSnapshotForTargetUser({ | ||
| 5628 | targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId, | ||
| 5629 | targetText: "command palette shortcut test", | ||
| 5630 | }), | ||
| 5631 | configureFixture: (nextFixture) => { | ||
| 5632 | nextFixture.serverConfig = { | ||
| 5633 | ...nextFixture.serverConfig, | ||
| 5634 | keybindings: [ | ||
| 5635 | { | ||
| 5636 | command: "commandPalette.toggle", | ||
| 5637 | shortcut: { | ||
| 5638 | key: "k", | ||
| 5639 | metaKey: false, | ||
| 5640 | ctrlKey: false, | ||
| 5641 | shiftKey: false, | ||
| 5642 | altKey: false, | ||
| 5643 | modKey: true, | ||
| 5644 | }, | ||
| 5645 | whenAst: { | ||
| 5646 | type: "not", | ||
| 5647 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 5648 | }, | ||
| 5649 | }, | ||
| 5650 | ], | ||
| 5651 | }; | ||
| 5652 | }, | ||
| 5653 | }); | ||
| 5654 | |||
| 5655 | try { | ||
| 5656 | await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); | ||
| 5657 | const palette = page.getByTestId("command-palette"); | ||
| 5658 | await openCommandPaletteFromTrigger(); | ||
| 5659 | |||
| 5660 | await expect.element(palette).toBeInTheDocument(); | ||
| 5661 | await expect | ||
| 5662 | .element(palette.getByText("New thread in Project", { exact: true })) | ||
| 5663 | .toBeInTheDocument(); | ||
| 5664 | await palette.getByText("New thread in Project", { exact: true }).click(); | ||
| 5665 | |||
| 5666 | await waitForURL( | ||
| 5667 | mounted.router, | ||
| 5668 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5669 | "Route should have changed to a new draft thread UUID from the command palette.", | ||
| 5670 | ); | ||
| 5671 | } finally { | ||
| 5672 | await mounted.cleanup(); | ||
| 5673 | } | ||
| 5674 | }); | ||
| 5675 | |||
| 5676 | it("filters command palette results as the user types", async () => { | ||
| 5677 | const mounted = await mountChatView({ | ||
| 5678 | viewport: DEFAULT_VIEWPORT, | ||
| 5679 | snapshot: createSnapshotForTargetUser({ | ||
| 5680 | targetMessageId: "msg-user-command-palette-search-test" as MessageId, | ||
| 5681 | targetText: "command palette search test", | ||
| 5682 | }), | ||
| 5683 | configureFixture: (nextFixture) => { | ||
| 5684 | nextFixture.serverConfig = { | ||
| 5685 | ...nextFixture.serverConfig, | ||
| 5686 | keybindings: [ | ||
| 5687 | { | ||
| 5688 | command: "commandPalette.toggle", | ||
| 5689 | shortcut: { | ||
| 5690 | key: "k", | ||
| 5691 | metaKey: false, | ||
| 5692 | ctrlKey: false, | ||
| 5693 | shiftKey: false, | ||
| 5694 | altKey: false, | ||
| 5695 | modKey: true, | ||
| 5696 | }, | ||
| 5697 | whenAst: { | ||
| 5698 | type: "not", | ||
| 5699 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 5700 | }, | ||
| 5701 | }, | ||
| 5702 | ], | ||
| 5703 | }; | ||
| 5704 | }, | ||
| 5705 | }); | ||
| 5706 | |||
| 5707 | try { | ||
| 5708 | await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); | ||
| 5709 | const palette = page.getByTestId("command-palette"); | ||
| 5710 | await openCommandPaletteFromTrigger(); | ||
| 5711 | |||
| 5712 | await expect.element(palette).toBeInTheDocument(); | ||
| 5713 | await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); | ||
| 5714 | await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument(); | ||
| 5715 | await expect | ||
| 5716 | .element(palette.getByText("New thread in Project", { exact: true })) | ||
| 5717 | .not.toBeInTheDocument(); | ||
| 5718 | } finally { | ||
| 5719 | await mounted.cleanup(); | ||
| 5720 | } | ||
| 5721 | }); | ||
| 5722 | |||
| 5723 | it("adds a project from browse mode with Enter when no directory is highlighted", async () => { | ||
| 5724 | const mounted = await mountChatView({ | ||
| 5725 | viewport: DEFAULT_VIEWPORT, | ||
| 5726 | snapshot: createSnapshotForTargetUser({ | ||
| 5727 | targetMessageId: "msg-user-command-palette-add-project-enter" as MessageId, | ||
| 5728 | targetText: "command palette add project enter", | ||
| 5729 | }), | ||
| 5730 | configureFixture: (nextFixture) => { | ||
| 5731 | nextFixture.serverConfig = { | ||
| 5732 | ...nextFixture.serverConfig, | ||
| 5733 | keybindings: [ | ||
| 5734 | { | ||
| 5735 | command: "commandPalette.toggle", | ||
| 5736 | shortcut: { | ||
| 5737 | key: "k", | ||
| 5738 | metaKey: false, | ||
| 5739 | ctrlKey: false, | ||
| 5740 | shiftKey: false, | ||
| 5741 | altKey: false, | ||
| 5742 | modKey: true, | ||
| 5743 | }, | ||
| 5744 | whenAst: { | ||
| 5745 | type: "not", | ||
| 5746 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 5747 | }, | ||
| 5748 | }, | ||
| 5749 | ], | ||
| 5750 | }; | ||
| 5751 | }, | ||
| 5752 | resolveRpc: (body) => { | ||
| 5753 | if (body._tag === WS_METHODS.filesystemBrowse) { | ||
| 5754 | if (body.partialPath === "~/Development/") { | ||
| 5755 | return { | ||
| 5756 | parentPath: "~/Development/", | ||
| 5757 | entries: [ | ||
| 5758 | { name: "alpha", fullPath: "~/Development/alpha" }, | ||
| 5759 | { name: "beta", fullPath: "~/Development/beta" }, | ||
| 5760 | ], | ||
| 5761 | }; | ||
| 5762 | } | ||
| 5763 | |||
| 5764 | return { | ||
| 5765 | parentPath: "~/", | ||
| 5766 | entries: [{ name: "Development", fullPath: "~/Development" }], | ||
| 5767 | }; | ||
| 5768 | } | ||
| 5769 | |||
| 5770 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 5771 | return { | ||
| 5772 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 5773 | }; | ||
| 5774 | } | ||
| 5775 | |||
| 5776 | return undefined; | ||
| 5777 | }, | ||
| 5778 | }); | ||
| 5779 | |||
| 5780 | try { | ||
| 5781 | await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); | ||
| 5782 | const palette = page.getByTestId("command-palette"); | ||
| 5783 | await openCommandPaletteFromTrigger(); | ||
| 5784 | |||
| 5785 | await expect.element(palette).toBeInTheDocument(); | ||
| 5786 | await palette.getByText("Add project", { exact: true }).click(); | ||
| 5787 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 5788 | |||
| 5789 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | ||
| 5790 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); | ||
| 5791 | await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); | ||
| 5792 | |||
| 5793 | await expect | ||
| 5794 | .element(palette.getByRole("button", { name: "Add (Enter)" })) | ||
| 5795 | .toBeInTheDocument(); | ||
| 5796 | |||
| 5797 | await dispatchInputKey(browseInput, { key: "Enter" }); | ||
| 5798 | |||
| 5799 | await vi.waitFor( | ||
| 5800 | () => { | ||
| 5801 | const dispatchRequest = wsRequests.find( | ||
| 5802 | (request) => | ||
| 5803 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 5804 | request.type === "project.create", | ||
| 5805 | ) as | ||
| 5806 | | { | ||
| 5807 | _tag: string; | ||
| 5808 | type?: string; | ||
| 5809 | workspaceRoot?: string; | ||
| 5810 | title?: string; | ||
| 5811 | } | ||
| 5812 | | undefined; | ||
| 5813 | |||
| 5814 | expect(dispatchRequest).toMatchObject({ | ||
| 5815 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, | ||
| 5816 | type: "project.create", | ||
| 5817 | workspaceRoot: "~/Development", | ||
| 5818 | title: "Development", | ||
| 5819 | }); | ||
| 5820 | }, | ||
| 5821 | { timeout: 8_000, interval: 16 }, | ||
| 5822 | ); | ||
| 5823 | |||
| 5824 | await waitForURL( | ||
| 5825 | mounted.router, | ||
| 5826 | (path) => UUID_ROUTE_RE.test(path), | ||
| 5827 | "Route should have changed to a new draft thread after adding a project with Enter.", | ||
| 5828 | ); | ||
| 5829 | } finally { | ||
| 5830 | await mounted.cleanup(); | ||
| 5831 | } | ||
| 5832 | }); | ||
| 5833 | |||
| 5834 | it("shows clone destination controls after resolving an add project repository", async () => { | ||
| 5835 | const mounted = await mountChatView({ | ||
| 5836 | viewport: DEFAULT_VIEWPORT, | ||
| 5837 | snapshot: createSnapshotForTargetUser({ | ||
| 5838 | targetMessageId: "msg-user-command-palette-add-project-remote" as MessageId, | ||
| 5839 | targetText: "command palette add project remote", | ||
| 5840 | }), | ||
| 5841 | configureFixture: (nextFixture) => { | ||
| 5842 | nextFixture.serverConfig = { | ||
| 5843 | ...nextFixture.serverConfig, | ||
| 5844 | keybindings: [ | ||
| 5845 | { | ||
| 5846 | command: "commandPalette.toggle", | ||
| 5847 | shortcut: { | ||
| 5848 | key: "k", | ||
| 5849 | metaKey: false, | ||
| 5850 | ctrlKey: false, | ||
| 5851 | shiftKey: false, | ||
| 5852 | altKey: false, | ||
| 5853 | modKey: true, | ||
| 5854 | }, | ||
| 5855 | whenAst: { | ||
| 5856 | type: "not", | ||
| 5857 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 5858 | }, | ||
| 5859 | }, | ||
| 5860 | ], | ||
| 5861 | }; | ||
| 5862 | }, | ||
| 5863 | resolveRpc: (body) => { | ||
| 5864 | if (body._tag === WS_METHODS.filesystemBrowse) { | ||
| 5865 | return { | ||
| 5866 | parentPath: "~/", | ||
| 5867 | entries: [{ name: "Development", fullPath: "~/Development" }], | ||
| 5868 | }; | ||
| 5869 | } | ||
| 5870 | |||
| 5871 | if (body._tag === WS_METHODS.sourceControlLookupRepository) { | ||
| 5872 | return { | ||
| 5873 | provider: "github", | ||
| 5874 | nameWithOwner: "t3-oss/t3-env", | ||
| 5875 | url: "https://github.com/t3-oss/t3-env", | ||
| 5876 | sshUrl: "git@github.com:t3-oss/t3-env.git", | ||
| 5877 | }; | ||
| 5878 | } | ||
| 5879 | |||
| 5880 | if (body._tag === WS_METHODS.sourceControlCloneRepository) { | ||
| 5881 | return { | ||
| 5882 | cwd: body.destinationPath, | ||
| 5883 | remoteUrl: body.remoteUrl, | ||
| 5884 | repository: null, | ||
| 5885 | }; | ||
| 5886 | } | ||
| 5887 | |||
| 5888 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 5889 | return { | ||
| 5890 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 5891 | }; | ||
| 5892 | } | ||
| 5893 | |||
| 5894 | return undefined; | ||
| 5895 | }, | ||
| 5896 | }); | ||
| 5897 | |||
| 5898 | try { | ||
| 5899 | await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); | ||
| 5900 | const palette = page.getByTestId("command-palette"); | ||
| 5901 | await openCommandPaletteFromTrigger(); | ||
| 5902 | |||
| 5903 | await expect.element(palette).toBeInTheDocument(); | ||
| 5904 | await palette.getByText("Add project", { exact: true }).click(); | ||
| 5905 | await palette.getByText("GitHub repository", { exact: true }).click(); | ||
| 5906 | |||
| 5907 | const repositoryInput = await waitForCommandPaletteInput( | ||
| 5908 | "Enter GitHub repository (owner/repo)", | ||
| 5909 | ); | ||
| 5910 | await page.getByPlaceholder("Enter GitHub repository (owner/repo)").fill("t3-oss/t3-env"); | ||
| 5911 | await dispatchInputKey(repositoryInput, { key: "Enter" }); | ||
| 5912 | |||
| 5913 | await vi.waitFor( | ||
| 5914 | () => { | ||
| 5915 | const clonePathInput = document.querySelector<HTMLInputElement>( | ||
| 5916 | 'input[placeholder="Enter path (e.g. ~/projects/my-app)"]', | ||
| 5917 | ); | ||
| 5918 | expect(clonePathInput?.value).toBe("~/"); | ||
| 5919 | expect(document.body.textContent).toContain("Repository"); | ||
| 5920 | expect(document.body.textContent).toContain("t3-oss/t3-env"); | ||
| 5921 | expect(document.body.textContent).toContain("https://github.com/t3-oss/t3-env"); | ||
| 5922 | expect(document.body.textContent).toContain("Select where to clone"); | ||
| 5923 | expect(document.body.textContent).toContain("Development"); | ||
| 5924 | expect(document.body.textContent).toContain("Clone"); | ||
| 5925 | }, | ||
| 5926 | { timeout: 8_000, interval: 16 }, | ||
| 5927 | ); | ||
| 5928 | |||
| 5929 | await page | ||
| 5930 | .getByPlaceholder("Enter path (e.g. ~/projects/my-app)") | ||
| 5931 | .fill("~/Development/t3env"); | ||
| 5932 | const clonePathInput = await waitForCommandPaletteInput( | ||
| 5933 | "Enter path (e.g. ~/projects/my-app)", | ||
| 5934 | ); | ||
| 5935 | await dispatchInputKey(clonePathInput, { key: "Enter" }); | ||
| 5936 | |||
| 5937 | await vi.waitFor( | ||
| 5938 | () => { | ||
| 5939 | const cloneRequest = wsRequests.find( | ||
| 5940 | (request) => request._tag === WS_METHODS.sourceControlCloneRepository, | ||
| 5941 | ) as { destinationPath?: string; remoteUrl?: string } | undefined; | ||
| 5942 | expect(cloneRequest).toMatchObject({ | ||
| 5943 | remoteUrl: "git@github.com:t3-oss/t3-env.git", | ||
| 5944 | destinationPath: "~/Development/t3env", | ||
| 5945 | }); | ||
| 5946 | }, | ||
| 5947 | { timeout: 8_000, interval: 16 }, | ||
| 5948 | ); | ||
| 5949 | } finally { | ||
| 5950 | await mounted.cleanup(); | ||
| 5951 | } | ||
| 5952 | }); | ||
| 5953 | |||
| 5954 | it("opens add project browse mode from the sidebar add button", async () => { | ||
| 5955 | const mounted = await mountChatView({ | ||
| 5956 | viewport: DEFAULT_VIEWPORT, | ||
| 5957 | snapshot: createSnapshotForTargetUser({ | ||
| 5958 | targetMessageId: "msg-user-sidebar-add-project-trigger" as MessageId, | ||
| 5959 | targetText: "sidebar add project trigger", | ||
| 5960 | }), | ||
| 5961 | resolveRpc: (body) => { | ||
| 5962 | if (body._tag === WS_METHODS.filesystemBrowse) { | ||
| 5963 | return { | ||
| 5964 | parentPath: "~/", | ||
| 5965 | entries: [{ name: "Development", fullPath: "~/Development" }], | ||
| 5966 | }; | ||
| 5967 | } | ||
| 5968 | |||
| 5969 | return undefined; | ||
| 5970 | }, | ||
| 5971 | }); | ||
| 5972 | |||
| 5973 | try { | ||
| 5974 | await waitForServerConfigToApply(); | ||
| 5975 | |||
| 5976 | await page.getByTestId("sidebar-add-project-trigger").click(); | ||
| 5977 | |||
| 5978 | const palette = page.getByTestId("command-palette"); | ||
| 5979 | await expect.element(palette).toBeInTheDocument(); | ||
| 5980 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 5981 | |||
| 5982 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | ||
| 5983 | await expect.element(browseInput).toHaveValue("~/"); | ||
| 5984 | |||
| 5985 | await vi.waitFor( | ||
| 5986 | () => { | ||
| 5987 | expect( | ||
| 5988 | wsRequests.some( | ||
| 5989 | (request) => | ||
| 5990 | request._tag === WS_METHODS.filesystemBrowse && request.partialPath === "~/", | ||
| 5991 | ), | ||
| 5992 | ).toBe(true); | ||
| 5993 | }, | ||
| 5994 | { timeout: 8_000, interval: 16 }, | ||
| 5995 | ); | ||
| 5996 | } finally { | ||
| 5997 | await mounted.cleanup(); | ||
| 5998 | } | ||
| 5999 | }); | ||
| 6000 | |||
| 6001 | it("starts add project browse mode from the configured base directory", async () => { | ||
| 6002 | const mounted = await mountChatView({ | ||
| 6003 | viewport: DEFAULT_VIEWPORT, | ||
| 6004 | snapshot: createSnapshotForTargetUser({ | ||
| 6005 | targetMessageId: "msg-user-sidebar-add-project-custom-base-dir" as MessageId, | ||
| 6006 | targetText: "sidebar add project custom base directory", | ||
| 6007 | }), | ||
| 6008 | configureFixture: (nextFixture) => { | ||
| 6009 | nextFixture.serverConfig = { | ||
| 6010 | ...nextFixture.serverConfig, | ||
| 6011 | settings: { | ||
| 6012 | ...nextFixture.serverConfig.settings, | ||
| 6013 | addProjectBaseDirectory: "~/Development", | ||
| 6014 | }, | ||
| 6015 | }; | ||
| 6016 | }, | ||
| 6017 | resolveRpc: (body) => { | ||
| 6018 | if (body._tag === WS_METHODS.filesystemBrowse) { | ||
| 6019 | if (body.partialPath === "~/Development/") { | ||
| 6020 | return { | ||
| 6021 | parentPath: "~/Development/", | ||
| 6022 | entries: [{ name: "codething", fullPath: "~/Development/codething" }], | ||
| 6023 | }; | ||
| 6024 | } | ||
| 6025 | |||
| 6026 | return { | ||
| 6027 | parentPath: "~/", | ||
| 6028 | entries: [{ name: "Development", fullPath: "~/Development" }], | ||
| 6029 | }; | ||
| 6030 | } | ||
| 6031 | |||
| 6032 | return undefined; | ||
| 6033 | }, | ||
| 6034 | }); | ||
| 6035 | |||
| 6036 | try { | ||
| 6037 | await waitForServerConfigToApply(); | ||
| 6038 | |||
| 6039 | await page.getByTestId("sidebar-add-project-trigger").click(); | ||
| 6040 | |||
| 6041 | const palette = page.getByTestId("command-palette"); | ||
| 6042 | await expect.element(palette).toBeInTheDocument(); | ||
| 6043 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 6044 | |||
| 6045 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | ||
| 6046 | await expect.element(browseInput).toHaveValue("~/Development/"); | ||
| 6047 | |||
| 6048 | await vi.waitFor( | ||
| 6049 | () => { | ||
| 6050 | expect( | ||
| 6051 | wsRequests.some( | ||
| 6052 | (request) => | ||
| 6053 | request._tag === WS_METHODS.filesystemBrowse && | ||
| 6054 | request.partialPath === "~/Development/", | ||
| 6055 | ), | ||
| 6056 | ).toBe(true); | ||
| 6057 | }, | ||
| 6058 | { timeout: 8_000, interval: 16 }, | ||
| 6059 | ); | ||
| 6060 | } finally { | ||
| 6061 | await mounted.cleanup(); | ||
| 6062 | } | ||
| 6063 | }); | ||
| 6064 | |||
| 6065 | it("shows create-folder affordances for missing project paths", async () => { | ||
| 6066 | const mounted = await mountChatView({ | ||
| 6067 | viewport: DEFAULT_VIEWPORT, | ||
| 6068 | snapshot: createSnapshotForTargetUser({ | ||
| 6069 | targetMessageId: "msg-user-command-palette-create-missing-project" as MessageId, | ||
| 6070 | targetText: "command palette create missing project", | ||
| 6071 | }), | ||
| 6072 | resolveRpc: (body) => { | ||
| 6073 | if (body._tag === WS_METHODS.filesystemBrowse) { | ||
| 6074 | if (body.partialPath === "~/Desktop/") { | ||
| 6075 | return { | ||
| 6076 | parentPath: "~/Desktop/", | ||
| 6077 | entries: [{ name: "existing", fullPath: "~/Desktop/existing" }], | ||
| 6078 | }; | ||
| 6079 | } | ||
| 6080 | |||
| 6081 | return { | ||
| 6082 | parentPath: "~/", | ||
| 6083 | entries: [{ name: "Desktop", fullPath: "~/Desktop" }], | ||
| 6084 | }; | ||
| 6085 | } | ||
| 6086 | |||
| 6087 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 6088 | return { | ||
| 6089 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 6090 | }; | ||
| 6091 | } | ||
| 6092 | |||
| 6093 | return undefined; | ||
| 6094 | }, | ||
| 6095 | }); | ||
| 6096 | |||
| 6097 | try { | ||
| 6098 | await waitForServerConfigToApply(); | ||
| 6099 | const palette = page.getByTestId("command-palette"); | ||
| 6100 | await page.getByTestId("sidebar-add-project-trigger").click(); | ||
| 6101 | |||
| 6102 | await expect.element(palette).toBeInTheDocument(); | ||
| 6103 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 6104 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | ||
| 6105 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project"); | ||
| 6106 | |||
| 6107 | await expect | ||
| 6108 | .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) | ||
| 6109 | .toBeInTheDocument(); | ||
| 6110 | await expect.element(palette.getByText("Will create this folder")).not.toBeInTheDocument(); | ||
| 6111 | |||
| 6112 | await dispatchInputKey(browseInput, { key: "Enter" }); | ||
| 6113 | |||
| 6114 | await vi.waitFor( | ||
| 6115 | () => { | ||
| 6116 | const dispatchRequest = wsRequests.find( | ||
| 6117 | (request) => | ||
| 6118 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 6119 | request.type === "project.create", | ||
| 6120 | ) as | ||
| 6121 | | { | ||
| 6122 | _tag: string; | ||
| 6123 | type?: string; | ||
| 6124 | workspaceRoot?: string; | ||
| 6125 | title?: string; | ||
| 6126 | createWorkspaceRootIfMissing?: boolean; | ||
| 6127 | } | ||
| 6128 | | undefined; | ||
| 6129 | |||
| 6130 | expect(dispatchRequest).toMatchObject({ | ||
| 6131 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, | ||
| 6132 | type: "project.create", | ||
| 6133 | workspaceRoot: "~/Desktop/fresh-project", | ||
| 6134 | title: "fresh-project", | ||
| 6135 | createWorkspaceRootIfMissing: true, | ||
| 6136 | }); | ||
| 6137 | }, | ||
| 6138 | { timeout: 8_000, interval: 16 }, | ||
| 6139 | ); | ||
| 6140 | } finally { | ||
| 6141 | await mounted.cleanup(); | ||
| 6142 | } | ||
| 6143 | }); | ||
| 6144 | |||
| 6145 | it("does not show create affordances for an existing directory with a trailing slash", async () => { | ||
| 6146 | const mounted = await mountChatView({ | ||
| 6147 | viewport: DEFAULT_VIEWPORT, | ||
| 6148 | snapshot: createSnapshotForTargetUser({ | ||
| 6149 | targetMessageId: "msg-user-command-palette-existing-trailing-directory" as MessageId, | ||
| 6150 | targetText: "command palette existing trailing directory", | ||
| 6151 | }), | ||
| 6152 | resolveRpc: (body) => { | ||
| 6153 | if (body._tag === WS_METHODS.filesystemBrowse) { | ||
| 6154 | if (body.partialPath === "~/Development/codex/") { | ||
| 6155 | return { | ||
| 6156 | parentPath: "~/Development/codex/", | ||
| 6157 | entries: [{ name: "Codex.app", fullPath: "~/Development/codex/Codex.app" }], | ||
| 6158 | }; | ||
| 6159 | } | ||
| 6160 | |||
| 6161 | return { | ||
| 6162 | parentPath: "~/", | ||
| 6163 | entries: [{ name: "Development", fullPath: "~/Development" }], | ||
| 6164 | }; | ||
| 6165 | } | ||
| 6166 | |||
| 6167 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 6168 | return { | ||
| 6169 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 6170 | }; | ||
| 6171 | } | ||
| 6172 | |||
| 6173 | return undefined; | ||
| 6174 | }, | ||
| 6175 | }); | ||
| 6176 | |||
| 6177 | try { | ||
| 6178 | await waitForServerConfigToApply(); | ||
| 6179 | const palette = page.getByTestId("command-palette"); | ||
| 6180 | await page.getByTestId("sidebar-add-project-trigger").click(); | ||
| 6181 | |||
| 6182 | await expect.element(palette).toBeInTheDocument(); | ||
| 6183 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 6184 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | ||
| 6185 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/"); | ||
| 6186 | |||
| 6187 | await vi.waitFor( | ||
| 6188 | () => { | ||
| 6189 | expect( | ||
| 6190 | wsRequests.some( | ||
| 6191 | (request) => | ||
| 6192 | request._tag === WS_METHODS.filesystemBrowse && | ||
| 6193 | request.partialPath === "~/Development/codex/", | ||
| 6194 | ), | ||
| 6195 | ).toBe(true); | ||
| 6196 | }, | ||
| 6197 | { timeout: 8_000, interval: 16 }, | ||
| 6198 | ); | ||
| 6199 | |||
| 6200 | await expect | ||
| 6201 | .element(palette.getByRole("button", { name: "Add (Enter)" })) | ||
| 6202 | .toBeInTheDocument(); | ||
| 6203 | await expect | ||
| 6204 | .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) | ||
| 6205 | .not.toBeInTheDocument(); | ||
| 6206 | |||
| 6207 | await dispatchInputKey(browseInput, { key: "Enter" }); | ||
| 6208 | |||
| 6209 | await vi.waitFor( | ||
| 6210 | () => { | ||
| 6211 | const dispatchRequest = wsRequests.find( | ||
| 6212 | (request) => | ||
| 6213 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 6214 | request.type === "project.create", | ||
| 6215 | ) as | ||
| 6216 | | { | ||
| 6217 | _tag: string; | ||
| 6218 | type?: string; | ||
| 6219 | workspaceRoot?: string; | ||
| 6220 | title?: string; | ||
| 6221 | } | ||
| 6222 | | undefined; | ||
| 6223 | |||
| 6224 | expect(dispatchRequest).toMatchObject({ | ||
| 6225 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, | ||
| 6226 | type: "project.create", | ||
| 6227 | workspaceRoot: "~/Development/codex", | ||
| 6228 | title: "codex", | ||
| 6229 | }); | ||
| 6230 | }, | ||
| 6231 | { timeout: 8_000, interval: 16 }, | ||
| 6232 | ); | ||
| 6233 | } finally { | ||
| 6234 | await mounted.cleanup(); | ||
| 6235 | } | ||
| 6236 | }); | ||
| 6237 | |||
| 6238 | it("selects an environment before browsing when multiple environments are available", async () => { | ||
| 6239 | const remoteBrowseMock = vi.fn(async ({ partialPath }: { partialPath: string }) => { | ||
| 6240 | if (partialPath === "~/workspaces/") { | ||
| 6241 | return { | ||
| 6242 | parentPath: "~/workspaces/", | ||
| 6243 | entries: [{ name: "codething", fullPath: "~/workspaces/codething" }], | ||
| 6244 | }; | ||
| 6245 | } | ||
| 6246 | |||
| 6247 | return { | ||
| 6248 | parentPath: "~/", | ||
| 6249 | entries: [{ name: "workspaces", fullPath: "~/workspaces" }], | ||
| 6250 | }; | ||
| 6251 | }); | ||
| 6252 | const remoteDispatchMock = vi.fn(async () => ({ | ||
| 6253 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 6254 | })); | ||
| 6255 | |||
| 6256 | __setEnvironmentApiOverrideForTests( | ||
| 6257 | REMOTE_ENVIRONMENT_ID, | ||
| 6258 | createMockEnvironmentApi({ | ||
| 6259 | browse: remoteBrowseMock, | ||
| 6260 | dispatchCommand: remoteDispatchMock, | ||
| 6261 | }), | ||
| 6262 | ); | ||
| 6263 | |||
| 6264 | const mounted = await mountChatView({ | ||
| 6265 | viewport: DEFAULT_VIEWPORT, | ||
| 6266 | snapshot: createSnapshotForTargetUser({ | ||
| 6267 | targetMessageId: "msg-user-command-palette-add-project-multi-env" as MessageId, | ||
| 6268 | targetText: "command palette add project multi env", | ||
| 6269 | }), | ||
| 6270 | }); | ||
| 6271 | |||
| 6272 | try { | ||
| 6273 | await waitForServerConfigToApply(); | ||
| 6274 | useSavedEnvironmentRegistryStore.getState().upsert({ | ||
| 6275 | environmentId: REMOTE_ENVIRONMENT_ID, | ||
| 6276 | label: "Staging", | ||
| 6277 | httpBaseUrl: "https://staging.example.test", | ||
| 6278 | wsBaseUrl: "wss://staging.example.test/ws", | ||
| 6279 | createdAt: NOW_ISO, | ||
| 6280 | lastConnectedAt: NOW_ISO, | ||
| 6281 | }); | ||
| 6282 | useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, { | ||
| 6283 | connectionState: "connected", | ||
| 6284 | authState: "authenticated", | ||
| 6285 | descriptor: { | ||
| 6286 | ...fixture.serverConfig.environment, | ||
| 6287 | environmentId: REMOTE_ENVIRONMENT_ID, | ||
| 6288 | label: "Staging", | ||
| 6289 | }, | ||
| 6290 | serverConfig: { | ||
| 6291 | ...fixture.serverConfig, | ||
| 6292 | environment: { | ||
| 6293 | ...fixture.serverConfig.environment, | ||
| 6294 | environmentId: REMOTE_ENVIRONMENT_ID, | ||
| 6295 | label: "Staging", | ||
| 6296 | }, | ||
| 6297 | settings: { | ||
| 6298 | ...fixture.serverConfig.settings, | ||
| 6299 | addProjectBaseDirectory: "~/workspaces", | ||
| 6300 | }, | ||
| 6301 | }, | ||
| 6302 | connectedAt: NOW_ISO, | ||
| 6303 | }); | ||
| 6304 | |||
| 6305 | const palette = page.getByTestId("command-palette"); | ||
| 6306 | await openCommandPaletteFromTrigger(); | ||
| 6307 | |||
| 6308 | await expect.element(palette).toBeInTheDocument(); | ||
| 6309 | await palette.getByText("Add project", { exact: true }).click(); | ||
| 6310 | await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); | ||
| 6311 | await expect | ||
| 6312 | .element(palette.getByText("This device", { exact: true }).first()) | ||
| 6313 | .toBeInTheDocument(); | ||
| 6314 | await palette.getByText("Staging", { exact: true }).click(); | ||
| 6315 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 6316 | |||
| 6317 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | ||
| 6318 | await expect.element(browseInput).toHaveValue("~/workspaces/"); | ||
| 6319 | |||
| 6320 | await vi.waitFor( | ||
| 6321 | () => { | ||
| 6322 | expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); | ||
| 6323 | }, | ||
| 6324 | { timeout: 8_000, interval: 16 }, | ||
| 6325 | ); | ||
| 6326 | |||
| 6327 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/workspaces/"); | ||
| 6328 | await vi.waitFor( | ||
| 6329 | () => { | ||
| 6330 | expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); | ||
| 6331 | }, | ||
| 6332 | { timeout: 8_000, interval: 16 }, | ||
| 6333 | ); | ||
| 6334 | await expect.element(palette.getByText("codething", { exact: true })).toBeInTheDocument(); | ||
| 6335 | await expect | ||
| 6336 | .element(palette.getByRole("button", { name: "Add (Enter)" })) | ||
| 6337 | .toBeInTheDocument(); | ||
| 6338 | |||
| 6339 | await dispatchInputKey(browseInput, { key: "Enter" }); | ||
| 6340 | |||
| 6341 | await vi.waitFor( | ||
| 6342 | () => { | ||
| 6343 | expect(remoteDispatchMock).toHaveBeenCalledWith( | ||
| 6344 | expect.objectContaining({ | ||
| 6345 | type: "project.create", | ||
| 6346 | workspaceRoot: "~/workspaces", | ||
| 6347 | title: "workspaces", | ||
| 6348 | }), | ||
| 6349 | ); | ||
| 6350 | }, | ||
| 6351 | { timeout: 8_000, interval: 16 }, | ||
| 6352 | ); | ||
| 6353 | |||
| 6354 | await waitForURL( | ||
| 6355 | mounted.router, | ||
| 6356 | (path) => UUID_ROUTE_RE.test(path), | ||
| 6357 | "Route should have changed to a new draft thread after adding a remote project.", | ||
| 6358 | ); | ||
| 6359 | } finally { | ||
| 6360 | await mounted.cleanup(); | ||
| 6361 | } | ||
| 6362 | }); | ||
| 6363 | |||
| 6364 | it("picks a local project from the native file manager", async () => { | ||
| 6365 | const pickFolder = vi.fn().mockResolvedValue("/Users/julius/Projects/finder-picked"); | ||
| 6366 | |||
| 6367 | const mounted = await mountChatView({ | ||
| 6368 | viewport: DEFAULT_VIEWPORT, | ||
| 6369 | snapshot: createSnapshotForTargetUser({ | ||
| 6370 | targetMessageId: "msg-user-command-palette-add-project-file-manager" as MessageId, | ||
| 6371 | targetText: "command palette add project file manager", | ||
| 6372 | }), | ||
| 6373 | resolveRpc: (body) => { | ||
| 6374 | if (body._tag === WS_METHODS.filesystemBrowse) { | ||
| 6375 | if (body.partialPath === "~/Applications/") { | ||
| 6376 | return { | ||
| 6377 | parentPath: "~/Applications/", | ||
| 6378 | entries: [{ name: "Utilities", fullPath: "~/Applications/Utilities" }], | ||
| 6379 | }; | ||
| 6380 | } | ||
| 6381 | |||
| 6382 | return { | ||
| 6383 | parentPath: "~/", | ||
| 6384 | entries: [{ name: "Applications", fullPath: "~/Applications" }], | ||
| 6385 | }; | ||
| 6386 | } | ||
| 6387 | |||
| 6388 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 6389 | return { | ||
| 6390 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 6391 | }; | ||
| 6392 | } | ||
| 6393 | |||
| 6394 | return undefined; | ||
| 6395 | }, | ||
| 6396 | }); | ||
| 6397 | |||
| 6398 | try { | ||
| 6399 | await waitForServerConfigToApply(); | ||
| 6400 | window.desktopBridge = { | ||
| 6401 | pickFolder, | ||
| 6402 | setTheme: vi.fn().mockResolvedValue(undefined), | ||
| 6403 | } as unknown as NonNullable<typeof window.desktopBridge>; | ||
| 6404 | |||
| 6405 | await page.getByTestId("sidebar-add-project-trigger").click(); | ||
| 6406 | |||
| 6407 | const palette = page.getByTestId("command-palette"); | ||
| 6408 | await expect.element(palette).toBeInTheDocument(); | ||
| 6409 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 6410 | const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER); | ||
| 6411 | await browseInput.fill("~/Applications/access"); | ||
| 6412 | |||
| 6413 | const fileManagerLabel = isMacPlatform(navigator.platform) | ||
| 6414 | ? "Open in Finder" | ||
| 6415 | : navigator.platform.toLowerCase().startsWith("win") | ||
| 6416 | ? "Open in Explorer" | ||
| 6417 | : "Open in Files"; | ||
| 6418 | await palette.getByRole("button", { name: fileManagerLabel }).click(); | ||
| 6419 | |||
| 6420 | await vi.waitFor( | ||
| 6421 | () => { | ||
| 6422 | expect(pickFolder).toHaveBeenCalledWith({ initialPath: "~/Applications" }); | ||
| 6423 | }, | ||
| 6424 | { timeout: 8_000, interval: 16 }, | ||
| 6425 | ); | ||
| 6426 | |||
| 6427 | await vi.waitFor( | ||
| 6428 | () => { | ||
| 6429 | const dispatchRequest = wsRequests.find( | ||
| 6430 | (request) => | ||
| 6431 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 6432 | request.type === "project.create", | ||
| 6433 | ) as | ||
| 6434 | | { | ||
| 6435 | _tag: string; | ||
| 6436 | type?: string; | ||
| 6437 | workspaceRoot?: string; | ||
| 6438 | title?: string; | ||
| 6439 | } | ||
| 6440 | | undefined; | ||
| 6441 | |||
| 6442 | expect(dispatchRequest).toMatchObject({ | ||
| 6443 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, | ||
| 6444 | type: "project.create", | ||
| 6445 | workspaceRoot: "/Users/julius/Projects/finder-picked", | ||
| 6446 | title: "finder-picked", | ||
| 6447 | }); | ||
| 6448 | }, | ||
| 6449 | { timeout: 8_000, interval: 16 }, | ||
| 6450 | ); | ||
| 6451 | |||
| 6452 | await waitForURL( | ||
| 6453 | mounted.router, | ||
| 6454 | (path) => UUID_ROUTE_RE.test(path), | ||
| 6455 | "Route should have changed to a new draft thread after adding a project from the native file manager.", | ||
| 6456 | ); | ||
| 6457 | } finally { | ||
| 6458 | await mounted.cleanup(); | ||
| 6459 | } | ||
| 6460 | }); | ||
| 6461 | |||
| 6462 | it("adds a project from browse mode with Mod+Enter when a directory is highlighted", async () => { | ||
| 6463 | const mounted = await mountChatView({ | ||
| 6464 | viewport: DEFAULT_VIEWPORT, | ||
| 6465 | snapshot: createSnapshotForTargetUser({ | ||
| 6466 | targetMessageId: "msg-user-command-palette-add-project-mod-enter" as MessageId, | ||
| 6467 | targetText: "command palette add project mod enter", | ||
| 6468 | }), | ||
| 6469 | configureFixture: (nextFixture) => { | ||
| 6470 | nextFixture.serverConfig = { | ||
| 6471 | ...nextFixture.serverConfig, | ||
| 6472 | keybindings: [ | ||
| 6473 | { | ||
| 6474 | command: "commandPalette.toggle", | ||
| 6475 | shortcut: { | ||
| 6476 | key: "k", | ||
| 6477 | metaKey: false, | ||
| 6478 | ctrlKey: false, | ||
| 6479 | shiftKey: false, | ||
| 6480 | altKey: false, | ||
| 6481 | modKey: true, | ||
| 6482 | }, | ||
| 6483 | whenAst: { | ||
| 6484 | type: "not", | ||
| 6485 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 6486 | }, | ||
| 6487 | }, | ||
| 6488 | ], | ||
| 6489 | }; | ||
| 6490 | }, | ||
| 6491 | resolveRpc: (body) => { | ||
| 6492 | if (body._tag === WS_METHODS.filesystemBrowse) { | ||
| 6493 | if (body.partialPath === "~/Development/") { | ||
| 6494 | return { | ||
| 6495 | parentPath: "~/Development/", | ||
| 6496 | entries: [ | ||
| 6497 | { name: "alpha", fullPath: "~/Development/alpha" }, | ||
| 6498 | { name: "beta", fullPath: "~/Development/beta" }, | ||
| 6499 | ], | ||
| 6500 | }; | ||
| 6501 | } | ||
| 6502 | |||
| 6503 | return { | ||
| 6504 | parentPath: "~/", | ||
| 6505 | entries: [{ name: "Development", fullPath: "~/Development" }], | ||
| 6506 | }; | ||
| 6507 | } | ||
| 6508 | |||
| 6509 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 6510 | return { | ||
| 6511 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 6512 | }; | ||
| 6513 | } | ||
| 6514 | |||
| 6515 | return undefined; | ||
| 6516 | }, | ||
| 6517 | }); | ||
| 6518 | |||
| 6519 | try { | ||
| 6520 | await waitForServerConfigToApply(); | ||
| 6521 | await waitForCommandPaletteShortcutLabel(); | ||
| 6522 | const palette = page.getByTestId("command-palette"); | ||
| 6523 | await openCommandPaletteFromTrigger(); | ||
| 6524 | |||
| 6525 | await expect.element(palette).toBeInTheDocument(); | ||
| 6526 | await palette.getByText("Add project", { exact: true }).click(); | ||
| 6527 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 6528 | |||
| 6529 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | ||
| 6530 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); | ||
| 6531 | await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); | ||
| 6532 | |||
| 6533 | await dispatchInputKey(browseInput, { key: "ArrowDown" }); | ||
| 6534 | |||
| 6535 | const addButtonLabel = isMacPlatform(navigator.platform) | ||
| 6536 | ? "Add (\u2318 Enter)" | ||
| 6537 | : "Add (Ctrl Enter)"; | ||
| 6538 | await vi.waitFor( | ||
| 6539 | () => { | ||
| 6540 | const legendEntries = getCommandPaletteLegendEntries(); | ||
| 6541 | expect(legendEntries).toContain("Enter Select"); | ||
| 6542 | }, | ||
| 6543 | { timeout: 8_000, interval: 16 }, | ||
| 6544 | ); | ||
| 6545 | await expect | ||
| 6546 | .element(palette.getByRole("button", { name: addButtonLabel })) | ||
| 6547 | .toBeInTheDocument(); | ||
| 6548 | |||
| 6549 | await dispatchInputKey(browseInput, { | ||
| 6550 | key: "Enter", | ||
| 6551 | metaKey: isMacPlatform(navigator.platform), | ||
| 6552 | ctrlKey: !isMacPlatform(navigator.platform), | ||
| 6553 | }); | ||
| 6554 | |||
| 6555 | await vi.waitFor( | ||
| 6556 | () => { | ||
| 6557 | const dispatchRequest = wsRequests.find( | ||
| 6558 | (request) => | ||
| 6559 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 6560 | request.type === "project.create", | ||
| 6561 | ) as | ||
| 6562 | | { | ||
| 6563 | _tag: string; | ||
| 6564 | type?: string; | ||
| 6565 | workspaceRoot?: string; | ||
| 6566 | title?: string; | ||
| 6567 | } | ||
| 6568 | | undefined; | ||
| 6569 | |||
| 6570 | expect(dispatchRequest).toMatchObject({ | ||
| 6571 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, | ||
| 6572 | type: "project.create", | ||
| 6573 | workspaceRoot: "~/Development", | ||
| 6574 | title: "Development", | ||
| 6575 | }); | ||
| 6576 | }, | ||
| 6577 | { timeout: 8_000, interval: 16 }, | ||
| 6578 | ); | ||
| 6579 | |||
| 6580 | await waitForURL( | ||
| 6581 | mounted.router, | ||
| 6582 | (path) => UUID_ROUTE_RE.test(path), | ||
| 6583 | "Route should have changed to a new draft thread after adding a project with Mod+Enter.", | ||
| 6584 | ); | ||
| 6585 | } finally { | ||
| 6586 | await mounted.cleanup(); | ||
| 6587 | } | ||
| 6588 | }); | ||
| 6589 | |||
| 6590 | it("keeps project-context thread matches available when searching by project name", async () => { | ||
| 6591 | const mounted = await mountChatView({ | ||
| 6592 | viewport: DEFAULT_VIEWPORT, | ||
| 6593 | snapshot: createSnapshotWithSecondaryProject(), | ||
| 6594 | configureFixture: (nextFixture) => { | ||
| 6595 | nextFixture.serverConfig = { | ||
| 6596 | ...nextFixture.serverConfig, | ||
| 6597 | keybindings: [ | ||
| 6598 | { | ||
| 6599 | command: "commandPalette.toggle", | ||
| 6600 | shortcut: { | ||
| 6601 | key: "k", | ||
| 6602 | metaKey: false, | ||
| 6603 | ctrlKey: false, | ||
| 6604 | shiftKey: false, | ||
| 6605 | altKey: false, | ||
| 6606 | modKey: true, | ||
| 6607 | }, | ||
| 6608 | whenAst: { | ||
| 6609 | type: "not", | ||
| 6610 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 6611 | }, | ||
| 6612 | }, | ||
| 6613 | ], | ||
| 6614 | }; | ||
| 6615 | }, | ||
| 6616 | }); | ||
| 6617 | |||
| 6618 | try { | ||
| 6619 | await waitForServerConfigToApply(); | ||
| 6620 | await waitForCommandPaletteShortcutLabel(); | ||
| 6621 | const palette = page.getByTestId("command-palette"); | ||
| 6622 | await openCommandPaletteFromTrigger(); | ||
| 6623 | |||
| 6624 | await expect.element(palette).toBeInTheDocument(); | ||
| 6625 | await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs"); | ||
| 6626 | await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); | ||
| 6627 | await expect | ||
| 6628 | .element(palette.getByText("Release checklist", { exact: true })) | ||
| 6629 | .toBeInTheDocument(); | ||
| 6630 | } finally { | ||
| 6631 | await mounted.cleanup(); | ||
| 6632 | } | ||
| 6633 | }); | ||
| 6634 | |||
| 6635 | it("searches projects by path and opens the latest thread for that project", async () => { | ||
| 6636 | const mounted = await mountChatView({ | ||
| 6637 | viewport: DEFAULT_VIEWPORT, | ||
| 6638 | snapshot: createSnapshotWithSecondaryProject(), | ||
| 6639 | configureFixture: (nextFixture) => { | ||
| 6640 | nextFixture.serverConfig = { | ||
| 6641 | ...nextFixture.serverConfig, | ||
| 6642 | settings: { | ||
| 6643 | ...nextFixture.serverConfig.settings, | ||
| 6644 | defaultThreadEnvMode: "worktree", | ||
| 6645 | }, | ||
| 6646 | keybindings: [ | ||
| 6647 | { | ||
| 6648 | command: "commandPalette.toggle", | ||
| 6649 | shortcut: { | ||
| 6650 | key: "k", | ||
| 6651 | metaKey: false, | ||
| 6652 | ctrlKey: false, | ||
| 6653 | shiftKey: false, | ||
| 6654 | altKey: false, | ||
| 6655 | modKey: true, | ||
| 6656 | }, | ||
| 6657 | whenAst: { | ||
| 6658 | type: "not", | ||
| 6659 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 6660 | }, | ||
| 6661 | }, | ||
| 6662 | ], | ||
| 6663 | }; | ||
| 6664 | }, | ||
| 6665 | }); | ||
| 6666 | |||
| 6667 | try { | ||
| 6668 | await waitForServerConfigToApply(); | ||
| 6669 | await waitForCommandPaletteShortcutLabel(); | ||
| 6670 | const palette = page.getByTestId("command-palette"); | ||
| 6671 | await openCommandPaletteFromTrigger(); | ||
| 6672 | |||
| 6673 | await expect.element(palette).toBeInTheDocument(); | ||
| 6674 | await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); | ||
| 6675 | await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); | ||
| 6676 | await expect | ||
| 6677 | .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) | ||
| 6678 | .toBeInTheDocument(); | ||
| 6679 | await palette.getByText("Docs Portal", { exact: true }).click(); | ||
| 6680 | |||
| 6681 | const nextPath = await waitForURL( | ||
| 6682 | mounted.router, | ||
| 6683 | (path) => path === serverThreadPath("thread-secondary-project" as ThreadId), | ||
| 6684 | "Route should have changed to the latest thread for the selected project.", | ||
| 6685 | ); | ||
| 6686 | expect(nextPath).toBe(serverThreadPath("thread-secondary-project" as ThreadId)); | ||
| 6687 | expect( | ||
| 6688 | useComposerDraftStore | ||
| 6689 | .getState() | ||
| 6690 | .getDraftThread(threadRefFor("thread-secondary-project" as ThreadId)), | ||
| 6691 | ).toBeNull(); | ||
| 6692 | } finally { | ||
| 6693 | await mounted.cleanup(); | ||
| 6694 | } | ||
| 6695 | }); | ||
| 6696 | |||
| 6697 | it("creates a new thread from project search when no active project thread exists", async () => { | ||
| 6698 | const mounted = await mountChatView({ | ||
| 6699 | viewport: DEFAULT_VIEWPORT, | ||
| 6700 | snapshot: createSnapshotWithSecondaryProject({ includeSecondaryThread: false }), | ||
| 6701 | configureFixture: (nextFixture) => { | ||
| 6702 | nextFixture.serverConfig = { | ||
| 6703 | ...nextFixture.serverConfig, | ||
| 6704 | settings: { | ||
| 6705 | ...nextFixture.serverConfig.settings, | ||
| 6706 | defaultThreadEnvMode: "worktree", | ||
| 6707 | }, | ||
| 6708 | keybindings: [ | ||
| 6709 | { | ||
| 6710 | command: "commandPalette.toggle", | ||
| 6711 | shortcut: { | ||
| 6712 | key: "k", | ||
| 6713 | metaKey: false, | ||
| 6714 | ctrlKey: false, | ||
| 6715 | shiftKey: false, | ||
| 6716 | altKey: false, | ||
| 6717 | modKey: true, | ||
| 6718 | }, | ||
| 6719 | whenAst: { | ||
| 6720 | type: "not", | ||
| 6721 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 6722 | }, | ||
| 6723 | }, | ||
| 6724 | ], | ||
| 6725 | }; | ||
| 6726 | }, | ||
| 6727 | }); | ||
| 6728 | |||
| 6729 | try { | ||
| 6730 | await waitForServerConfigToApply(); | ||
| 6731 | await waitForCommandPaletteShortcutLabel(); | ||
| 6732 | const palette = page.getByTestId("command-palette"); | ||
| 6733 | await openCommandPaletteFromTrigger(); | ||
| 6734 | |||
| 6735 | await expect.element(palette).toBeInTheDocument(); | ||
| 6736 | await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); | ||
| 6737 | await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); | ||
| 6738 | await expect | ||
| 6739 | .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) | ||
| 6740 | .toBeInTheDocument(); | ||
| 6741 | await palette.getByText("Docs Portal", { exact: true }).click(); | ||
| 6742 | |||
| 6743 | const nextPath = await waitForURL( | ||
| 6744 | mounted.router, | ||
| 6745 | (path) => UUID_ROUTE_RE.test(path), | ||
| 6746 | "Route should have changed to a new draft thread UUID from the project search result.", | ||
| 6747 | ); | ||
| 6748 | const nextDraftId = draftIdFromPath(nextPath); | ||
| 6749 | const draftThread = useComposerDraftStore.getState().getDraftSession(nextDraftId); | ||
| 6750 | expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); | ||
| 6751 | expect(draftThread?.envMode).toBe("worktree"); | ||
| 6752 | } finally { | ||
| 6753 | await mounted.cleanup(); | ||
| 6754 | } | ||
| 6755 | }); | ||
| 6756 | |||
| 6757 | it("filters archived threads out of command palette search results", async () => { | ||
| 6758 | const mounted = await mountChatView({ | ||
| 6759 | viewport: DEFAULT_VIEWPORT, | ||
| 6760 | snapshot: createSnapshotWithSecondaryProject(), | ||
| 6761 | configureFixture: (nextFixture) => { | ||
| 6762 | nextFixture.serverConfig = { | ||
| 6763 | ...nextFixture.serverConfig, | ||
| 6764 | keybindings: [ | ||
| 6765 | { | ||
| 6766 | command: "commandPalette.toggle", | ||
| 6767 | shortcut: { | ||
| 6768 | key: "k", | ||
| 6769 | metaKey: false, | ||
| 6770 | ctrlKey: false, | ||
| 6771 | shiftKey: false, | ||
| 6772 | altKey: false, | ||
| 6773 | modKey: true, | ||
| 6774 | }, | ||
| 6775 | whenAst: { | ||
| 6776 | type: "not", | ||
| 6777 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 6778 | }, | ||
| 6779 | }, | ||
| 6780 | ], | ||
| 6781 | }; | ||
| 6782 | }, | ||
| 6783 | }); | ||
| 6784 | |||
| 6785 | try { | ||
| 6786 | await waitForServerConfigToApply(); | ||
| 6787 | await waitForCommandPaletteShortcutLabel(); | ||
| 6788 | const palette = page.getByTestId("command-palette"); | ||
| 6789 | await openCommandPaletteFromTrigger(); | ||
| 6790 | |||
| 6791 | await expect.element(palette).toBeInTheDocument(); | ||
| 6792 | await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs-archive"); | ||
| 6793 | await expect | ||
| 6794 | .element(palette.getByText("Archived Docs Notes", { exact: true })) | ||
| 6795 | .not.toBeInTheDocument(); | ||
| 6796 | } finally { | ||
| 6797 | await mounted.cleanup(); | ||
| 6798 | } | ||
| 6799 | }); | ||
| 6800 | |||
| 6801 | it("creates a fresh draft after the previous draft thread is promoted", async () => { | ||
| 6802 | const mounted = await mountChatView({ | ||
| 6803 | viewport: DEFAULT_VIEWPORT, | ||
| 6804 | snapshot: createSnapshotForTargetUser({ | ||
| 6805 | targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId, | ||
| 6806 | targetText: "promoted draft shortcut test", | ||
| 6807 | }), | ||
| 6808 | configureFixture: (nextFixture) => { | ||
| 6809 | nextFixture.serverConfig = { | ||
| 6810 | ...nextFixture.serverConfig, | ||
| 6811 | keybindings: [ | ||
| 6812 | { | ||
| 6813 | command: "chat.new", | ||
| 6814 | shortcut: { | ||
| 6815 | key: "o", | ||
| 6816 | metaKey: false, | ||
| 6817 | ctrlKey: false, | ||
| 6818 | shiftKey: true, | ||
| 6819 | altKey: false, | ||
| 6820 | modKey: true, | ||
| 6821 | }, | ||
| 6822 | whenAst: { | ||
| 6823 | type: "not", | ||
| 6824 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 6825 | }, | ||
| 6826 | }, | ||
| 6827 | ], | ||
| 6828 | }; | ||
| 6829 | }, | ||
| 6830 | }); | ||
| 6831 | |||
| 6832 | try { | ||
| 6833 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 6834 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 6835 | await waitForServerConfigToApply(); | ||
| 6836 | await newThreadButton.click(); | ||
| 6837 | |||
| 6838 | const promotedThreadPath = await waitForURL( | ||
| 6839 | mounted.router, | ||
| 6840 | (path) => UUID_ROUTE_RE.test(path), | ||
| 6841 | "Route should have changed to a promoted draft thread UUID.", | ||
| 6842 | ); | ||
| 6843 | const promotedDraftId = draftIdFromPath(promotedThreadPath); | ||
| 6844 | const promotedThreadId = draftThreadIdFor(promotedDraftId); | ||
| 6845 | |||
| 6846 | await promoteDraftThreadViaDomainEvent(promotedThreadId); | ||
| 6847 | await waitForURL( | ||
| 6848 | mounted.router, | ||
| 6849 | (path) => path === serverThreadPath(promotedThreadId), | ||
| 6850 | "Promoted drafts should canonicalize to the server thread route before a fresh draft is created.", | ||
| 6851 | ); | ||
| 6852 | await vi.waitFor( | ||
| 6853 | () => { | ||
| 6854 | expect(useComposerDraftStore.getState().getDraftThread(promotedDraftId)).toBeNull(); | ||
| 6855 | }, | ||
| 6856 | { timeout: 8_000, interval: 16 }, | ||
| 6857 | ); | ||
| 6858 | const composerEditor = await waitForComposerEditor(); | ||
| 6859 | composerEditor.focus(); | ||
| 6860 | await waitForLayout(); | ||
| 6861 | |||
| 6862 | const freshThreadPath = await triggerChatNewShortcutUntilPath( | ||
| 6863 | mounted.router, | ||
| 6864 | (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, | ||
| 6865 | "Shortcut should create a fresh draft instead of reusing the promoted thread.", | ||
| 6866 | ); | ||
| 6867 | expect(freshThreadPath).not.toBe(promotedThreadPath); | ||
| 6868 | } finally { | ||
| 6869 | await mounted.cleanup(); | ||
| 6870 | } | ||
| 6871 | }); | ||
| 6872 | |||
| 6873 | it("keeps long proposed plans lightweight until the user expands them", async () => { | ||
| 6874 | const mounted = await mountChatView({ | ||
| 6875 | viewport: DEFAULT_VIEWPORT, | ||
| 6876 | snapshot: createSnapshotWithLongProposedPlan(), | ||
| 6877 | }); | ||
| 6878 | |||
| 6879 | try { | ||
| 6880 | await waitForElement( | ||
| 6881 | () => | ||
| 6882 | Array.from(document.querySelectorAll("button")).find( | ||
| 6883 | (button) => button.textContent?.trim() === "Expand plan", | ||
| 6884 | ) as HTMLButtonElement | null, | ||
| 6885 | "Unable to find Expand plan button.", | ||
| 6886 | ); | ||
| 6887 | |||
| 6888 | expect(document.body.textContent).not.toContain("deep hidden detail only after expand"); | ||
| 6889 | |||
| 6890 | const expandButton = await waitForElement( | ||
| 6891 | () => | ||
| 6892 | Array.from(document.querySelectorAll("button")).find( | ||
| 6893 | (button) => button.textContent?.trim() === "Expand plan", | ||
| 6894 | ) as HTMLButtonElement | null, | ||
| 6895 | "Unable to find Expand plan button.", | ||
| 6896 | ); | ||
| 6897 | expandButton.click(); | ||
| 6898 | |||
| 6899 | await vi.waitFor( | ||
| 6900 | () => { | ||
| 6901 | expect(document.body.textContent).toContain("deep hidden detail only after expand"); | ||
| 6902 | }, | ||
| 6903 | { timeout: 8_000, interval: 16 }, | ||
| 6904 | ); | ||
| 6905 | } finally { | ||
| 6906 | await mounted.cleanup(); | ||
| 6907 | } | ||
| 6908 | }); | ||
| 6909 | |||
| 6910 | it("uses the active worktree path when saving a proposed plan to the workspace", async () => { | ||
| 6911 | const snapshot = createSnapshotWithLongProposedPlan(); | ||
| 6912 | const threads = snapshot.threads.slice(); | ||
| 6913 | const targetThreadIndex = threads.findIndex((thread) => thread.id === THREAD_ID); | ||
| 6914 | const targetThread = targetThreadIndex >= 0 ? threads[targetThreadIndex] : undefined; | ||
| 6915 | if (targetThread) { | ||
| 6916 | threads[targetThreadIndex] = { | ||
| 6917 | ...targetThread, | ||
| 6918 | worktreePath: "/repo/worktrees/plan-thread", | ||
| 6919 | }; | ||
| 6920 | } | ||
| 6921 | |||
| 6922 | const mounted = await mountChatView({ | ||
| 6923 | viewport: DEFAULT_VIEWPORT, | ||
| 6924 | snapshot: { | ||
| 6925 | ...snapshot, | ||
| 6926 | threads, | ||
| 6927 | }, | ||
| 6928 | }); | ||
| 6929 | |||
| 6930 | try { | ||
| 6931 | const planActionsButton = await waitForElement( | ||
| 6932 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Plan actions"]'), | ||
| 6933 | "Unable to find proposed plan actions button.", | ||
| 6934 | ); | ||
| 6935 | planActionsButton.click(); | ||
| 6936 | |||
| 6937 | const saveToWorkspaceItem = await waitForElement( | ||
| 6938 | () => | ||
| 6939 | (Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find( | ||
| 6940 | (item) => item.textContent?.trim() === "Save to workspace", | ||
| 6941 | ) ?? null) as HTMLElement | null, | ||
| 6942 | 'Unable to find "Save to workspace" menu item.', | ||
| 6943 | ); | ||
| 6944 | saveToWorkspaceItem.click(); | ||
| 6945 | |||
| 6946 | await vi.waitFor( | ||
| 6947 | () => { | ||
| 6948 | expect(document.body.textContent).toContain( | ||
| 6949 | "Enter a path relative to /repo/worktrees/plan-thread.", | ||
| 6950 | ); | ||
| 6951 | }, | ||
| 6952 | { timeout: 8_000, interval: 16 }, | ||
| 6953 | ); | ||
| 6954 | } finally { | ||
| 6955 | await mounted.cleanup(); | ||
| 6956 | } | ||
| 6957 | }); | ||
| 6958 | |||
| 6959 | it("keeps pending-question footer actions inside the composer after a real resize", async () => { | ||
| 6960 | const mounted = await mountChatView({ | ||
| 6961 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 6962 | snapshot: createSnapshotWithPendingUserInput(), | ||
| 6963 | }); | ||
| 6964 | |||
| 6965 | try { | ||
| 6966 | const firstOption = await waitForButtonContainingText("Tight"); | ||
| 6967 | firstOption.click(); | ||
| 6968 | |||
| 6969 | await waitForButtonByText("Previous"); | ||
| 6970 | await waitForButtonByText("Submit answers"); | ||
| 6971 | |||
| 6972 | await mounted.setContainerSize(COMPACT_FOOTER_VIEWPORT); | ||
| 6973 | await expectComposerActionsContained(); | ||
| 6974 | } finally { | ||
| 6975 | await mounted.cleanup(); | ||
| 6976 | } | ||
| 6977 | }); | ||
| 6978 | |||
| 6979 | it("submits pending user input after the final option selection resolves the draft answers", async () => { | ||
| 6980 | const mounted = await mountChatView({ | ||
| 6981 | viewport: DEFAULT_VIEWPORT, | ||
| 6982 | snapshot: createSnapshotWithPendingUserInput(), | ||
| 6983 | resolveRpc: (body) => { | ||
| 6984 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | ||
| 6985 | return { | ||
| 6986 | sequence: fixture.snapshot.snapshotSequence + 1, | ||
| 6987 | }; | ||
| 6988 | } | ||
| 6989 | return undefined; | ||
| 6990 | }, | ||
| 6991 | }); | ||
| 6992 | |||
| 6993 | try { | ||
| 6994 | const firstOption = await waitForButtonContainingText("Tight"); | ||
| 6995 | firstOption.click(); | ||
| 6996 | |||
| 6997 | const finalOption = await waitForButtonContainingText("Conservative"); | ||
| 6998 | finalOption.click(); | ||
| 6999 | |||
| 7000 | await vi.waitFor( | ||
| 7001 | () => { | ||
| 7002 | const dispatchRequest = wsRequests.find( | ||
| 7003 | (request) => | ||
| 7004 | request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && | ||
| 7005 | request.type === "thread.user-input.respond", | ||
| 7006 | ) as | ||
| 7007 | | { | ||
| 7008 | _tag: string; | ||
| 7009 | type?: string; | ||
| 7010 | requestId?: string; | ||
| 7011 | answers?: Record<string, unknown>; | ||
| 7012 | } | ||
| 7013 | | undefined; | ||
| 7014 | |||
| 7015 | expect(dispatchRequest).toMatchObject({ | ||
| 7016 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, | ||
| 7017 | type: "thread.user-input.respond", | ||
| 7018 | requestId: "req-browser-user-input", | ||
| 7019 | answers: { | ||
| 7020 | scope: "Tight", | ||
| 7021 | risk: "Conservative", | ||
| 7022 | }, | ||
| 7023 | }); | ||
| 7024 | }, | ||
| 7025 | { timeout: 8_000, interval: 16 }, | ||
| 7026 | ); | ||
| 7027 | } finally { | ||
| 7028 | await mounted.cleanup(); | ||
| 7029 | } | ||
| 7030 | }); | ||
| 7031 | |||
| 7032 | it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => { | ||
| 7033 | const mounted = await mountChatView({ | ||
| 7034 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 7035 | snapshot: createSnapshotWithPlanFollowUpPrompt(), | ||
| 7036 | }); | ||
| 7037 | |||
| 7038 | try { | ||
| 7039 | const footer = await waitForElement( | ||
| 7040 | () => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'), | ||
| 7041 | "Unable to find composer footer.", | ||
| 7042 | ); | ||
| 7043 | const initialModelPicker = await waitForElement( | ||
| 7044 | findComposerProviderModelPicker, | ||
| 7045 | "Unable to find provider model picker.", | ||
| 7046 | ); | ||
| 7047 | const initialModelPickerOffset = | ||
| 7048 | initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left; | ||
| 7049 | const initialImplementButton = await waitForButtonByText("Implement"); | ||
| 7050 | const initialImplementWidth = initialImplementButton.getBoundingClientRect().width; | ||
| 7051 | |||
| 7052 | await waitForElement( | ||
| 7053 | () => | ||
| 7054 | document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'), | ||
| 7055 | "Unable to find implementation actions trigger.", | ||
| 7056 | ); | ||
| 7057 | |||
| 7058 | await mounted.setContainerSize({ | ||
| 7059 | width: 440, | ||
| 7060 | height: WIDE_FOOTER_VIEWPORT.height, | ||
| 7061 | }); | ||
| 7062 | await expectComposerActionsContained(); | ||
| 7063 | |||
| 7064 | const implementButton = await waitForButtonByText("Implement"); | ||
| 7065 | const implementActionsButton = await waitForElement( | ||
| 7066 | () => | ||
| 7067 | document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'), | ||
| 7068 | "Unable to find implementation actions trigger.", | ||
| 7069 | ); | ||
| 7070 | |||
| 7071 | await vi.waitFor( | ||
| 7072 | () => { | ||
| 7073 | const implementRect = implementButton.getBoundingClientRect(); | ||
| 7074 | const implementActionsRect = implementActionsButton.getBoundingClientRect(); | ||
| 7075 | const compactModelPicker = findComposerProviderModelPicker(); | ||
| 7076 | expect(compactModelPicker).toBeTruthy(); | ||
| 7077 | |||
| 7078 | const compactModelPickerOffset = | ||
| 7079 | compactModelPicker!.getBoundingClientRect().left - footer.getBoundingClientRect().left; | ||
| 7080 | |||
| 7081 | expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1); | ||
| 7082 | expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1); | ||
| 7083 | expect(Math.abs(implementRect.width - initialImplementWidth)).toBeLessThanOrEqual(1); | ||
| 7084 | expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual( | ||
| 7085 | 1, | ||
| 7086 | ); | ||
| 7087 | }, | ||
| 7088 | { timeout: 8_000, interval: 16 }, | ||
| 7089 | ); | ||
| 7090 | } finally { | ||
| 7091 | await mounted.cleanup(); | ||
| 7092 | } | ||
| 7093 | }); | ||
| 7094 | |||
| 7095 | it("keeps the wide desktop follow-up layout expanded when the footer still fits", async () => { | ||
| 7096 | const mounted = await mountChatView({ | ||
| 7097 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 7098 | snapshot: createSnapshotWithPlanFollowUpPrompt({ | ||
| 7099 | modelSelection: { | ||
| 7100 | instanceId: ProviderInstanceId.make("codex"), | ||
| 7101 | model: "gpt-5.3-codex-spark", | ||
| 7102 | }, | ||
| 7103 | planMarkdown: | ||
| 7104 | "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", | ||
| 7105 | }), | ||
| 7106 | }); | ||
| 7107 | |||
| 7108 | try { | ||
| 7109 | await waitForButtonByText("Implement"); | ||
| 7110 | |||
| 7111 | await vi.waitFor( | ||
| 7112 | () => { | ||
| 7113 | const footer = document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'); | ||
| 7114 | const actions = document.querySelector<HTMLElement>( | ||
| 7115 | '[data-chat-composer-actions="right"]', | ||
| 7116 | ); | ||
| 7117 | |||
| 7118 | expect(footer?.dataset.chatComposerFooterCompact).toBe("false"); | ||
| 7119 | expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("false"); | ||
| 7120 | }, | ||
| 7121 | { timeout: 8_000, interval: 16 }, | ||
| 7122 | ); | ||
| 7123 | } finally { | ||
| 7124 | await mounted.cleanup(); | ||
| 7125 | } | ||
| 7126 | }); | ||
| 7127 | |||
| 7128 | it("compacts the footer when a wide desktop follow-up layout starts overflowing", async () => { | ||
| 7129 | const mounted = await mountChatView({ | ||
| 7130 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 7131 | snapshot: createSnapshotWithPlanFollowUpPrompt({ | ||
| 7132 | modelSelection: { | ||
| 7133 | instanceId: ProviderInstanceId.make("codex"), | ||
| 7134 | model: "gpt-5.3-codex-spark", | ||
| 7135 | }, | ||
| 7136 | planMarkdown: | ||
| 7137 | "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", | ||
| 7138 | }), | ||
| 7139 | }); | ||
| 7140 | |||
| 7141 | try { | ||
| 7142 | await waitForButtonByText("Implement"); | ||
| 7143 | |||
| 7144 | await mounted.setContainerSize({ | ||
| 7145 | width: 804, | ||
| 7146 | height: WIDE_FOOTER_VIEWPORT.height, | ||
| 7147 | }); | ||
| 7148 | |||
| 7149 | await expectComposerActionsContained(); | ||
| 7150 | |||
| 7151 | await vi.waitFor( | ||
| 7152 | () => { | ||
| 7153 | const footer = document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'); | ||
| 7154 | const actions = document.querySelector<HTMLElement>( | ||
| 7155 | '[data-chat-composer-actions="right"]', | ||
| 7156 | ); | ||
| 7157 | |||
| 7158 | expect(footer?.dataset.chatComposerFooterCompact).toBe("true"); | ||
| 7159 | expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("true"); | ||
| 7160 | }, | ||
| 7161 | { timeout: 8_000, interval: 16 }, | ||
| 7162 | ); | ||
| 7163 | } finally { | ||
| 7164 | await mounted.cleanup(); | ||
| 7165 | } | ||
| 7166 | }); | ||
| 7167 | |||
| 7168 | it("keeps the slash-command menu visible above the composer", async () => { | ||
| 7169 | const mounted = await mountChatView({ | ||
| 7170 | viewport: DEFAULT_VIEWPORT, | ||
| 7171 | snapshot: createSnapshotForTargetUser({ | ||
| 7172 | targetMessageId: "msg-user-command-menu-target" as MessageId, | ||
| 7173 | targetText: "command menu thread", | ||
| 7174 | }), | ||
| 7175 | }); | ||
| 7176 | |||
| 7177 | try { | ||
| 7178 | await waitForComposerEditor(); | ||
| 7179 | await page.getByTestId("composer-editor").fill("/"); | ||
| 7180 | |||
| 7181 | const menuItem = await waitForComposerMenuItem("slash:model"); | ||
| 7182 | const composerForm = await waitForElement( | ||
| 7183 | () => document.querySelector<HTMLElement>('[data-chat-composer-form="true"]'), | ||
| 7184 | "Unable to find composer form.", | ||
| 7185 | ); | ||
| 7186 | |||
| 7187 | await vi.waitFor( | ||
| 7188 | () => { | ||
| 7189 | const menuRect = menuItem.getBoundingClientRect(); | ||
| 7190 | const composerRect = composerForm.getBoundingClientRect(); | ||
| 7191 | const hitTarget = document.elementFromPoint( | ||
| 7192 | menuRect.left + menuRect.width / 2, | ||
| 7193 | menuRect.top + menuRect.height / 2, | ||
| 7194 | ); | ||
| 7195 | |||
| 7196 | expect(menuRect.width).toBeGreaterThan(0); | ||
| 7197 | expect(menuRect.height).toBeGreaterThan(0); | ||
| 7198 | expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom); | ||
| 7199 | expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true); | ||
| 7200 | }, | ||
| 7201 | { timeout: 8_000, interval: 16 }, | ||
| 7202 | ); | ||
| 7203 | } finally { | ||
| 7204 | await mounted.cleanup(); | ||
| 7205 | } | ||
| 7206 | }); | ||
| 7207 | |||
| 7208 | it("opens the model picker when selecting /model", async () => { | ||
| 7209 | const mounted = await mountChatView({ | ||
| 7210 | viewport: DEFAULT_VIEWPORT, | ||
| 7211 | snapshot: createSnapshotForTargetUser({ | ||
| 7212 | targetMessageId: "msg-user-model-command-target" as MessageId, | ||
| 7213 | targetText: "model command thread", | ||
| 7214 | }), | ||
| 7215 | }); | ||
| 7216 | |||
| 7217 | try { | ||
| 7218 | await waitForComposerEditor(); | ||
| 7219 | await page.getByTestId("composer-editor").fill("/mod"); | ||
| 7220 | |||
| 7221 | const menuItem = await waitForComposerMenuItem("slash:model"); | ||
| 7222 | await menuItem.click(); | ||
| 7223 | |||
| 7224 | await vi.waitFor(() => { | ||
| 7225 | expect(document.querySelector(".model-picker-list")).not.toBeNull(); | ||
| 7226 | expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model"); | ||
| 7227 | }); | ||
| 7228 | |||
| 7229 | await new Promise<void>((resolve) => { | ||
| 7230 | requestAnimationFrame(() => { | ||
| 7231 | requestAnimationFrame(() => resolve()); | ||
| 7232 | }); | ||
| 7233 | }); | ||
| 7234 | |||
| 7235 | await vi.waitFor(() => { | ||
| 7236 | const searchInput = document.querySelector<HTMLInputElement>( | ||
| 7237 | 'input[placeholder="Search models..."]', | ||
| 7238 | ); | ||
| 7239 | expect(searchInput).not.toBeNull(); | ||
| 7240 | expect(document.activeElement).toBe(searchInput); | ||
| 7241 | }); | ||
| 7242 | } finally { | ||
| 7243 | await mounted.cleanup(); | ||
| 7244 | } | ||
| 7245 | }); | ||
| 7246 | |||
| 7247 | it("toggles the model picker and shows jump keys immediately from the shortcut", async () => { | ||
| 7248 | const snapshot = createSnapshotForTargetUser({ | ||
| 7249 | targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId, | ||
| 7250 | targetText: "model picker shortcut thread", | ||
| 7251 | }); | ||
| 7252 | const mounted = await mountChatView({ | ||
| 7253 | viewport: DEFAULT_VIEWPORT, | ||
| 7254 | snapshot: { | ||
| 7255 | ...snapshot, | ||
| 7256 | projects: snapshot.projects.map((project) => | ||
| 7257 | project.id === PROJECT_ID | ||
| 7258 | ? Object.assign({}, project, { | ||
| 7259 | defaultModelSelection: { | ||
| 7260 | instanceId: ProviderInstanceId.make("codex"), | ||
| 7261 | model: "gpt-5.4", | ||
| 7262 | }, | ||
| 7263 | }) | ||
| 7264 | : project, | ||
| 7265 | ), | ||
| 7266 | threads: snapshot.threads.map((thread) => | ||
| 7267 | thread.id === THREAD_ID | ||
| 7268 | ? Object.assign({}, thread, { | ||
| 7269 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 7270 | }) | ||
| 7271 | : thread, | ||
| 7272 | ), | ||
| 7273 | }, | ||
| 7274 | configureFixture: (nextFixture) => { | ||
| 7275 | nextFixture.serverConfig = { | ||
| 7276 | ...nextFixture.serverConfig, | ||
| 7277 | keybindings: [ | ||
| 7278 | { | ||
| 7279 | command: "modelPicker.toggle", | ||
| 7280 | shortcut: { | ||
| 7281 | key: "m", | ||
| 7282 | metaKey: false, | ||
| 7283 | ctrlKey: true, | ||
| 7284 | shiftKey: true, | ||
| 7285 | altKey: false, | ||
| 7286 | modKey: false, | ||
| 7287 | }, | ||
| 7288 | whenAst: { | ||
| 7289 | type: "not", | ||
| 7290 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 7291 | }, | ||
| 7292 | }, | ||
| 7293 | { | ||
| 7294 | command: "thread.jump.1", | ||
| 7295 | shortcut: { | ||
| 7296 | key: "1", | ||
| 7297 | metaKey: false, | ||
| 7298 | ctrlKey: true, | ||
| 7299 | shiftKey: false, | ||
| 7300 | altKey: false, | ||
| 7301 | modKey: false, | ||
| 7302 | }, | ||
| 7303 | }, | ||
| 7304 | { | ||
| 7305 | command: "modelPicker.jump.1", | ||
| 7306 | shortcut: { | ||
| 7307 | key: "1", | ||
| 7308 | metaKey: false, | ||
| 7309 | ctrlKey: true, | ||
| 7310 | shiftKey: false, | ||
| 7311 | altKey: false, | ||
| 7312 | modKey: false, | ||
| 7313 | }, | ||
| 7314 | whenAst: { type: "identifier", name: "modelPickerOpen" }, | ||
| 7315 | }, | ||
| 7316 | ], | ||
| 7317 | providers: [ | ||
| 7318 | { | ||
| 7319 | ...nextFixture.serverConfig.providers[0]!, | ||
| 7320 | models: [ | ||
| 7321 | { | ||
| 7322 | slug: "gpt-5.1-codex-max", | ||
| 7323 | name: "GPT-5.1 Codex Max", | ||
| 7324 | isCustom: false, | ||
| 7325 | capabilities: createModelCapabilities({ | ||
| 7326 | optionDescriptors: [ | ||
| 7327 | { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, | ||
| 7328 | ], | ||
| 7329 | }), | ||
| 7330 | }, | ||
| 7331 | { | ||
| 7332 | slug: "gpt-5.3-codex", | ||
| 7333 | name: "GPT-5.3 Codex", | ||
| 7334 | isCustom: false, | ||
| 7335 | capabilities: createModelCapabilities({ | ||
| 7336 | optionDescriptors: [ | ||
| 7337 | { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, | ||
| 7338 | ], | ||
| 7339 | }), | ||
| 7340 | }, | ||
| 7341 | { | ||
| 7342 | slug: "gpt-5.4", | ||
| 7343 | name: "GPT-5.4", | ||
| 7344 | isCustom: false, | ||
| 7345 | capabilities: createModelCapabilities({ | ||
| 7346 | optionDescriptors: [ | ||
| 7347 | { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, | ||
| 7348 | ], | ||
| 7349 | }), | ||
| 7350 | }, | ||
| 7351 | ], | ||
| 7352 | }, | ||
| 7353 | ], | ||
| 7354 | }; | ||
| 7355 | }, | ||
| 7356 | }); | ||
| 7357 | |||
| 7358 | try { | ||
| 7359 | await waitForServerConfigToApply(); | ||
| 7360 | await waitForComposerEditor(); | ||
| 7361 | |||
| 7362 | const initialPath = mounted.router.state.location.pathname; | ||
| 7363 | window.dispatchEvent( | ||
| 7364 | new KeyboardEvent("keydown", { | ||
| 7365 | key: "m", | ||
| 7366 | ctrlKey: true, | ||
| 7367 | shiftKey: true, | ||
| 7368 | bubbles: true, | ||
| 7369 | cancelable: true, | ||
| 7370 | }), | ||
| 7371 | ); | ||
| 7372 | |||
| 7373 | await vi.waitFor(() => { | ||
| 7374 | expect(document.querySelector(".model-picker-list")).not.toBeNull(); | ||
| 7375 | }); | ||
| 7376 | |||
| 7377 | const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1"; | ||
| 7378 | await vi.waitFor(() => { | ||
| 7379 | expect( | ||
| 7380 | Array.from( | ||
| 7381 | document.querySelectorAll<HTMLElement>('.model-picker-list [data-slot="kbd"]'), | ||
| 7382 | ).some((element) => element.textContent?.trim() === jumpLabel), | ||
| 7383 | ).toBe(true); | ||
| 7384 | }); | ||
| 7385 | expect(mounted.router.state.location.pathname).toBe(initialPath); | ||
| 7386 | |||
| 7387 | window.dispatchEvent( | ||
| 7388 | new KeyboardEvent("keydown", { | ||
| 7389 | key: "m", | ||
| 7390 | ctrlKey: true, | ||
| 7391 | shiftKey: true, | ||
| 7392 | bubbles: true, | ||
| 7393 | cancelable: true, | ||
| 7394 | }), | ||
| 7395 | ); | ||
| 7396 | |||
| 7397 | await vi.waitFor(() => { | ||
| 7398 | expect(document.querySelector(".model-picker-list")).toBeNull(); | ||
| 7399 | }); | ||
| 7400 | } finally { | ||
| 7401 | releaseModShortcut("Control"); | ||
| 7402 | await mounted.cleanup(); | ||
| 7403 | } | ||
| 7404 | }); | ||
| 7405 | |||
| 7406 | it("shows a tooltip with the skill description when hovering a skill pill", async () => { | ||
| 7407 | const mounted = await mountChatView({ | ||
| 7408 | viewport: DEFAULT_VIEWPORT, | ||
| 7409 | snapshot: createSnapshotForTargetUser({ | ||
| 7410 | targetMessageId: "msg-user-skill-tooltip-target" as MessageId, | ||
| 7411 | targetText: "skill tooltip thread", | ||
| 7412 | }), | ||
| 7413 | configureFixture: (nextFixture) => { | ||
| 7414 | const provider = nextFixture.serverConfig.providers[0]; | ||
| 7415 | if (!provider) { | ||
| 7416 | throw new Error("Expected default provider in test fixture."); | ||
| 7417 | } | ||
| 7418 | ( | ||
| 7419 | provider as { | ||
| 7420 | skills: ServerConfig["providers"][number]["skills"]; | ||
| 7421 | } | ||
| 7422 | ).skills = [ | ||
| 7423 | { | ||
| 7424 | name: "agent-browser", | ||
| 7425 | displayName: "Agent Browser", | ||
| 7426 | description: "Open pages, click around, and inspect web apps.", | ||
| 7427 | path: "/Users/test/.agents/skills/agent-browser/SKILL.md", | ||
| 7428 | enabled: true, | ||
| 7429 | }, | ||
| 7430 | ]; | ||
| 7431 | }, | ||
| 7432 | }); | ||
| 7433 | |||
| 7434 | try { | ||
| 7435 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "use the $agent-browser "); | ||
| 7436 | await waitForComposerText("use the $agent-browser "); | ||
| 7437 | |||
| 7438 | await waitForElement( | ||
| 7439 | () => document.querySelector<HTMLElement>('[data-composer-skill-chip="true"]'), | ||
| 7440 | "Unable to find rendered composer skill chip.", | ||
| 7441 | ); | ||
| 7442 | await page.getByText("Agent Browser").hover(); | ||
| 7443 | |||
| 7444 | await vi.waitFor( | ||
| 7445 | () => { | ||
| 7446 | const tooltip = document.querySelector<HTMLElement>('[data-slot="tooltip-popup"]'); | ||
| 7447 | expect(tooltip).not.toBeNull(); | ||
| 7448 | expect(tooltip?.textContent).toContain("Open pages, click around, and inspect web apps."); | ||
| 7449 | }, | ||
| 7450 | { timeout: 8_000, interval: 16 }, | ||
| 7451 | ); | ||
| 7452 | } finally { | ||
| 7453 | await mounted.cleanup(); | ||
| 7454 | } | ||
| 7455 | }); | ||
| 7456 | }); | ||
apps/web/src/components/ChatView.logic.test.ts+129-563| ... | @@ -1,16 +1,7 @@ | ... | @@ -1,16 +1,7 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | EnvironmentId, | ||
| 4 | ProjectId, | ||
| 5 | ProviderDriverKind, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | TurnId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 11 | import { type EnvironmentState, useStore } from "../store"; | ||
| 12 | import { type Thread } from "../types"; | ||
| 13 | 3 | ||
| 4 | import type { Thread } from "../types"; | ||
| 14 | import { | 5 | import { |
| 15 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | 6 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, |
| 16 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 7 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| ... | @@ -23,10 +14,60 @@ import { | ... | @@ -23,10 +14,60 @@ import { |
| 23 | reconcileRetainedMountedThreadIds, | 14 | reconcileRetainedMountedThreadIds, |
| 24 | resolveSendEnvMode, | 15 | resolveSendEnvMode, |
| 25 | shouldWriteThreadErrorToCurrentServerThread, | 16 | shouldWriteThreadErrorToCurrentServerThread, |
| 26 | waitForStartedServerThread, | ||
| 27 | } from "./ChatView.logic"; | 17 | } from "./ChatView.logic"; |
| 28 | 18 | ||
| 29 | const localEnvironmentId = EnvironmentId.make("environment-local"); | 19 | const environmentId = EnvironmentId.make("environment-local"); |
| 20 | const projectId = ProjectId.make("project-1"); | ||
| 21 | const threadId = ThreadId.make("thread-1"); | ||
| 22 | const now = "2026-03-29T00:00:00.000Z"; | ||
| 23 | |||
| 24 | function makeThread(overrides: Partial<Thread> = {}): Thread { | ||
| 25 | return { | ||
| 26 | id: threadId, | ||
| 27 | environmentId, | ||
| 28 | projectId, | ||
| 29 | title: "Thread", | ||
| 30 | modelSelection: { | ||
| 31 | instanceId: ProviderInstanceId.make("codex"), | ||
| 32 | model: "gpt-5.4", | ||
| 33 | }, | ||
| 34 | runtimeMode: "full-access", | ||
| 35 | interactionMode: "default", | ||
| 36 | session: null, | ||
| 37 | messages: [], | ||
| 38 | proposedPlans: [], | ||
| 39 | activities: [], | ||
| 40 | checkpoints: [], | ||
| 41 | createdAt: now, | ||
| 42 | updatedAt: now, | ||
| 43 | archivedAt: null, | ||
| 44 | deletedAt: null, | ||
| 45 | latestTurn: null, | ||
| 46 | branch: null, | ||
| 47 | worktreePath: null, | ||
| 48 | ...overrides, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | const completedTurn = { | ||
| 53 | turnId: TurnId.make("turn-1"), | ||
| 54 | state: "completed" as const, | ||
| 55 | requestedAt: now, | ||
| 56 | startedAt: "2026-03-29T00:00:01.000Z", | ||
| 57 | completedAt: "2026-03-29T00:00:10.000Z", | ||
| 58 | assistantMessageId: null, | ||
| 59 | }; | ||
| 60 | |||
| 61 | const readySession = { | ||
| 62 | threadId, | ||
| 63 | status: "ready" as const, | ||
| 64 | providerName: "codex", | ||
| 65 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 66 | runtimeMode: "full-access" as const, | ||
| 67 | activeTurnId: null, | ||
| 68 | lastError: null, | ||
| 69 | updatedAt: "2026-03-29T00:00:10.000Z", | ||
| 70 | }; | ||
| 30 | 71 | ||
| 31 | describe("deriveComposerSendState", () => { | 72 | describe("deriveComposerSendState", () => { |
| 32 | it("treats expired terminal pills as non-sendable content", () => { | 73 | it("treats expired terminal pills as non-sendable content", () => { |
| ... | @@ -36,13 +77,13 @@ describe("deriveComposerSendState", () => { | ... | @@ -36,13 +77,13 @@ describe("deriveComposerSendState", () => { |
| 36 | terminalContexts: [ | 77 | terminalContexts: [ |
| 37 | { | 78 | { |
| 38 | id: "ctx-expired", | 79 | id: "ctx-expired", |
| 39 | threadId: ThreadId.make("thread-1"), | 80 | threadId, |
| 40 | terminalId: "default", | 81 | terminalId: "default", |
| 41 | terminalLabel: "Terminal 1", | 82 | terminalLabel: "Terminal 1", |
| 42 | lineStart: 4, | 83 | lineStart: 4, |
| 43 | lineEnd: 4, | 84 | lineEnd: 4, |
| 44 | text: "", | 85 | text: "", |
| 45 | createdAt: "2026-03-17T12:52:29.000Z", | 86 | createdAt: now, |
| 46 | }, | 87 | }, |
| 47 | ], | 88 | ], |
| 48 | }); | 89 | }); |
| ... | @@ -60,13 +101,13 @@ describe("deriveComposerSendState", () => { | ... | @@ -60,13 +101,13 @@ describe("deriveComposerSendState", () => { |
| 60 | terminalContexts: [ | 101 | terminalContexts: [ |
| 61 | { | 102 | { |
| 62 | id: "ctx-expired", | 103 | id: "ctx-expired", |
| 63 | threadId: ThreadId.make("thread-1"), | 104 | threadId, |
| 64 | terminalId: "default", | 105 | terminalId: "default", |
| 65 | terminalLabel: "Terminal 1", | 106 | terminalLabel: "Terminal 1", |
| 66 | lineStart: 4, | 107 | lineStart: 4, |
| 67 | lineEnd: 4, | 108 | lineEnd: 4, |
| 68 | text: "", | 109 | text: "", |
| 69 | createdAt: "2026-03-17T12:52:29.000Z", | 110 | createdAt: now, |
| 70 | }, | 111 | }, |
| 71 | ], | 112 | ], |
| 72 | }); | 113 | }); |
| ... | @@ -102,14 +143,11 @@ describe("deriveComposerSendState", () => { | ... | @@ -102,14 +143,11 @@ describe("deriveComposerSendState", () => { |
| 102 | }); | 143 | }); |
| 103 | 144 | ||
| 104 | describe("buildExpiredTerminalContextToastCopy", () => { | 145 | describe("buildExpiredTerminalContextToastCopy", () => { |
| 105 | it("formats clear empty-state guidance", () => { | 146 | it("formats empty and omission guidance", () => { |
| 106 | expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ | 147 | expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ |
| 107 | title: "Expired terminal context won't be sent", | 148 | title: "Expired terminal context won't be sent", |
| 108 | description: "Remove it or re-add it to include terminal output.", | 149 | description: "Remove it or re-add it to include terminal output.", |
| 109 | }); | 150 | }); |
| 110 | }); | ||
| 111 | |||
| 112 | it("formats omission guidance for sent messages", () => { | ||
| 113 | expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ | 151 | expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ |
| 114 | title: "Expired terminal contexts omitted from message", | 152 | title: "Expired terminal contexts omitted from message", |
| 115 | description: "Re-add it if you want that terminal output included.", | 153 | description: "Re-add it if you want that terminal output included.", |
| ... | @@ -185,94 +223,38 @@ describe("getStartedThreadModelChangeBlockReason", () => { | ... | @@ -185,94 +223,38 @@ describe("getStartedThreadModelChangeBlockReason", () => { |
| 185 | }); | 223 | }); |
| 186 | 224 | ||
| 187 | describe("resolveSendEnvMode", () => { | 225 | describe("resolveSendEnvMode", () => { |
| 188 | it("keeps worktree mode for git repositories", () => { | 226 | it("keeps worktree mode only for git repositories", () => { |
| 189 | expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); | 227 | expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); |
| 190 | }); | ||
| 191 | |||
| 192 | it("forces local mode for non-git repositories", () => { | ||
| 193 | expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); | 228 | expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); |
| 194 | expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local"); | ||
| 195 | }); | 229 | }); |
| 196 | }); | 230 | }); |
| 197 | 231 | ||
| 198 | describe("reconcileMountedTerminalThreadIds", () => { | 232 | describe("reconcileMountedTerminalThreadIds", () => { |
| 199 | it("keeps previously mounted open threads and adds the active open thread", () => { | 233 | it("keeps open threads and makes the active thread most recent", () => { |
| 200 | expect( | ||
| 201 | reconcileMountedTerminalThreadIds({ | ||
| 202 | currentThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-stale")], | ||
| 203 | openThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-active")], | ||
| 204 | activeThreadId: ThreadId.make("thread-active"), | ||
| 205 | activeThreadTerminalOpen: true, | ||
| 206 | }), | ||
| 207 | ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); | ||
| 208 | }); | ||
| 209 | |||
| 210 | it("drops mounted threads once their terminal drawer is no longer open", () => { | ||
| 211 | expect( | ||
| 212 | reconcileMountedTerminalThreadIds({ | ||
| 213 | currentThreadIds: [ThreadId.make("thread-closed")], | ||
| 214 | openThreadIds: [], | ||
| 215 | activeThreadId: ThreadId.make("thread-closed"), | ||
| 216 | activeThreadTerminalOpen: false, | ||
| 217 | }), | ||
| 218 | ).toEqual([]); | ||
| 219 | }); | ||
| 220 | |||
| 221 | it("keeps only the most recently active hidden terminal threads", () => { | ||
| 222 | expect( | ||
| 223 | reconcileMountedTerminalThreadIds({ | ||
| 224 | currentThreadIds: [ | ||
| 225 | ThreadId.make("thread-1"), | ||
| 226 | ThreadId.make("thread-2"), | ||
| 227 | ThreadId.make("thread-3"), | ||
| 228 | ], | ||
| 229 | openThreadIds: [ | ||
| 230 | ThreadId.make("thread-1"), | ||
| 231 | ThreadId.make("thread-2"), | ||
| 232 | ThreadId.make("thread-3"), | ||
| 233 | ThreadId.make("thread-4"), | ||
| 234 | ], | ||
| 235 | activeThreadId: ThreadId.make("thread-4"), | ||
| 236 | activeThreadTerminalOpen: true, | ||
| 237 | maxHiddenThreadCount: 2, | ||
| 238 | }), | ||
| 239 | ).toEqual([ThreadId.make("thread-2"), ThreadId.make("thread-3"), ThreadId.make("thread-4")]); | ||
| 240 | }); | ||
| 241 | |||
| 242 | it("moves the active thread to the end so it is treated as most recently used", () => { | ||
| 243 | expect( | 234 | expect( |
| 244 | reconcileMountedTerminalThreadIds({ | 235 | reconcileMountedTerminalThreadIds({ |
| 245 | currentThreadIds: [ | 236 | currentThreadIds: ["thread-a", "thread-b", "thread-c"], |
| 246 | ThreadId.make("thread-a"), | 237 | openThreadIds: ["thread-a", "thread-b", "thread-c"], |
| 247 | ThreadId.make("thread-b"), | 238 | activeThreadId: "thread-a", |
| 248 | ThreadId.make("thread-c"), | ||
| 249 | ], | ||
| 250 | openThreadIds: [ | ||
| 251 | ThreadId.make("thread-a"), | ||
| 252 | ThreadId.make("thread-b"), | ||
| 253 | ThreadId.make("thread-c"), | ||
| 254 | ], | ||
| 255 | activeThreadId: ThreadId.make("thread-a"), | ||
| 256 | activeThreadTerminalOpen: true, | 239 | activeThreadTerminalOpen: true, |
| 257 | maxHiddenThreadCount: 2, | 240 | maxHiddenThreadCount: 2, |
| 258 | }), | 241 | }), |
| 259 | ).toEqual([ThreadId.make("thread-b"), ThreadId.make("thread-c"), ThreadId.make("thread-a")]); | 242 | ).toEqual(["thread-b", "thread-c", "thread-a"]); |
| 260 | }); | 243 | }); |
| 261 | 244 | ||
| 262 | it("defaults to the hidden mounted terminal cap", () => { | 245 | it("drops closed threads and enforces the hidden mounted cap", () => { |
| 263 | const currentThreadIds = Array.from( | 246 | const ids = Array.from( |
| 264 | { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, | 247 | { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, |
| 265 | (_, index) => ThreadId.make(`thread-${index + 1}`), | 248 | (_, index) => `thread-${index}`, |
| 266 | ); | 249 | ); |
| 267 | |||
| 268 | expect( | 250 | expect( |
| 269 | reconcileMountedTerminalThreadIds({ | 251 | reconcileMountedTerminalThreadIds({ |
| 270 | currentThreadIds, | 252 | currentThreadIds: ids, |
| 271 | openThreadIds: currentThreadIds, | 253 | openThreadIds: ids.slice(1), |
| 272 | activeThreadId: null, | 254 | activeThreadId: null, |
| 273 | activeThreadTerminalOpen: false, | 255 | activeThreadTerminalOpen: false, |
| 274 | }), | 256 | }), |
| 275 | ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); | 257 | ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); |
| 276 | }); | 258 | }); |
| 277 | }); | 259 | }); |
| 278 | 260 | ||
| ... | @@ -321,319 +303,38 @@ describe("reconcileRetainedMountedThreadIds", () => { | ... | @@ -321,319 +303,38 @@ describe("reconcileRetainedMountedThreadIds", () => { |
| 321 | }); | 303 | }); |
| 322 | 304 | ||
| 323 | describe("shouldWriteThreadErrorToCurrentServerThread", () => { | 305 | describe("shouldWriteThreadErrorToCurrentServerThread", () => { |
| 324 | it("routes errors to the active server thread when route and target match", () => { | 306 | it("requires the environment, route thread, and target thread to match", () => { |
| 325 | const threadId = ThreadId.make("thread-1"); | 307 | const routeThreadRef = { environmentId, threadId }; |
| 326 | const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId); | ||
| 327 | 308 | ||
| 328 | expect( | 309 | expect( |
| 329 | shouldWriteThreadErrorToCurrentServerThread({ | 310 | shouldWriteThreadErrorToCurrentServerThread({ |
| 330 | serverThread: { | 311 | serverThread: { environmentId, id: threadId }, |
| 331 | environmentId: localEnvironmentId, | ||
| 332 | id: threadId, | ||
| 333 | }, | ||
| 334 | routeThreadRef, | 312 | routeThreadRef, |
| 335 | targetThreadId: threadId, | 313 | targetThreadId: threadId, |
| 336 | }), | 314 | }), |
| 337 | ).toBe(true); | 315 | ).toBe(true); |
| 338 | }); | ||
| 339 | |||
| 340 | it("does not route draft-thread errors into server-backed state", () => { | ||
| 341 | const threadId = ThreadId.make("thread-1"); | ||
| 342 | |||
| 343 | expect( | 316 | expect( |
| 344 | shouldWriteThreadErrorToCurrentServerThread({ | 317 | shouldWriteThreadErrorToCurrentServerThread({ |
| 345 | serverThread: undefined, | 318 | serverThread: null, |
| 346 | routeThreadRef: scopeThreadRef(localEnvironmentId, threadId), | 319 | routeThreadRef, |
| 347 | targetThreadId: threadId, | 320 | targetThreadId: threadId, |
| 348 | }), | 321 | }), |
| 349 | ).toBe(false); | 322 | ).toBe(false); |
| 350 | }); | 323 | }); |
| 351 | }); | 324 | }); |
| 352 | 325 | ||
| 353 | const makeThread = (input?: { | ||
| 354 | id?: ThreadId; | ||
| 355 | latestTurn?: { | ||
| 356 | turnId: TurnId; | ||
| 357 | state: "running" | "completed"; | ||
| 358 | requestedAt: string; | ||
| 359 | startedAt: string | null; | ||
| 360 | completedAt: string | null; | ||
| 361 | } | null; | ||
| 362 | }): Thread => ({ | ||
| 363 | id: input?.id ?? ThreadId.make("thread-1"), | ||
| 364 | environmentId: localEnvironmentId, | ||
| 365 | codexThreadId: null, | ||
| 366 | projectId: ProjectId.make("project-1"), | ||
| 367 | title: "Thread", | ||
| 368 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 369 | runtimeMode: "full-access" as const, | ||
| 370 | interactionMode: "default" as const, | ||
| 371 | session: null, | ||
| 372 | messages: [], | ||
| 373 | proposedPlans: [], | ||
| 374 | error: null, | ||
| 375 | createdAt: "2026-03-29T00:00:00.000Z", | ||
| 376 | archivedAt: null, | ||
| 377 | updatedAt: "2026-03-29T00:00:00.000Z", | ||
| 378 | latestTurn: input?.latestTurn | ||
| 379 | ? { | ||
| 380 | ...input.latestTurn, | ||
| 381 | assistantMessageId: null, | ||
| 382 | } | ||
| 383 | : null, | ||
| 384 | branch: null, | ||
| 385 | worktreePath: null, | ||
| 386 | turnDiffSummaries: [], | ||
| 387 | activities: [], | ||
| 388 | }); | ||
| 389 | |||
| 390 | function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) { | ||
| 391 | const projectId = ProjectId.make("project-1"); | ||
| 392 | const environmentState: EnvironmentState = { | ||
| 393 | projectIds: [projectId], | ||
| 394 | projectById: { | ||
| 395 | [projectId]: { | ||
| 396 | id: projectId, | ||
| 397 | environmentId: localEnvironmentId, | ||
| 398 | name: "Project", | ||
| 399 | cwd: "/tmp/project", | ||
| 400 | defaultModelSelection: { | ||
| 401 | instanceId: ProviderInstanceId.make("codex"), | ||
| 402 | model: "gpt-5.4", | ||
| 403 | }, | ||
| 404 | createdAt: "2026-03-29T00:00:00.000Z", | ||
| 405 | updatedAt: "2026-03-29T00:00:00.000Z", | ||
| 406 | scripts: [], | ||
| 407 | }, | ||
| 408 | }, | ||
| 409 | threadIds: threads.map((thread) => thread.id), | ||
| 410 | threadIdsByProjectId: { | ||
| 411 | [projectId]: threads.map((thread) => thread.id), | ||
| 412 | }, | ||
| 413 | threadShellById: Object.fromEntries( | ||
| 414 | threads.map((thread) => [ | ||
| 415 | thread.id, | ||
| 416 | { | ||
| 417 | id: thread.id, | ||
| 418 | environmentId: thread.environmentId, | ||
| 419 | codexThreadId: thread.codexThreadId, | ||
| 420 | projectId: thread.projectId, | ||
| 421 | title: thread.title, | ||
| 422 | modelSelection: thread.modelSelection, | ||
| 423 | runtimeMode: thread.runtimeMode, | ||
| 424 | interactionMode: thread.interactionMode, | ||
| 425 | error: thread.error, | ||
| 426 | createdAt: thread.createdAt, | ||
| 427 | archivedAt: thread.archivedAt, | ||
| 428 | updatedAt: thread.updatedAt, | ||
| 429 | branch: thread.branch, | ||
| 430 | worktreePath: thread.worktreePath, | ||
| 431 | }, | ||
| 432 | ]), | ||
| 433 | ), | ||
| 434 | threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])), | ||
| 435 | threadTurnStateById: Object.fromEntries( | ||
| 436 | threads.map((thread) => [ | ||
| 437 | thread.id, | ||
| 438 | { | ||
| 439 | latestTurn: thread.latestTurn, | ||
| 440 | ...(thread.pendingSourceProposedPlan | ||
| 441 | ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } | ||
| 442 | : {}), | ||
| 443 | }, | ||
| 444 | ]), | ||
| 445 | ), | ||
| 446 | messageIdsByThreadId: Object.fromEntries( | ||
| 447 | threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]), | ||
| 448 | ), | ||
| 449 | messageByThreadId: Object.fromEntries( | ||
| 450 | threads.map((thread) => [ | ||
| 451 | thread.id, | ||
| 452 | Object.fromEntries(thread.messages.map((message) => [message.id, message])), | ||
| 453 | ]), | ||
| 454 | ), | ||
| 455 | activityIdsByThreadId: Object.fromEntries( | ||
| 456 | threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]), | ||
| 457 | ), | ||
| 458 | activityByThreadId: Object.fromEntries( | ||
| 459 | threads.map((thread) => [ | ||
| 460 | thread.id, | ||
| 461 | Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])), | ||
| 462 | ]), | ||
| 463 | ), | ||
| 464 | proposedPlanIdsByThreadId: Object.fromEntries( | ||
| 465 | threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]), | ||
| 466 | ), | ||
| 467 | proposedPlanByThreadId: Object.fromEntries( | ||
| 468 | threads.map((thread) => [ | ||
| 469 | thread.id, | ||
| 470 | Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])), | ||
| 471 | ]), | ||
| 472 | ), | ||
| 473 | turnDiffIdsByThreadId: Object.fromEntries( | ||
| 474 | threads.map((thread) => [ | ||
| 475 | thread.id, | ||
| 476 | thread.turnDiffSummaries.map((summary) => summary.turnId), | ||
| 477 | ]), | ||
| 478 | ), | ||
| 479 | turnDiffSummaryByThreadId: Object.fromEntries( | ||
| 480 | threads.map((thread) => [ | ||
| 481 | thread.id, | ||
| 482 | Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])), | ||
| 483 | ]), | ||
| 484 | ), | ||
| 485 | sidebarThreadSummaryById: {}, | ||
| 486 | bootstrapComplete: true, | ||
| 487 | }; | ||
| 488 | useStore.setState({ | ||
| 489 | activeEnvironmentId: localEnvironmentId, | ||
| 490 | environmentStateById: { | ||
| 491 | [localEnvironmentId]: environmentState, | ||
| 492 | }, | ||
| 493 | }); | ||
| 494 | } | ||
| 495 | |||
| 496 | afterEach(() => { | ||
| 497 | vi.useRealTimers(); | ||
| 498 | vi.restoreAllMocks(); | ||
| 499 | setStoreThreads([]); | ||
| 500 | }); | ||
| 501 | |||
| 502 | describe("waitForStartedServerThread", () => { | ||
| 503 | it("resolves immediately when the thread is already started", async () => { | ||
| 504 | const threadId = ThreadId.make("thread-started"); | ||
| 505 | setStoreThreads([ | ||
| 506 | makeThread({ | ||
| 507 | id: threadId, | ||
| 508 | latestTurn: { | ||
| 509 | turnId: TurnId.make("turn-started"), | ||
| 510 | state: "running", | ||
| 511 | requestedAt: "2026-03-29T00:00:01.000Z", | ||
| 512 | startedAt: "2026-03-29T00:00:01.000Z", | ||
| 513 | completedAt: null, | ||
| 514 | }, | ||
| 515 | }), | ||
| 516 | ]); | ||
| 517 | |||
| 518 | await expect( | ||
| 519 | waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)), | ||
| 520 | ).resolves.toBe(true); | ||
| 521 | }); | ||
| 522 | |||
| 523 | it("waits for the thread to start via subscription updates", async () => { | ||
| 524 | const threadId = ThreadId.make("thread-wait"); | ||
| 525 | setStoreThreads([makeThread({ id: threadId })]); | ||
| 526 | |||
| 527 | const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); | ||
| 528 | |||
| 529 | setStoreThreads([ | ||
| 530 | makeThread({ | ||
| 531 | id: threadId, | ||
| 532 | latestTurn: { | ||
| 533 | turnId: TurnId.make("turn-started"), | ||
| 534 | state: "running", | ||
| 535 | requestedAt: "2026-03-29T00:00:01.000Z", | ||
| 536 | startedAt: "2026-03-29T00:00:01.000Z", | ||
| 537 | completedAt: null, | ||
| 538 | }, | ||
| 539 | }), | ||
| 540 | ]); | ||
| 541 | |||
| 542 | await expect(promise).resolves.toBe(true); | ||
| 543 | }); | ||
| 544 | |||
| 545 | it("handles the thread starting between the initial read and subscription setup", async () => { | ||
| 546 | const threadId = ThreadId.make("thread-race"); | ||
| 547 | setStoreThreads([makeThread({ id: threadId })]); | ||
| 548 | |||
| 549 | const originalSubscribe = useStore.subscribe.bind(useStore); | ||
| 550 | let raced = false; | ||
| 551 | vi.spyOn(useStore, "subscribe").mockImplementation((listener) => { | ||
| 552 | if (!raced) { | ||
| 553 | raced = true; | ||
| 554 | setStoreThreads([ | ||
| 555 | makeThread({ | ||
| 556 | id: threadId, | ||
| 557 | latestTurn: { | ||
| 558 | turnId: TurnId.make("turn-race"), | ||
| 559 | state: "running", | ||
| 560 | requestedAt: "2026-03-29T00:00:01.000Z", | ||
| 561 | startedAt: "2026-03-29T00:00:01.000Z", | ||
| 562 | completedAt: null, | ||
| 563 | }, | ||
| 564 | }), | ||
| 565 | ]); | ||
| 566 | } | ||
| 567 | return originalSubscribe(listener); | ||
| 568 | }); | ||
| 569 | |||
| 570 | await expect( | ||
| 571 | waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500), | ||
| 572 | ).resolves.toBe(true); | ||
| 573 | }); | ||
| 574 | |||
| 575 | it("returns false after the timeout when the thread never starts", async () => { | ||
| 576 | vi.useFakeTimers(); | ||
| 577 | |||
| 578 | const threadId = ThreadId.make("thread-timeout"); | ||
| 579 | setStoreThreads([makeThread({ id: threadId })]); | ||
| 580 | const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); | ||
| 581 | |||
| 582 | await vi.advanceTimersByTimeAsync(500); | ||
| 583 | |||
| 584 | await expect(promise).resolves.toBe(false); | ||
| 585 | }); | ||
| 586 | }); | ||
| 587 | |||
| 588 | describe("hasServerAcknowledgedLocalDispatch", () => { | 326 | describe("hasServerAcknowledgedLocalDispatch", () => { |
| 589 | const projectId = ProjectId.make("project-1"); | 327 | it("does not acknowledge unchanged server state", () => { |
| 590 | const previousLatestTurn = { | 328 | const localDispatch = createLocalDispatchSnapshot( |
| 591 | turnId: TurnId.make("turn-1"), | 329 | makeThread({ latestTurn: completedTurn, session: readySession }), |
| 592 | state: "completed" as const, | 330 | ); |
| 593 | requestedAt: "2026-03-29T00:00:00.000Z", | ||
| 594 | startedAt: "2026-03-29T00:00:01.000Z", | ||
| 595 | completedAt: "2026-03-29T00:00:10.000Z", | ||
| 596 | assistantMessageId: null, | ||
| 597 | }; | ||
| 598 | |||
| 599 | const previousSession = { | ||
| 600 | provider: ProviderDriverKind.make("codex"), | ||
| 601 | status: "ready" as const, | ||
| 602 | createdAt: "2026-03-29T00:00:00.000Z", | ||
| 603 | updatedAt: "2026-03-29T00:00:10.000Z", | ||
| 604 | orchestrationStatus: "idle" as const, | ||
| 605 | }; | ||
| 606 | |||
| 607 | it("does not clear local dispatch before server state changes", () => { | ||
| 608 | const localDispatch = createLocalDispatchSnapshot({ | ||
| 609 | id: ThreadId.make("thread-1"), | ||
| 610 | environmentId: localEnvironmentId, | ||
| 611 | codexThreadId: null, | ||
| 612 | projectId, | ||
| 613 | title: "Thread", | ||
| 614 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 615 | runtimeMode: "full-access", | ||
| 616 | interactionMode: "default", | ||
| 617 | session: previousSession, | ||
| 618 | messages: [], | ||
| 619 | proposedPlans: [], | ||
| 620 | error: null, | ||
| 621 | createdAt: "2026-03-29T00:00:00.000Z", | ||
| 622 | archivedAt: null, | ||
| 623 | updatedAt: "2026-03-29T00:00:10.000Z", | ||
| 624 | latestTurn: previousLatestTurn, | ||
| 625 | branch: null, | ||
| 626 | worktreePath: null, | ||
| 627 | turnDiffSummaries: [], | ||
| 628 | activities: [], | ||
| 629 | }); | ||
| 630 | 331 | ||
| 631 | expect( | 332 | expect( |
| 632 | hasServerAcknowledgedLocalDispatch({ | 333 | hasServerAcknowledgedLocalDispatch({ |
| 633 | localDispatch, | 334 | localDispatch, |
| 634 | phase: "ready", | 335 | phase: "ready", |
| 635 | latestTurn: previousLatestTurn, | 336 | latestTurn: completedTurn, |
| 636 | session: previousSession, | 337 | session: readySession, |
| 637 | hasPendingApproval: false, | 338 | hasPendingApproval: false, |
| 638 | hasPendingUserInput: false, | 339 | hasPendingUserInput: false, |
| 639 | threadError: null, | 340 | threadError: null, |
| ... | @@ -641,45 +342,24 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -641,45 +342,24 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 641 | ).toBe(false); | 342 | ).toBe(false); |
| 642 | }); | 343 | }); |
| 643 | 344 | ||
| 644 | it("clears local dispatch when a new turn is already settled", () => { | 345 | it("acknowledges a settled newer turn", () => { |
| 645 | const localDispatch = createLocalDispatchSnapshot({ | 346 | const localDispatch = createLocalDispatchSnapshot( |
| 646 | id: ThreadId.make("thread-1"), | 347 | makeThread({ latestTurn: completedTurn, session: readySession }), |
| 647 | environmentId: localEnvironmentId, | 348 | ); |
| 648 | codexThreadId: null, | 349 | const newerTurn = { |
| 649 | projectId, | 350 | ...completedTurn, |
| 650 | title: "Thread", | 351 | turnId: TurnId.make("turn-2"), |
| 651 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | 352 | requestedAt: "2026-03-29T00:01:00.000Z", |
| 652 | runtimeMode: "full-access", | 353 | startedAt: "2026-03-29T00:01:01.000Z", |
| 653 | interactionMode: "default", | 354 | completedAt: "2026-03-29T00:01:30.000Z", |
| 654 | session: previousSession, | 355 | }; |
| 655 | messages: [], | ||
| 656 | proposedPlans: [], | ||
| 657 | error: null, | ||
| 658 | createdAt: "2026-03-29T00:00:00.000Z", | ||
| 659 | archivedAt: null, | ||
| 660 | updatedAt: "2026-03-29T00:00:10.000Z", | ||
| 661 | latestTurn: previousLatestTurn, | ||
| 662 | branch: null, | ||
| 663 | worktreePath: null, | ||
| 664 | turnDiffSummaries: [], | ||
| 665 | activities: [], | ||
| 666 | }); | ||
| 667 | 356 | ||
| 668 | expect( | 357 | expect( |
| 669 | hasServerAcknowledgedLocalDispatch({ | 358 | hasServerAcknowledgedLocalDispatch({ |
| 670 | localDispatch, | 359 | localDispatch, |
| 671 | phase: "ready", | 360 | phase: "ready", |
| 672 | latestTurn: { | 361 | latestTurn: newerTurn, |
| 673 | ...previousLatestTurn, | 362 | session: { ...readySession, updatedAt: newerTurn.completedAt }, |
| 674 | turnId: TurnId.make("turn-2"), | ||
| 675 | requestedAt: "2026-03-29T00:01:00.000Z", | ||
| 676 | startedAt: "2026-03-29T00:01:01.000Z", | ||
| 677 | completedAt: "2026-03-29T00:01:30.000Z", | ||
| 678 | }, | ||
| 679 | session: { | ||
| 680 | ...previousSession, | ||
| 681 | updatedAt: "2026-03-29T00:01:30.000Z", | ||
| 682 | }, | ||
| 683 | hasPendingApproval: false, | 363 | hasPendingApproval: false, |
| 684 | hasPendingUserInput: false, | 364 | hasPendingUserInput: false, |
| 685 | threadError: null, | 365 | threadError: null, |
| ... | @@ -687,134 +367,43 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -687,134 +367,43 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 687 | ).toBe(true); | 367 | ).toBe(true); |
| 688 | }); | 368 | }); |
| 689 | 369 | ||
| 690 | it("does not clear local dispatch while the session is running a newer turn than latestTurn", () => { | 370 | it("waits for the matching running turn before acknowledging", () => { |
| 691 | const localDispatch = createLocalDispatchSnapshot({ | 371 | const localDispatch = createLocalDispatchSnapshot( |
| 692 | id: ThreadId.make("thread-1"), | 372 | makeThread({ latestTurn: completedTurn, session: readySession }), |
| 693 | environmentId: localEnvironmentId, | 373 | ); |
| 694 | codexThreadId: null, | 374 | const runningTurn = { |
| 695 | projectId, | 375 | ...completedTurn, |
| 696 | title: "Thread", | 376 | turnId: TurnId.make("turn-2"), |
| 697 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | 377 | state: "running" as const, |
| 698 | runtimeMode: "full-access", | 378 | requestedAt: "2026-03-29T00:01:00.000Z", |
| 699 | interactionMode: "default", | 379 | startedAt: "2026-03-29T00:01:01.000Z", |
| 700 | session: previousSession, | 380 | completedAt: null, |
| 701 | messages: [], | 381 | }; |
| 702 | proposedPlans: [], | ||
| 703 | error: null, | ||
| 704 | createdAt: "2026-03-29T00:00:00.000Z", | ||
| 705 | archivedAt: null, | ||
| 706 | updatedAt: "2026-03-29T00:00:10.000Z", | ||
| 707 | latestTurn: previousLatestTurn, | ||
| 708 | branch: null, | ||
| 709 | worktreePath: null, | ||
| 710 | turnDiffSummaries: [], | ||
| 711 | activities: [], | ||
| 712 | }); | ||
| 713 | |||
| 714 | expect( | ||
| 715 | hasServerAcknowledgedLocalDispatch({ | ||
| 716 | localDispatch, | ||
| 717 | phase: "running", | ||
| 718 | latestTurn: previousLatestTurn, | ||
| 719 | session: { | ||
| 720 | ...previousSession, | ||
| 721 | status: "running", | ||
| 722 | orchestrationStatus: "running", | ||
| 723 | activeTurnId: TurnId.make("turn-2"), | ||
| 724 | updatedAt: "2026-03-29T00:01:00.000Z", | ||
| 725 | }, | ||
| 726 | hasPendingApproval: false, | ||
| 727 | hasPendingUserInput: false, | ||
| 728 | threadError: null, | ||
| 729 | }), | ||
| 730 | ).toBe(false); | ||
| 731 | }); | ||
| 732 | |||
| 733 | it("does not clear local dispatch while the session is running but latestTurn has not advanced yet", () => { | ||
| 734 | const localDispatch = createLocalDispatchSnapshot({ | ||
| 735 | id: ThreadId.make("thread-1"), | ||
| 736 | environmentId: localEnvironmentId, | ||
| 737 | codexThreadId: null, | ||
| 738 | projectId, | ||
| 739 | title: "Thread", | ||
| 740 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 741 | runtimeMode: "full-access", | ||
| 742 | interactionMode: "default", | ||
| 743 | session: previousSession, | ||
| 744 | messages: [], | ||
| 745 | proposedPlans: [], | ||
| 746 | error: null, | ||
| 747 | createdAt: "2026-03-29T00:00:00.000Z", | ||
| 748 | archivedAt: null, | ||
| 749 | updatedAt: "2026-03-29T00:00:10.000Z", | ||
| 750 | latestTurn: previousLatestTurn, | ||
| 751 | branch: null, | ||
| 752 | worktreePath: null, | ||
| 753 | turnDiffSummaries: [], | ||
| 754 | activities: [], | ||
| 755 | }); | ||
| 756 | 382 | ||
| 757 | expect( | 383 | expect( |
| 758 | hasServerAcknowledgedLocalDispatch({ | 384 | hasServerAcknowledgedLocalDispatch({ |
| 759 | localDispatch, | 385 | localDispatch, |
| 760 | phase: "running", | 386 | phase: "running", |
| 761 | latestTurn: previousLatestTurn, | 387 | latestTurn: runningTurn, |
| 762 | session: { | 388 | session: { |
| 763 | ...previousSession, | 389 | ...readySession, |
| 764 | status: "running", | 390 | status: "running", |
| 765 | orchestrationStatus: "running", | 391 | activeTurnId: TurnId.make("turn-other"), |
| 766 | activeTurnId: undefined, | ||
| 767 | updatedAt: "2026-03-29T00:01:00.000Z", | ||
| 768 | }, | 392 | }, |
| 769 | hasPendingApproval: false, | 393 | hasPendingApproval: false, |
| 770 | hasPendingUserInput: false, | 394 | hasPendingUserInput: false, |
| 771 | threadError: null, | 395 | threadError: null, |
| 772 | }), | 396 | }), |
| 773 | ).toBe(false); | 397 | ).toBe(false); |
| 774 | }); | ||
| 775 | |||
| 776 | it("clears local dispatch once the running latestTurn matches the active session turn", () => { | ||
| 777 | const localDispatch = createLocalDispatchSnapshot({ | ||
| 778 | id: ThreadId.make("thread-1"), | ||
| 779 | environmentId: localEnvironmentId, | ||
| 780 | codexThreadId: null, | ||
| 781 | projectId, | ||
| 782 | title: "Thread", | ||
| 783 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 784 | runtimeMode: "full-access", | ||
| 785 | interactionMode: "default", | ||
| 786 | session: previousSession, | ||
| 787 | messages: [], | ||
| 788 | proposedPlans: [], | ||
| 789 | error: null, | ||
| 790 | createdAt: "2026-03-29T00:00:00.000Z", | ||
| 791 | archivedAt: null, | ||
| 792 | updatedAt: "2026-03-29T00:00:10.000Z", | ||
| 793 | latestTurn: previousLatestTurn, | ||
| 794 | branch: null, | ||
| 795 | worktreePath: null, | ||
| 796 | turnDiffSummaries: [], | ||
| 797 | activities: [], | ||
| 798 | }); | ||
| 799 | |||
| 800 | expect( | 398 | expect( |
| 801 | hasServerAcknowledgedLocalDispatch({ | 399 | hasServerAcknowledgedLocalDispatch({ |
| 802 | localDispatch, | 400 | localDispatch, |
| 803 | phase: "running", | 401 | phase: "running", |
| 804 | latestTurn: { | 402 | latestTurn: runningTurn, |
| 805 | ...previousLatestTurn, | ||
| 806 | turnId: TurnId.make("turn-2"), | ||
| 807 | state: "running", | ||
| 808 | requestedAt: "2026-03-29T00:01:00.000Z", | ||
| 809 | startedAt: "2026-03-29T00:01:01.000Z", | ||
| 810 | completedAt: null, | ||
| 811 | }, | ||
| 812 | session: { | 403 | session: { |
| 813 | ...previousSession, | 404 | ...readySession, |
| 814 | status: "running", | 405 | status: "running", |
| 815 | orchestrationStatus: "running", | 406 | activeTurnId: runningTurn.turnId, |
| 816 | activeTurnId: TurnId.make("turn-2"), | ||
| 817 | updatedAt: "2026-03-29T00:01:01.000Z", | ||
| 818 | }, | 407 | }, |
| 819 | hasPendingApproval: false, | 408 | hasPendingApproval: false, |
| 820 | hasPendingUserInput: false, | 409 | hasPendingUserInput: false, |
| ... | @@ -823,43 +412,20 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -823,43 +412,20 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 823 | ).toBe(true); | 412 | ).toBe(true); |
| 824 | }); | 413 | }); |
| 825 | 414 | ||
| 826 | it("clears local dispatch when the session changes without an observed running phase", () => { | 415 | it("acknowledges pending user interaction and errors immediately", () => { |
| 827 | const localDispatch = createLocalDispatchSnapshot({ | 416 | const localDispatch = createLocalDispatchSnapshot(makeThread()); |
| 828 | id: ThreadId.make("thread-1"), | 417 | const common = { |
| 829 | environmentId: localEnvironmentId, | 418 | localDispatch, |
| 830 | codexThreadId: null, | 419 | phase: "ready" as const, |
| 831 | projectId, | 420 | latestTurn: null, |
| 832 | title: "Thread", | 421 | session: null, |
| 833 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | 422 | hasPendingApproval: false, |
| 834 | runtimeMode: "full-access", | 423 | hasPendingUserInput: false, |
| 835 | interactionMode: "default", | 424 | threadError: null, |
| 836 | session: previousSession, | 425 | }; |
| 837 | messages: [], | 426 | |
| 838 | proposedPlans: [], | 427 | expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); |
| 839 | error: null, | 428 | expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); |
| 840 | createdAt: "2026-03-29T00:00:00.000Z", | 429 | expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); |
| 841 | archivedAt: null, | ||
| 842 | updatedAt: "2026-03-29T00:00:10.000Z", | ||
| 843 | latestTurn: previousLatestTurn, | ||
| 844 | branch: null, | ||
| 845 | worktreePath: null, | ||
| 846 | turnDiffSummaries: [], | ||
| 847 | activities: [], | ||
| 848 | }); | ||
| 849 | |||
| 850 | expect( | ||
| 851 | hasServerAcknowledgedLocalDispatch({ | ||
| 852 | localDispatch, | ||
| 853 | phase: "ready", | ||
| 854 | latestTurn: previousLatestTurn, | ||
| 855 | session: { | ||
| 856 | ...previousSession, | ||
| 857 | updatedAt: "2026-03-29T00:00:11.000Z", | ||
| 858 | }, | ||
| 859 | hasPendingApproval: false, | ||
| 860 | hasPendingUserInput: false, | ||
| 861 | threadError: null, | ||
| 862 | }), | ||
| 863 | ).toBe(true); | ||
| 864 | }); | 430 | }); |
| 865 | }); | 431 | }); |
apps/web/src/components/ChatView.logic.ts+16-15| ... | @@ -9,10 +9,11 @@ import { | ... | @@ -9,10 +9,11 @@ import { |
| 9 | type ThreadId, | 9 | type ThreadId, |
| 10 | type TurnId, | 10 | type TurnId, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; | 12 | import { type ChatMessage, type SessionPhase, type Thread } from "../types"; |
| 13 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 13 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 14 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| 15 | import { selectThreadByRef, useStore } from "../store"; | 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 16 | import { environmentThreadDetails } from "../state/threads"; | ||
| 16 | import { | 17 | import { |
| 17 | filterTerminalContextsWithText, | 18 | filterTerminalContextsWithText, |
| 18 | stripInlineTerminalContextPlaceholders, | 19 | stripInlineTerminalContextPlaceholders, |
| ... | @@ -30,12 +31,10 @@ export function buildLocalDraftThread( | ... | @@ -30,12 +31,10 @@ export function buildLocalDraftThread( |
| 30 | threadId: ThreadId, | 31 | threadId: ThreadId, |
| 31 | draftThread: DraftThreadState, | 32 | draftThread: DraftThreadState, |
| 32 | fallbackModelSelection: ModelSelection, | 33 | fallbackModelSelection: ModelSelection, |
| 33 | error: string | null, | ||
| 34 | ): Thread { | 34 | ): Thread { |
| 35 | return { | 35 | return { |
| 36 | id: threadId, | 36 | id: threadId, |
| 37 | environmentId: draftThread.environmentId, | 37 | environmentId: draftThread.environmentId, |
| 38 | codexThreadId: null, | ||
| 39 | projectId: draftThread.projectId, | 38 | projectId: draftThread.projectId, |
| 40 | title: "New thread", | 39 | title: "New thread", |
| 41 | modelSelection: fallbackModelSelection, | 40 | modelSelection: fallbackModelSelection, |
| ... | @@ -43,13 +42,14 @@ export function buildLocalDraftThread( | ... | @@ -43,13 +42,14 @@ export function buildLocalDraftThread( |
| 43 | interactionMode: draftThread.interactionMode, | 42 | interactionMode: draftThread.interactionMode, |
| 44 | session: null, | 43 | session: null, |
| 45 | messages: [], | 44 | messages: [], |
| 46 | error, | ||
| 47 | createdAt: draftThread.createdAt, | 45 | createdAt: draftThread.createdAt, |
| 46 | updatedAt: draftThread.createdAt, | ||
| 48 | archivedAt: null, | 47 | archivedAt: null, |
| 48 | deletedAt: null, | ||
| 49 | latestTurn: null, | 49 | latestTurn: null, |
| 50 | branch: draftThread.branch, | 50 | branch: draftThread.branch, |
| 51 | worktreePath: draftThread.worktreePath, | 51 | worktreePath: draftThread.worktreePath, |
| 52 | turnDiffSummaries: [], | 52 | checkpoints: [], |
| 53 | activities: [], | 53 | activities: [], |
| 54 | proposedPlans: [], | 54 | proposedPlans: [], |
| 55 | }; | 55 | }; |
| ... | @@ -275,8 +275,8 @@ export function deriveLockedProvider(input: { | ... | @@ -275,8 +275,8 @@ export function deriveLockedProvider(input: { |
| 275 | if (!threadHasStarted(input.thread)) { | 275 | if (!threadHasStarted(input.thread)) { |
| 276 | return null; | 276 | return null; |
| 277 | } | 277 | } |
| 278 | const sessionProvider = input.thread?.session?.provider ?? null; | 278 | const sessionProvider = input.thread?.session?.providerName ?? null; |
| 279 | if (sessionProvider) { | 279 | if (sessionProvider && isProviderDriverKind(sessionProvider)) { |
| 280 | return sessionProvider; | 280 | return sessionProvider; |
| 281 | } | 281 | } |
| 282 | const narrowedThreadProvider = | 282 | const narrowedThreadProvider = |
| ... | @@ -332,7 +332,8 @@ export async function waitForStartedServerThread( | ... | @@ -332,7 +332,8 @@ export async function waitForStartedServerThread( |
| 332 | threadRef: ScopedThreadRef, | 332 | threadRef: ScopedThreadRef, |
| 333 | timeoutMs = 1_000, | 333 | timeoutMs = 1_000, |
| 334 | ): Promise<boolean> { | 334 | ): Promise<boolean> { |
| 335 | const getThread = () => selectThreadByRef(useStore.getState(), threadRef); | 335 | const threadAtom = environmentThreadDetails.detailAtom(threadRef); |
| 336 | const getThread = () => appAtomRegistry.get(threadAtom); | ||
| 336 | const thread = getThread(); | 337 | const thread = getThread(); |
| 337 | 338 | ||
| 338 | if (threadHasStarted(thread)) { | 339 | if (threadHasStarted(thread)) { |
| ... | @@ -354,8 +355,8 @@ export async function waitForStartedServerThread( | ... | @@ -354,8 +355,8 @@ export async function waitForStartedServerThread( |
| 354 | resolve(result); | 355 | resolve(result); |
| 355 | }; | 356 | }; |
| 356 | 357 | ||
| 357 | const unsubscribe = useStore.subscribe((state) => { | 358 | const unsubscribe = appAtomRegistry.subscribe(threadAtom, (thread) => { |
| 358 | if (!threadHasStarted(selectThreadByRef(state, threadRef))) { | 359 | if (!threadHasStarted(thread)) { |
| 359 | return; | 360 | return; |
| 360 | } | 361 | } |
| 361 | finish(true); | 362 | finish(true); |
| ... | @@ -379,7 +380,7 @@ export interface LocalDispatchSnapshot { | ... | @@ -379,7 +380,7 @@ export interface LocalDispatchSnapshot { |
| 379 | latestTurnRequestedAt: string | null; | 380 | latestTurnRequestedAt: string | null; |
| 380 | latestTurnStartedAt: string | null; | 381 | latestTurnStartedAt: string | null; |
| 381 | latestTurnCompletedAt: string | null; | 382 | latestTurnCompletedAt: string | null; |
| 382 | sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null; | 383 | sessionStatus: NonNullable<Thread["session"]>["status"] | null; |
| 383 | sessionUpdatedAt: string | null; | 384 | sessionUpdatedAt: string | null; |
| 384 | } | 385 | } |
| 385 | 386 | ||
| ... | @@ -396,7 +397,7 @@ export function createLocalDispatchSnapshot( | ... | @@ -396,7 +397,7 @@ export function createLocalDispatchSnapshot( |
| 396 | latestTurnRequestedAt: latestTurn?.requestedAt ?? null, | 397 | latestTurnRequestedAt: latestTurn?.requestedAt ?? null, |
| 397 | latestTurnStartedAt: latestTurn?.startedAt ?? null, | 398 | latestTurnStartedAt: latestTurn?.startedAt ?? null, |
| 398 | latestTurnCompletedAt: latestTurn?.completedAt ?? null, | 399 | latestTurnCompletedAt: latestTurn?.completedAt ?? null, |
| 399 | sessionOrchestrationStatus: session?.orchestrationStatus ?? null, | 400 | sessionStatus: session?.status ?? null, |
| 400 | sessionUpdatedAt: session?.updatedAt ?? null, | 401 | sessionUpdatedAt: session?.updatedAt ?? null, |
| 401 | }; | 402 | }; |
| 402 | } | 403 | } |
| ... | @@ -433,8 +434,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { | ... | @@ -433,8 +434,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 433 | return false; | 434 | return false; |
| 434 | } | 435 | } |
| 435 | if ( | 436 | if ( |
| 437 | session?.activeTurnId !== null && | ||
| 436 | session?.activeTurnId !== undefined && | 438 | session?.activeTurnId !== undefined && |
| 437 | session.activeTurnId !== null && | ||
| 438 | latestTurn?.turnId !== session.activeTurnId | 439 | latestTurn?.turnId !== session.activeTurnId |
| 439 | ) { | 440 | ) { |
| 440 | return false; | 441 | return false; |
| ... | @@ -444,7 +445,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { | ... | @@ -444,7 +445,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 444 | 445 | ||
| 445 | return ( | 446 | return ( |
| 446 | latestTurnChanged || | 447 | latestTurnChanged || |
| 447 | input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || | 448 | input.localDispatch.sessionStatus !== (session?.status ?? null) || |
| 448 | input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) | 449 | input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) |
| 449 | ); | 450 | ); |
| 450 | } | 451 | } |
apps/web/src/components/ChatView.tsx+1176-1211| ... | @@ -21,7 +21,16 @@ import { | ... | @@ -21,7 +21,16 @@ import { |
| 21 | RuntimeMode, | 21 | RuntimeMode, |
| 22 | TerminalOpenInput, | 22 | TerminalOpenInput, |
| 23 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 24 | import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 24 | import { |
| 25 | connectionStatusText, | ||
| 26 | type EnvironmentConnectionPresentation, | ||
| 27 | } from "@t3tools/client-runtime/connection"; | ||
| 28 | import { | ||
| 29 | parseScopedThreadKey, | ||
| 30 | scopedThreadKey, | ||
| 31 | scopeProjectRef, | ||
| 32 | scopeThreadRef, | ||
| 33 | } from "@t3tools/client-runtime/environment"; | ||
| 25 | import { | 34 | import { |
| 26 | applyClaudePromptEffortPrefix, | 35 | applyClaudePromptEffortPrefix, |
| 27 | createModelSelection, | 36 | createModelSelection, |
| ... | @@ -31,15 +40,20 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje | ... | @@ -31,15 +40,20 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje |
| 31 | import { truncate } from "@t3tools/shared/String"; | 40 | import { truncate } from "@t3tools/shared/String"; |
| 32 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | 41 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; |
| 33 | import { Debouncer } from "@tanstack/react-pacer"; | 42 | import { Debouncer } from "@tanstack/react-pacer"; |
| 43 | import { useAtomValue } from "@effect/atom-react"; | ||
| 34 | import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 44 | import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 35 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 45 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 36 | import { useShallow } from "zustand/react/shallow"; | 46 | import { useShallow } from "zustand/react/shallow"; |
| 37 | import { useVcsStatus } from "~/lib/vcsStatusState"; | 47 | import { |
| 38 | import { usePrimaryEnvironmentId } from "../environments/primary/context"; | 48 | isAtomCommandInterrupted, |
| 39 | import { readEnvironmentApi } from "../environmentApi"; | 49 | mapAtomCommandResult, |
| 40 | import { resolveAssetUrl } from "../assets/assetUrls"; | 50 | settlePromise, |
| 51 | squashAtomCommandFailure, | ||
| 52 | type AtomCommandResult, | ||
| 53 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 54 | import * as Cause from "effect/Cause"; | ||
| 55 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 41 | import { isElectron } from "../env"; | 56 | import { isElectron } from "../env"; |
| 42 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; | ||
| 43 | import { readLocalApi } from "../localApi"; | 57 | import { readLocalApi } from "../localApi"; |
| 44 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 58 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 45 | import { | 59 | import { |
| ... | @@ -67,8 +81,6 @@ import { | ... | @@ -67,8 +81,6 @@ import { |
| 67 | togglePendingUserInputOptionSelection, | 81 | togglePendingUserInputOptionSelection, |
| 68 | type PendingUserInputDraftAnswer, | 82 | type PendingUserInputDraftAnswer, |
| 69 | } from "../pendingUserInput"; | 83 | } from "../pendingUserInput"; |
| 70 | import { selectEnvironmentState, selectProjectsAcrossEnvironments, useStore } from "../store"; | ||
| 71 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | ||
| 72 | import { useUiStateStore } from "../uiStateStore"; | 84 | import { useUiStateStore } from "../uiStateStore"; |
| 73 | import { | 85 | import { |
| 74 | buildPlanImplementationThreadTitle, | 86 | buildPlanImplementationThreadTitle, |
| ... | @@ -87,7 +99,7 @@ import { | ... | @@ -87,7 +99,7 @@ import { |
| 87 | } from "../types"; | 99 | } from "../types"; |
| 88 | import { useTheme } from "../hooks/useTheme"; | 100 | import { useTheme } from "../hooks/useTheme"; |
| 89 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 101 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 90 | import { useCommandPaletteStore } from "../commandPaletteStore"; | 102 | import { isCommandPaletteOpen } from "../commandPaletteContext"; |
| 91 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; | 103 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 92 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 104 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 93 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; | 105 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; |
| ... | @@ -99,27 +111,22 @@ import { | ... | @@ -99,27 +111,22 @@ import { |
| 99 | useRightPanelStore, | 111 | useRightPanelStore, |
| 100 | } from "../rightPanelStore"; | 112 | } from "../rightPanelStore"; |
| 101 | import { | 113 | import { |
| 114 | applyPreviewServerSnapshot, | ||
| 102 | isPreviewSupportedInRuntime, | 115 | isPreviewSupportedInRuntime, |
| 103 | selectThreadPreviewState, | 116 | removePreviewSession, |
| 104 | usePreviewStateStore, | 117 | setActivePreviewTab, |
| 118 | useThreadPreviewState, | ||
| 105 | } from "../previewStateStore"; | 119 | } from "../previewStateStore"; |
| 106 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 120 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 107 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; | 121 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 108 | // Lazy: keeps the entire preview component graph (webview host, favicon | 122 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; |
| 109 | // helper, Chromium error icon) out of the web bundle until first open. | 123 | import { RightPanelTabs } from "./RightPanelTabs"; |
| 110 | const PreviewPanel = lazy(() => | 124 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 111 | import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), | ||
| 112 | ); | ||
| 113 | const DiffPanel = lazy(() => import("./DiffPanel")); | ||
| 114 | const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); | ||
| 115 | const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet<string> = new Set(); | ||
| 116 | import { BranchToolbar } from "./BranchToolbar"; | 125 | import { BranchToolbar } from "./BranchToolbar"; |
| 117 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 126 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 118 | import PlanSidebar from "./PlanSidebar"; | 127 | import PlanSidebar from "./PlanSidebar"; |
| 119 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 128 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 120 | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; | 129 | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; |
| 121 | import { RightPanelTabs } from "./RightPanelTabs"; | ||
| 122 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | ||
| 123 | import { cn, randomHex } from "~/lib/utils"; | 130 | import { cn, randomHex } from "~/lib/utils"; |
| 124 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 131 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 125 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | 132 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| ... | @@ -129,7 +136,7 @@ import { | ... | @@ -129,7 +136,7 @@ import { |
| 129 | nextProjectScriptId, | 136 | nextProjectScriptId, |
| 130 | projectScriptIdFromCommand, | 137 | projectScriptIdFromCommand, |
| 131 | } from "~/projectScripts"; | 138 | } from "~/projectScripts"; |
| 132 | import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | 139 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 133 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | 140 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 134 | import { useSettings } from "../hooks/useSettings"; | 141 | import { useSettings } from "../hooks/useSettings"; |
| 135 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 142 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| ... | @@ -138,11 +145,6 @@ import { | ... | @@ -138,11 +145,6 @@ import { |
| 138 | deriveLogicalProjectKeyFromSettings, | 145 | deriveLogicalProjectKeyFromSettings, |
| 139 | selectProjectGroupingSettings, | 146 | selectProjectGroupingSettings, |
| 140 | } from "../logicalProject"; | 147 | } from "../logicalProject"; |
| 141 | import { | ||
| 142 | useSavedEnvironmentRegistryStore, | ||
| 143 | useSavedEnvironmentRuntimeStore, | ||
| 144 | } from "../environments/runtime/catalog"; | ||
| 145 | import { reconnectSavedEnvironment } from "../environments/runtime/service"; | ||
| 146 | import { buildDraftThreadRouteParams } from "../threadRoutes"; | 148 | import { buildDraftThreadRouteParams } from "../threadRoutes"; |
| 147 | import { | 149 | import { |
| 148 | type ComposerImageAttachment, | 150 | type ComposerImageAttachment, |
| ... | @@ -156,8 +158,6 @@ import { | ... | @@ -156,8 +158,6 @@ import { |
| 156 | type TerminalContextDraft, | 158 | type TerminalContextDraft, |
| 157 | type TerminalContextSelection, | 159 | type TerminalContextSelection, |
| 158 | } from "../lib/terminalContext"; | 160 | } from "../lib/terminalContext"; |
| 159 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | ||
| 160 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; | ||
| 161 | import { | 161 | import { |
| 162 | appendElementContextsToPrompt, | 162 | appendElementContextsToPrompt, |
| 163 | type ElementContextDraft, | 163 | type ElementContextDraft, |
| ... | @@ -165,6 +165,28 @@ import { | ... | @@ -165,6 +165,28 @@ import { |
| 165 | } from "../lib/elementContext"; | 165 | } from "../lib/elementContext"; |
| 166 | import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; | 166 | import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; |
| 167 | import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; | 167 | import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; |
| 168 | import { environmentCatalog } from "../connection/catalog"; | ||
| 169 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | ||
| 170 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; | ||
| 171 | import { projectEnvironment } from "../state/projects"; | ||
| 172 | import { useEnvironmentQuery } from "../state/query"; | ||
| 173 | import { | ||
| 174 | primaryServerAvailableEditorsAtom, | ||
| 175 | primaryServerKeybindingsAtom, | ||
| 176 | serverEnvironment, | ||
| 177 | } from "../state/server"; | ||
| 178 | import { terminalEnvironment } from "../state/terminal"; | ||
| 179 | import { threadEnvironment } from "../state/threads"; | ||
| 180 | import { vcsEnvironment } from "../state/vcs"; | ||
| 181 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; | ||
| 182 | import { | ||
| 183 | useProject, | ||
| 184 | useProjects, | ||
| 185 | useThread, | ||
| 186 | useThreadProposedPlans, | ||
| 187 | useThreadRefs, | ||
| 188 | } from "../state/entities"; | ||
| 189 | import { environmentShell } from "../state/shell"; | ||
| 168 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; | 190 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; |
| 169 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; | 191 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 170 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | 192 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| ... | @@ -173,11 +195,12 @@ import { ChatHeader } from "./chat/ChatHeader"; | ... | @@ -173,11 +195,12 @@ import { ChatHeader } from "./chat/ChatHeader"; |
| 173 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; | 195 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| 174 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 196 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 175 | import { NoActiveThreadState } from "./NoActiveThreadState"; | 197 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 176 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | 198 | import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; |
| 177 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | 199 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| 178 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 200 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 179 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | 201 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 180 | import { | 202 | import { |
| 203 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | ||
| 181 | buildExpiredTerminalContextToastCopy, | 204 | buildExpiredTerminalContextToastCopy, |
| 182 | buildLocalDraftThread, | 205 | buildLocalDraftThread, |
| 183 | collectUserMessageBlobPreviewUrls, | 206 | collectUserMessageBlobPreviewUrls, |
| ... | @@ -192,22 +215,18 @@ import { | ... | @@ -192,22 +215,18 @@ import { |
| 192 | cloneComposerImageForRetry, | 215 | cloneComposerImageForRetry, |
| 193 | deriveLockedProvider, | 216 | deriveLockedProvider, |
| 194 | readFileAsDataUrl, | 217 | readFileAsDataUrl, |
| 218 | reconcileMountedTerminalThreadIds, | ||
| 195 | resolveSendEnvMode, | 219 | resolveSendEnvMode, |
| 196 | revokeBlobPreviewUrl, | 220 | revokeBlobPreviewUrl, |
| 197 | revokeUserMessagePreviewUrls, | 221 | revokeUserMessagePreviewUrls, |
| 198 | shouldWriteThreadErrorToCurrentServerThread, | ||
| 199 | waitForStartedServerThread, | 222 | waitForStartedServerThread, |
| 200 | } from "./ChatView.logic"; | 223 | } from "./ChatView.logic"; |
| 201 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | 224 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 202 | import { useComposerHandleContext } from "../composerHandleContext"; | 225 | import { useComposerHandleContext } from "../composerHandleContext"; |
| 203 | import { | ||
| 204 | useServerAvailableEditors, | ||
| 205 | useServerConfig, | ||
| 206 | useServerKeybindings, | ||
| 207 | } from "~/rpc/serverState"; | ||
| 208 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; | 226 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 209 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; | ||
| 210 | import { RightPanelSheet } from "./RightPanelSheet"; | 227 | import { RightPanelSheet } from "./RightPanelSheet"; |
| 228 | import { previewEnvironment } from "../state/preview"; | ||
| 229 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 211 | import { Button } from "./ui/button"; | 230 | import { Button } from "./ui/button"; |
| 212 | import { | 231 | import { |
| 213 | buildVersionMismatchDismissalKey, | 232 | buildVersionMismatchDismissalKey, |
| ... | @@ -215,14 +234,20 @@ import { | ... | @@ -215,14 +234,20 @@ import { |
| 215 | isVersionMismatchDismissed, | 234 | isVersionMismatchDismissed, |
| 216 | resolveServerConfigVersionMismatch, | 235 | resolveServerConfigVersionMismatch, |
| 217 | } from "../versionSkew"; | 236 | } from "../versionSkew"; |
| 237 | import { useAssetUrls } from "../assets/assetUrls"; | ||
| 218 | 238 | ||
| 219 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 239 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 220 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | 240 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 221 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | 241 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 222 | const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; | ||
| 223 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 242 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 224 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; | 243 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 225 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | 244 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 245 | const PreviewPanel = lazy(() => | ||
| 246 | import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), | ||
| 247 | ); | ||
| 248 | const DiffPanel = lazy(() => import("./DiffPanel")); | ||
| 249 | const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); | ||
| 250 | const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet<string> = new Set(); | ||
| 226 | const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ | 251 | const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ |
| 227 | "input", | 252 | "input", |
| 228 | "textarea", | 253 | "textarea", |
| ... | @@ -255,7 +280,7 @@ const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ | ... | @@ -255,7 +280,7 @@ const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ |
| 255 | type EnvironmentUnavailableState = { | 280 | type EnvironmentUnavailableState = { |
| 256 | readonly environmentId: EnvironmentId; | 281 | readonly environmentId: EnvironmentId; |
| 257 | readonly label: string; | 282 | readonly label: string; |
| 258 | readonly connectionState: "connecting" | "disconnected" | "error"; | 283 | readonly connection: EnvironmentConnectionPresentation; |
| 259 | }; | 284 | }; |
| 260 | 285 | ||
| 261 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; | 286 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; |
| ... | @@ -280,119 +305,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { | ... | @@ -280,119 +305,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { |
| 280 | return true; | 305 | return true; |
| 281 | } | 306 | } |
| 282 | 307 | ||
| 283 | function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { | ||
| 284 | return useStore( | ||
| 285 | useMemo(() => { | ||
| 286 | let previousThreadIds: readonly ThreadId[] = []; | ||
| 287 | let previousResult: ThreadPlanCatalogEntry[] = []; | ||
| 288 | let previousEntries = new Map< | ||
| 289 | ThreadId, | ||
| 290 | { | ||
| 291 | shell: object | null; | ||
| 292 | proposedPlanIds: readonly string[] | undefined; | ||
| 293 | proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined; | ||
| 294 | entry: ThreadPlanCatalogEntry; | ||
| 295 | } | ||
| 296 | >(); | ||
| 297 | |||
| 298 | return (state) => { | ||
| 299 | const sameThreadIds = | ||
| 300 | previousThreadIds.length === threadIds.length && | ||
| 301 | previousThreadIds.every((id, index) => id === threadIds[index]); | ||
| 302 | const nextEntries = new Map< | ||
| 303 | ThreadId, | ||
| 304 | { | ||
| 305 | shell: object | null; | ||
| 306 | proposedPlanIds: readonly string[] | undefined; | ||
| 307 | proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined; | ||
| 308 | entry: ThreadPlanCatalogEntry; | ||
| 309 | } | ||
| 310 | >(); | ||
| 311 | const nextResult: ThreadPlanCatalogEntry[] = []; | ||
| 312 | let changed = !sameThreadIds; | ||
| 313 | |||
| 314 | for (const threadId of threadIds) { | ||
| 315 | let shell: object | undefined; | ||
| 316 | let proposedPlanIds: readonly string[] | undefined; | ||
| 317 | let proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined; | ||
| 318 | |||
| 319 | for (const environmentState of Object.values(state.environmentStateById)) { | ||
| 320 | const matchedShell = environmentState.threadShellById[threadId]; | ||
| 321 | if (!matchedShell) { | ||
| 322 | continue; | ||
| 323 | } | ||
| 324 | shell = matchedShell; | ||
| 325 | proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; | ||
| 326 | proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as | ||
| 327 | | Record<string, Thread["proposedPlans"][number]> | ||
| 328 | | undefined; | ||
| 329 | break; | ||
| 330 | } | ||
| 331 | |||
| 332 | if (!shell) { | ||
| 333 | const previous = previousEntries.get(threadId); | ||
| 334 | if ( | ||
| 335 | previous && | ||
| 336 | previous.shell === null && | ||
| 337 | previous.proposedPlanIds === undefined && | ||
| 338 | previous.proposedPlansById === undefined | ||
| 339 | ) { | ||
| 340 | nextEntries.set(threadId, previous); | ||
| 341 | continue; | ||
| 342 | } | ||
| 343 | changed = true; | ||
| 344 | nextEntries.set(threadId, { | ||
| 345 | shell: null, | ||
| 346 | proposedPlanIds: undefined, | ||
| 347 | proposedPlansById: undefined, | ||
| 348 | entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, | ||
| 349 | }); | ||
| 350 | continue; | ||
| 351 | } | ||
| 352 | |||
| 353 | const previous = previousEntries.get(threadId); | ||
| 354 | if ( | ||
| 355 | previous && | ||
| 356 | previous.shell === shell && | ||
| 357 | previous.proposedPlanIds === proposedPlanIds && | ||
| 358 | previous.proposedPlansById === proposedPlansById | ||
| 359 | ) { | ||
| 360 | nextEntries.set(threadId, previous); | ||
| 361 | nextResult.push(previous.entry); | ||
| 362 | continue; | ||
| 363 | } | ||
| 364 | |||
| 365 | changed = true; | ||
| 366 | const proposedPlans = | ||
| 367 | proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById | ||
| 368 | ? proposedPlanIds.flatMap((planId) => { | ||
| 369 | const proposedPlan = proposedPlansById?.[planId]; | ||
| 370 | return proposedPlan ? [proposedPlan] : []; | ||
| 371 | }) | ||
| 372 | : EMPTY_PROPOSED_PLANS; | ||
| 373 | const entry = { id: threadId, proposedPlans }; | ||
| 374 | nextEntries.set(threadId, { | ||
| 375 | shell, | ||
| 376 | proposedPlanIds, | ||
| 377 | proposedPlansById, | ||
| 378 | entry, | ||
| 379 | }); | ||
| 380 | nextResult.push(entry); | ||
| 381 | } | ||
| 382 | |||
| 383 | if (!changed && previousResult.length === nextResult.length) { | ||
| 384 | return previousResult; | ||
| 385 | } | ||
| 386 | |||
| 387 | previousThreadIds = threadIds; | ||
| 388 | previousEntries = nextEntries; | ||
| 389 | previousResult = nextResult; | ||
| 390 | return nextResult; | ||
| 391 | }; | ||
| 392 | }, [threadIds]), | ||
| 393 | ); | ||
| 394 | } | ||
| 395 | |||
| 396 | function formatOutgoingPrompt(params: { | 308 | function formatOutgoingPrompt(params: { |
| 397 | provider: ProviderDriverKind; | 309 | provider: ProviderDriverKind; |
| 398 | model: string | null; | 310 | model: string | null; |
| ... | @@ -443,21 +355,6 @@ function useLocalDispatchState(input: { | ... | @@ -443,21 +355,6 @@ function useLocalDispatchState(input: { |
| 443 | }) { | 355 | }) { |
| 444 | const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null); | 356 | const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null); |
| 445 | 357 | ||
| 446 | const beginLocalDispatch = useCallback( | ||
| 447 | (options?: { preparingWorktree?: boolean }) => { | ||
| 448 | const preparingWorktree = Boolean(options?.preparingWorktree); | ||
| 449 | setLocalDispatch((current) => { | ||
| 450 | if (current) { | ||
| 451 | return current.preparingWorktree === preparingWorktree | ||
| 452 | ? current | ||
| 453 | : { ...current, preparingWorktree }; | ||
| 454 | } | ||
| 455 | return createLocalDispatchSnapshot(input.activeThread, options); | ||
| 456 | }); | ||
| 457 | }, | ||
| 458 | [input.activeThread], | ||
| 459 | ); | ||
| 460 | |||
| 461 | const resetLocalDispatch = useCallback(() => { | 358 | const resetLocalDispatch = useCallback(() => { |
| 462 | setLocalDispatch(null); | 359 | setLocalDispatch(null); |
| 463 | }, []); | 360 | }, []); |
| ... | @@ -483,20 +380,29 @@ function useLocalDispatchState(input: { | ... | @@ -483,20 +380,29 @@ function useLocalDispatchState(input: { |
| 483 | localDispatch, | 380 | localDispatch, |
| 484 | ], | 381 | ], |
| 485 | ); | 382 | ); |
| 486 | 383 | const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; | |
| 487 | useEffect(() => { | 384 | const beginLocalDispatch = useCallback( |
| 488 | if (!serverAcknowledgedLocalDispatch) { | 385 | (options?: { preparingWorktree?: boolean }) => { |
| 489 | return; | 386 | const preparingWorktree = Boolean(options?.preparingWorktree); |
| 490 | } | 387 | setLocalDispatch((current) => { |
| 491 | resetLocalDispatch(); | 388 | const active = serverAcknowledgedLocalDispatch ? null : current; |
| 492 | }, [resetLocalDispatch, serverAcknowledgedLocalDispatch]); | 389 | if (active) { |
| 390 | return active.preparingWorktree === preparingWorktree | ||
| 391 | ? active | ||
| 392 | : { ...active, preparingWorktree }; | ||
| 393 | } | ||
| 394 | return createLocalDispatchSnapshot(input.activeThread, options); | ||
| 395 | }); | ||
| 396 | }, | ||
| 397 | [input.activeThread, serverAcknowledgedLocalDispatch], | ||
| 398 | ); | ||
| 493 | 399 | ||
| 494 | return { | 400 | return { |
| 495 | beginLocalDispatch, | 401 | beginLocalDispatch, |
| 496 | resetLocalDispatch, | 402 | resetLocalDispatch, |
| 497 | localDispatchStartedAt: localDispatch?.startedAt ?? null, | 403 | localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, |
| 498 | isPreparingWorktree: localDispatch?.preparingWorktree ?? false, | 404 | isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, |
| 499 | isSendBusy: localDispatch !== null && !serverAcknowledgedLocalDispatch, | 405 | isSendBusy: activeLocalDispatch !== null, |
| 500 | }; | 406 | }; |
| 501 | } | 407 | } |
| 502 | 408 | ||
| ... | @@ -543,7 +449,6 @@ interface PersistentThreadTerminalDrawerProps { | ... | @@ -543,7 +449,6 @@ interface PersistentThreadTerminalDrawerProps { |
| 543 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; | 449 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; |
| 544 | threadId: ThreadId; | 450 | threadId: ThreadId; |
| 545 | visible: boolean; | 451 | visible: boolean; |
| 546 | mode?: "drawer" | "panel"; | ||
| 547 | launchContext: PersistentTerminalLaunchContext | null; | 452 | launchContext: PersistentTerminalLaunchContext | null; |
| 548 | focusRequestId: number; | 453 | focusRequestId: number; |
| 549 | splitShortcutLabel: string | undefined; | 454 | splitShortcutLabel: string | undefined; |
| ... | @@ -558,7 +463,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -558,7 +463,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 558 | threadRef, | 463 | threadRef, |
| 559 | threadId, | 464 | threadId, |
| 560 | visible, | 465 | visible, |
| 561 | mode = "drawer", | ||
| 562 | launchContext, | 466 | launchContext, |
| 563 | focusRequestId, | 467 | focusRequestId, |
| 564 | splitShortcutLabel, | 468 | splitShortcutLabel, |
| ... | @@ -568,14 +472,17 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -568,14 +472,17 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 568 | keybindings, | 472 | keybindings, |
| 569 | onAddTerminalContext, | 473 | onAddTerminalContext, |
| 570 | }: PersistentThreadTerminalDrawerProps) { | 474 | }: PersistentThreadTerminalDrawerProps) { |
| 571 | const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); | 475 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); |
| 476 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); | ||
| 477 | const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); | ||
| 478 | const serverThread = useThread(threadRef); | ||
| 572 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); | 479 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); |
| 573 | const projectRef = serverThread | 480 | const projectRef = serverThread |
| 574 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) | 481 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 575 | : draftThread | 482 | : draftThread |
| 576 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | 483 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 577 | : null; | 484 | : null; |
| 578 | const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); | 485 | const project = useProject(projectRef); |
| 579 | const terminalUiState = useTerminalUiStateStore((state) => | 486 | const terminalUiState = useTerminalUiStateStore((state) => |
| 580 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), | 487 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), |
| 581 | ); | 488 | ); |
| ... | @@ -634,7 +541,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -634,7 +541,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 634 | cwd: launchContext?.cwd ?? summary.cwd, | 541 | cwd: launchContext?.cwd ?? summary.cwd, |
| 635 | worktreePath: worktreePathForLaunch, | 542 | worktreePath: worktreePathForLaunch, |
| 636 | runtimeEnv: projectScriptRuntimeEnv({ | 543 | runtimeEnv: projectScriptRuntimeEnv({ |
| 637 | project: { cwd: project.cwd }, | 544 | project: { cwd: project.workspaceRoot }, |
| 638 | worktreePath: worktreePathForLaunch, | 545 | worktreePath: worktreePathForLaunch, |
| 639 | }), | 546 | }), |
| 640 | }); | 547 | }); |
| ... | @@ -680,7 +587,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -680,7 +587,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 680 | launchContext?.cwd ?? | 587 | launchContext?.cwd ?? |
| 681 | (project | 588 | (project |
| 682 | ? projectScriptCwd({ | 589 | ? projectScriptCwd({ |
| 683 | project: { cwd: project.cwd }, | 590 | project: { cwd: project.workspaceRoot }, |
| 684 | worktreePath: effectiveWorktreePath, | 591 | worktreePath: effectiveWorktreePath, |
| 685 | }) | 592 | }) |
| 686 | : null), | 593 | : null), |
| ... | @@ -690,7 +597,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -690,7 +597,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 690 | () => | 597 | () => |
| 691 | project | 598 | project |
| 692 | ? projectScriptRuntimeEnv({ | 599 | ? projectScriptRuntimeEnv({ |
| 693 | project: { cwd: project.cwd }, | 600 | project: { cwd: project.workspaceRoot }, |
| 694 | worktreePath: effectiveWorktreePath, | 601 | worktreePath: effectiveWorktreePath, |
| 695 | }) | 602 | }) |
| 696 | : {}, | 603 | : {}, |
| ... | @@ -712,26 +619,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -712,26 +619,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 712 | ); | 619 | ); |
| 713 | 620 | ||
| 714 | const splitTerminal = useCallback(() => { | 621 | const splitTerminal = useCallback(() => { |
| 715 | const api = readEnvironmentApi(threadRef.environmentId); | 622 | if (!cwd) { |
| 716 | if (!api || !cwd) { | ||
| 717 | return; | 623 | return; |
| 718 | } | 624 | } |
| 719 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | 625 | const terminalId = nextTerminalId(serverOrderedTerminalIds); |
| 720 | storeSplitTerminal(threadRef, terminalId); | 626 | storeSplitTerminal(threadRef, terminalId); |
| 721 | bumpFocusRequestId(); | 627 | bumpFocusRequestId(); |
| 722 | void (async () => { | 628 | void openTerminal({ |
| 723 | try { | 629 | environmentId: threadRef.environmentId, |
| 724 | await api.terminal.open({ | 630 | input: { |
| 725 | threadId, | 631 | threadId, |
| 726 | terminalId, | 632 | terminalId, |
| 727 | cwd, | 633 | cwd, |
| 728 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), | 634 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), |
| 729 | env: runtimeEnv, | 635 | env: runtimeEnv, |
| 730 | }); | 636 | }, |
| 731 | } catch { | 637 | }); |
| 732 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 733 | } | ||
| 734 | })(); | ||
| 735 | }, [ | 638 | }, [ |
| 736 | bumpFocusRequestId, | 639 | bumpFocusRequestId, |
| 737 | cwd, | 640 | cwd, |
| ... | @@ -741,28 +644,30 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -741,28 +644,30 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 741 | storeSplitTerminal, | 644 | storeSplitTerminal, |
| 742 | threadId, | 645 | threadId, |
| 743 | threadRef, | 646 | threadRef, |
| 647 | openTerminal, | ||
| 744 | ]); | 648 | ]); |
| 745 | const splitTerminalVertical = useCallback(() => { | 649 | const splitTerminalVertical = useCallback(() => { |
| 746 | const api = readEnvironmentApi(threadRef.environmentId); | 650 | if (!cwd) { |
| 747 | if (!api || !cwd) { | ||
| 748 | return; | 651 | return; |
| 749 | } | 652 | } |
| 750 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | 653 | const terminalId = nextTerminalId(serverOrderedTerminalIds); |
| 751 | storeSplitTerminalVertical(threadRef, terminalId); | 654 | storeSplitTerminalVertical(threadRef, terminalId); |
| 752 | bumpFocusRequestId(); | 655 | bumpFocusRequestId(); |
| 753 | void api.terminal | 656 | void openTerminal({ |
| 754 | .open({ | 657 | environmentId: threadRef.environmentId, |
| 658 | input: { | ||
| 755 | threadId, | 659 | threadId, |
| 756 | terminalId, | 660 | terminalId, |
| 757 | cwd, | 661 | cwd, |
| 758 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), | 662 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), |
| 759 | env: runtimeEnv, | 663 | env: runtimeEnv, |
| 760 | }) | 664 | }, |
| 761 | .catch(() => undefined); | 665 | }); |
| 762 | }, [ | 666 | }, [ |
| 763 | bumpFocusRequestId, | 667 | bumpFocusRequestId, |
| 764 | cwd, | 668 | cwd, |
| 765 | effectiveWorktreePath, | 669 | effectiveWorktreePath, |
| 670 | openTerminal, | ||
| 766 | runtimeEnv, | 671 | runtimeEnv, |
| 767 | serverOrderedTerminalIds, | 672 | serverOrderedTerminalIds, |
| 768 | storeSplitTerminalVertical, | 673 | storeSplitTerminalVertical, |
| ... | @@ -771,26 +676,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -771,26 +676,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 771 | ]); | 676 | ]); |
| 772 | 677 | ||
| 773 | const createNewTerminal = useCallback(() => { | 678 | const createNewTerminal = useCallback(() => { |
| 774 | const api = readEnvironmentApi(threadRef.environmentId); | 679 | if (!cwd) { |
| 775 | if (!api || !cwd) { | ||
| 776 | return; | 680 | return; |
| 777 | } | 681 | } |
| 778 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | 682 | const terminalId = nextTerminalId(serverOrderedTerminalIds); |
| 779 | storeNewTerminal(threadRef, terminalId); | 683 | storeNewTerminal(threadRef, terminalId); |
| 780 | bumpFocusRequestId(); | 684 | bumpFocusRequestId(); |
| 781 | void (async () => { | 685 | void openTerminal({ |
| 782 | try { | 686 | environmentId: threadRef.environmentId, |
| 783 | await api.terminal.open({ | 687 | input: { |
| 784 | threadId, | 688 | threadId, |
| 785 | terminalId, | 689 | terminalId, |
| 786 | cwd, | 690 | cwd, |
| 787 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), | 691 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), |
| 788 | env: runtimeEnv, | 692 | env: runtimeEnv, |
| 789 | }); | 693 | }, |
| 790 | } catch { | 694 | }); |
| 791 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 792 | } | ||
| 793 | })(); | ||
| 794 | }, [ | 695 | }, [ |
| 795 | bumpFocusRequestId, | 696 | bumpFocusRequestId, |
| 796 | cwd, | 697 | cwd, |
| ... | @@ -800,6 +701,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -800,6 +701,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 800 | storeNewTerminal, | 701 | storeNewTerminal, |
| 801 | threadId, | 702 | threadId, |
| 802 | threadRef, | 703 | threadRef, |
| 704 | openTerminal, | ||
| 803 | ]); | 705 | ]); |
| 804 | 706 | ||
| 805 | const activateTerminal = useCallback( | 707 | const activateTerminal = useCallback( |
| ... | @@ -812,31 +714,37 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -812,31 +714,37 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 812 | 714 | ||
| 813 | const closeTerminal = useCallback( | 715 | const closeTerminal = useCallback( |
| 814 | (terminalId: string) => { | 716 | (terminalId: string) => { |
| 815 | const api = readEnvironmentApi(threadRef.environmentId); | ||
| 816 | if (!api) return; | ||
| 817 | const isFinalTerminal = terminalUiState.terminalIds.length <= 1; | ||
| 818 | const fallbackExitWrite = () => | 717 | const fallbackExitWrite = () => |
| 819 | api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); | 718 | writeTerminal({ |
| 719 | environmentId: threadRef.environmentId, | ||
| 720 | input: { threadId, terminalId, data: "exit\n" }, | ||
| 721 | }); | ||
| 820 | 722 | ||
| 821 | if ("close" in api.terminal && typeof api.terminal.close === "function") { | 723 | void (async () => { |
| 822 | void (async () => { | 724 | const closeResult = await closeTerminalMutation({ |
| 823 | if (isFinalTerminal) { | 725 | environmentId: threadRef.environmentId, |
| 824 | await api.terminal.clear({ threadId, terminalId }).catch(() => undefined); | 726 | input: { |
| 825 | } | ||
| 826 | await api.terminal.close({ | ||
| 827 | threadId, | 727 | threadId, |
| 828 | terminalId, | 728 | terminalId, |
| 829 | deleteHistory: true, | 729 | deleteHistory: true, |
| 830 | }); | 730 | }, |
| 831 | })().catch(() => fallbackExitWrite()); | 731 | }); |
| 832 | } else { | 732 | if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { |
| 833 | void fallbackExitWrite(); | 733 | await fallbackExitWrite(); |
| 834 | } | 734 | } |
| 735 | })(); | ||
| 835 | 736 | ||
| 836 | storeCloseTerminal(threadRef, terminalId); | 737 | storeCloseTerminal(threadRef, terminalId); |
| 837 | bumpFocusRequestId(); | 738 | bumpFocusRequestId(); |
| 838 | }, | 739 | }, |
| 839 | [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef], | 740 | [ |
| 741 | bumpFocusRequestId, | ||
| 742 | storeCloseTerminal, | ||
| 743 | threadId, | ||
| 744 | threadRef, | ||
| 745 | closeTerminalMutation, | ||
| 746 | writeTerminal, | ||
| 747 | ], | ||
| 840 | ); | 748 | ); |
| 841 | 749 | ||
| 842 | const handleAddTerminalContext = useCallback( | 750 | const handleAddTerminalContext = useCallback( |
| ... | @@ -854,9 +762,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -854,9 +762,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 854 | } | 762 | } |
| 855 | 763 | ||
| 856 | return ( | 764 | return ( |
| 857 | <div className={cn(visible ? undefined : "hidden", mode === "panel" && "h-full min-h-0")}> | 765 | <div className={visible ? undefined : "hidden"}> |
| 858 | <ThreadTerminalDrawer | 766 | <ThreadTerminalDrawer |
| 859 | mode={mode} | ||
| 860 | threadRef={threadRef} | 767 | threadRef={threadRef} |
| 861 | threadId={threadId} | 768 | threadId={threadId} |
| 862 | cwd={cwd} | 769 | cwd={cwd} |
| ... | @@ -890,7 +797,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -890,7 +797,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 890 | }); | 797 | }); |
| 891 | 798 | ||
| 892 | interface PersistentThreadTerminalPanelProps { | 799 | interface PersistentThreadTerminalPanelProps { |
| 893 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; | 800 | threadRef: ScopedThreadRef; |
| 894 | surface: Extract<RightPanelSurface, { kind: "terminal" }>; | 801 | surface: Extract<RightPanelSurface, { kind: "terminal" }>; |
| 895 | launchContext: PersistentTerminalLaunchContext | null; | 802 | launchContext: PersistentTerminalLaunchContext | null; |
| 896 | focusRequestId: number; | 803 | focusRequestId: number; |
| ... | @@ -924,41 +831,41 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane | ... | @@ -924,41 +831,41 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane |
| 924 | newShortcutLabel, | 831 | newShortcutLabel, |
| 925 | closeShortcutLabel, | 832 | closeShortcutLabel, |
| 926 | }: PersistentThreadTerminalPanelProps) { | 833 | }: PersistentThreadTerminalPanelProps) { |
| 927 | const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); | 834 | const serverThread = useThread(threadRef); |
| 928 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); | 835 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); |
| 929 | const projectRef = serverThread | 836 | const projectRef = serverThread |
| 930 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) | 837 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 931 | : draftThread | 838 | : draftThread |
| 932 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | 839 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 933 | : null; | 840 | : null; |
| 934 | const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); | 841 | const project = useProject(projectRef); |
| 935 | const knownTerminalSessions = useKnownTerminalSessions({ | 842 | const knownTerminalSessions = useKnownTerminalSessions({ |
| 936 | environmentId: threadRef.environmentId, | 843 | environmentId: threadRef.environmentId, |
| 937 | threadId: threadRef.threadId, | 844 | threadId: threadRef.threadId, |
| 938 | }); | 845 | }); |
| 939 | const terminalSummary = | 846 | const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| 847 | const activeSummary = | ||
| 940 | knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) | 848 | knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) |
| 941 | ?.state.summary ?? null; | 849 | ?.state.summary ?? null; |
| 942 | const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | ||
| 943 | const worktreePath = | 850 | const worktreePath = |
| 944 | launchContext?.worktreePath ?? terminalSummary?.worktreePath ?? threadWorktreePath; | 851 | launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; |
| 945 | const cwd = useMemo( | 852 | const cwd = useMemo( |
| 946 | () => | 853 | () => |
| 947 | launchContext?.cwd ?? | 854 | launchContext?.cwd ?? |
| 948 | terminalSummary?.cwd ?? | 855 | activeSummary?.cwd ?? |
| 949 | (project | 856 | (project |
| 950 | ? projectScriptCwd({ | 857 | ? projectScriptCwd({ |
| 951 | project: { cwd: project.cwd }, | 858 | project: { cwd: project.workspaceRoot }, |
| 952 | worktreePath, | 859 | worktreePath, |
| 953 | }) | 860 | }) |
| 954 | : null), | 861 | : null), |
| 955 | [launchContext?.cwd, project, terminalSummary?.cwd, worktreePath], | 862 | [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], |
| 956 | ); | 863 | ); |
| 957 | const runtimeEnv = useMemo( | 864 | const runtimeEnv = useMemo( |
| 958 | () => | 865 | () => |
| 959 | project | 866 | project |
| 960 | ? projectScriptRuntimeEnv({ | 867 | ? projectScriptRuntimeEnv({ |
| 961 | project: { cwd: project.cwd }, | 868 | project: { cwd: project.workspaceRoot }, |
| 962 | worktreePath, | 869 | worktreePath, |
| 963 | }) | 870 | }) |
| 964 | : {}, | 871 | : {}, |
| ... | @@ -994,7 +901,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane | ... | @@ -994,7 +901,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane |
| 994 | summary?.cwd ?? | 901 | summary?.cwd ?? |
| 995 | (project | 902 | (project |
| 996 | ? projectScriptCwd({ | 903 | ? projectScriptCwd({ |
| 997 | project: { cwd: project.cwd }, | 904 | project: { cwd: project.workspaceRoot }, |
| 998 | worktreePath: terminalWorktreePath, | 905 | worktreePath: terminalWorktreePath, |
| 999 | }) | 906 | }) |
| 1000 | : null); | 907 | : null); |
| ... | @@ -1003,7 +910,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane | ... | @@ -1003,7 +910,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane |
| 1003 | cwd: terminalCwd, | 910 | cwd: terminalCwd, |
| 1004 | worktreePath: terminalWorktreePath, | 911 | worktreePath: terminalWorktreePath, |
| 1005 | runtimeEnv: projectScriptRuntimeEnv({ | 912 | runtimeEnv: projectScriptRuntimeEnv({ |
| 1006 | project: { cwd: project.cwd }, | 913 | project: { cwd: project.workspaceRoot }, |
| 1007 | worktreePath: terminalWorktreePath, | 914 | worktreePath: terminalWorktreePath, |
| 1008 | }), | 915 | }), |
| 1009 | }); | 916 | }); |
| ... | @@ -1018,9 +925,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane | ... | @@ -1018,9 +925,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane |
| 1018 | threadWorktreePath, | 925 | threadWorktreePath, |
| 1019 | ]); | 926 | ]); |
| 1020 | 927 | ||
| 1021 | if (!project || !cwd) { | 928 | if (!project || !cwd) return null; |
| 1022 | return null; | ||
| 1023 | } | ||
| 1024 | 929 | ||
| 1025 | return ( | 930 | return ( |
| 1026 | <ThreadTerminalDrawer | 931 | <ThreadTerminalDrawer |
| ... | @@ -1074,15 +979,49 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1074,15 +979,49 @@ function ChatViewContent(props: ChatViewProps) { |
| 1074 | [environmentId, threadId], | 979 | [environmentId, threadId], |
| 1075 | ); | 980 | ); |
| 1076 | const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); | 981 | const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); |
| 982 | const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); | ||
| 983 | const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { | ||
| 984 | reportFailure: false, | ||
| 985 | }); | ||
| 986 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); | ||
| 987 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); | ||
| 988 | const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); | ||
| 989 | const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); | ||
| 990 | const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); | ||
| 991 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | ||
| 992 | reportFailure: false, | ||
| 993 | }); | ||
| 994 | const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { | ||
| 995 | reportFailure: false, | ||
| 996 | }); | ||
| 997 | const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { | ||
| 998 | reportFailure: false, | ||
| 999 | }); | ||
| 1000 | const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); | ||
| 1001 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { | ||
| 1002 | reportFailure: false, | ||
| 1003 | }); | ||
| 1004 | const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, { | ||
| 1005 | reportFailure: false, | ||
| 1006 | }); | ||
| 1007 | const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, { | ||
| 1008 | reportFailure: false, | ||
| 1009 | }); | ||
| 1010 | const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { | ||
| 1011 | reportFailure: false, | ||
| 1012 | }); | ||
| 1013 | const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); | ||
| 1014 | const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); | ||
| 1015 | const { environments } = useEnvironments(); | ||
| 1016 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 1017 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); | ||
| 1018 | const environmentById = useMemo( | ||
| 1019 | () => new Map(environments.map((environment) => [environment.environmentId, environment])), | ||
| 1020 | [environments], | ||
| 1021 | ); | ||
| 1077 | const composerDraftTarget: ScopedThreadRef | DraftId = | 1022 | const composerDraftTarget: ScopedThreadRef | DraftId = |
| 1078 | routeKind === "server" ? routeThreadRef : props.draftId; | 1023 | routeKind === "server" ? routeThreadRef : props.draftId; |
| 1079 | const serverThread = useStore( | 1024 | const serverThread = useThread(routeKind === "server" ? routeThreadRef : null); |
| 1080 | useMemo( | ||
| 1081 | () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), | ||
| 1082 | [routeKind, routeThreadRef], | ||
| 1083 | ), | ||
| 1084 | ); | ||
| 1085 | const setStoreThreadError = useStore((store) => store.setError); | ||
| 1086 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); | 1025 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 1087 | const activeThreadLastVisitedAt = useUiStateStore((store) => | 1026 | const activeThreadLastVisitedAt = useUiStateStore((store) => |
| 1088 | routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, | 1027 | routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, |
| ... | @@ -1156,6 +1095,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1156,6 +1095,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1156 | const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< | 1095 | const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< |
| 1157 | Record<string, string | null> | 1096 | Record<string, string | null> |
| 1158 | >({}); | 1097 | >({}); |
| 1098 | const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< | ||
| 1099 | Record<string, string | null> | ||
| 1100 | >({}); | ||
| 1159 | const [isConnecting, _setIsConnecting] = useState(false); | 1101 | const [isConnecting, _setIsConnecting] = useState(false); |
| 1160 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | 1102 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 1161 | const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>( | 1103 | const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>( |
| ... | @@ -1202,23 +1144,50 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1202,23 +1144,50 @@ function ChatViewContent(props: ChatViewProps) { |
| 1202 | const terminalUiState = useTerminalUiStateStore((state) => | 1144 | const terminalUiState = useTerminalUiStateStore((state) => |
| 1203 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), | 1145 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), |
| 1204 | ); | 1146 | ); |
| 1147 | const openTerminalThreadKeys = useTerminalUiStateStore( | ||
| 1148 | useShallow((state) => | ||
| 1149 | Object.entries(state.terminalUiStateByThreadKey).flatMap( | ||
| 1150 | ([nextThreadKey, nextTerminalUiState]) => | ||
| 1151 | nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], | ||
| 1152 | ), | ||
| 1153 | ), | ||
| 1154 | ); | ||
| 1205 | const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); | 1155 | const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); |
| 1156 | const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); | ||
| 1206 | const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); | 1157 | const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); |
| 1207 | const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); | 1158 | const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); |
| 1208 | const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); | 1159 | const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); |
| 1209 | const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); | 1160 | const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); |
| 1210 | const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); | 1161 | const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); |
| 1162 | const serverThreadRefs = useThreadRefs(); | ||
| 1163 | const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); | ||
| 1164 | const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); | ||
| 1165 | const draftThreadKeys = useMemo( | ||
| 1166 | () => | ||
| 1167 | Object.values(draftThreadsByThreadKey).map((draftThread) => | ||
| 1168 | scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), | ||
| 1169 | ), | ||
| 1170 | [draftThreadsByThreadKey], | ||
| 1171 | ); | ||
| 1172 | const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]); | ||
| 1173 | const mountedTerminalThreadRefs = useMemo( | ||
| 1174 | () => | ||
| 1175 | mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { | ||
| 1176 | const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); | ||
| 1177 | return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; | ||
| 1178 | }), | ||
| 1179 | [mountedTerminalThreadKeys], | ||
| 1180 | ); | ||
| 1211 | 1181 | ||
| 1212 | const fallbackDraftProjectRef = draftThread | 1182 | const fallbackDraftProjectRef = draftThread |
| 1213 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | 1183 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 1214 | : null; | 1184 | : null; |
| 1215 | const fallbackDraftProject = useStore( | 1185 | const fallbackDraftProject = useProject(fallbackDraftProjectRef); |
| 1216 | useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), | ||
| 1217 | ); | ||
| 1218 | const localDraftError = | 1186 | const localDraftError = |
| 1219 | routeKind === "server" && serverThread | 1187 | routeKind === "server" && serverThread |
| 1220 | ? null | 1188 | ? null |
| 1221 | : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); | 1189 | : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); |
| 1190 | const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? null; | ||
| 1222 | const localDraftThread = useMemo( | 1191 | const localDraftThread = useMemo( |
| 1223 | () => | 1192 | () => |
| 1224 | draftThread | 1193 | draftThread |
| ... | @@ -1229,13 +1198,15 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1229,13 +1198,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 1229 | instanceId: ProviderInstanceId.make("codex"), | 1198 | instanceId: ProviderInstanceId.make("codex"), |
| 1230 | model: DEFAULT_MODEL, | 1199 | model: DEFAULT_MODEL, |
| 1231 | }, | 1200 | }, |
| 1232 | localDraftError, | ||
| 1233 | ) | 1201 | ) |
| 1234 | : undefined, | 1202 | : undefined, |
| 1235 | [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], | 1203 | [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], |
| 1236 | ); | 1204 | ); |
| 1237 | const isServerThread = routeKind === "server" && serverThread !== undefined; | 1205 | const isServerThread = routeKind === "server" && serverThread !== null; |
| 1238 | const activeThread = isServerThread ? serverThread : localDraftThread; | 1206 | const activeThread = isServerThread ? serverThread : localDraftThread; |
| 1207 | const threadError = isServerThread | ||
| 1208 | ? (localServerError ?? serverThread?.session?.lastError ?? null) | ||
| 1209 | : localDraftError; | ||
| 1239 | const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; | 1210 | const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 1240 | const interactionMode = | 1211 | const interactionMode = |
| 1241 | composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; | 1212 | composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; |
| ... | @@ -1268,35 +1239,32 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1268,35 +1239,32 @@ function ChatViewContent(props: ChatViewProps) { |
| 1268 | [activeServerOrderedTerminalIds, terminalUiState.terminalIds], | 1239 | [activeServerOrderedTerminalIds, terminalUiState.terminalIds], |
| 1269 | ); | 1240 | ); |
| 1270 | const activeTerminalLabelsById = useMemo(() => { | 1241 | const activeTerminalLabelsById = useMemo(() => { |
| 1271 | const next = new Map<string, string>(); | 1242 | const labels = new Map<string, string>(); |
| 1272 | for (const session of activeThreadKnownSessions) { | 1243 | for (const session of activeThreadKnownSessions) { |
| 1273 | next.set( | 1244 | labels.set( |
| 1274 | session.target.terminalId, | 1245 | session.target.terminalId, |
| 1275 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | 1246 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), |
| 1276 | ); | 1247 | ); |
| 1277 | } | 1248 | } |
| 1278 | return next; | 1249 | return labels; |
| 1279 | }, [activeThreadKnownSessions]); | 1250 | }, [activeThreadKnownSessions]); |
| 1280 | const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); | ||
| 1281 | const activeThreadRef = useMemo( | 1251 | const activeThreadRef = useMemo( |
| 1282 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), | 1252 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), |
| 1283 | [activeThread], | 1253 | [activeThread], |
| 1284 | ); | 1254 | ); |
| 1285 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | 1255 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1286 | const activeRightPanelKind = useRightPanelStore((store) => | 1256 | const activeRightPanelKind = useRightPanelStore((state) => |
| 1287 | selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), | 1257 | selectActiveRightPanelKindWithUrl(state.byThreadKey, activeThreadRef, diffOpen), |
| 1288 | ); | 1258 | ); |
| 1289 | const rightPanelState = useRightPanelStore((store) => | 1259 | const rightPanelState = useRightPanelStore((state) => |
| 1290 | selectThreadRightPanelState(store.byThreadKey, activeThreadRef), | 1260 | selectThreadRightPanelState(state.byThreadKey, activeThreadRef), |
| 1291 | ); | 1261 | ); |
| 1292 | const activeRightPanelSurface = useRightPanelStore((store) => | 1262 | const activeRightPanelSurface = useRightPanelStore((state) => |
| 1293 | selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), | 1263 | selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), |
| 1294 | ); | 1264 | ); |
| 1295 | const activeFileSurface = | 1265 | const activeFileSurface = |
| 1296 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; | 1266 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1297 | const activePreviewState = usePreviewStateStore((state) => | 1267 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| 1298 | selectThreadPreviewState(state.byThreadKey, activeThreadRef), | ||
| 1299 | ); | ||
| 1300 | const panelTerminalIds = useMemo( | 1268 | const panelTerminalIds = useMemo( |
| 1301 | () => | 1269 | () => |
| 1302 | new Set( | 1270 | new Set( |
| ... | @@ -1306,37 +1274,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1306,37 +1274,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 1306 | ), | 1274 | ), |
| 1307 | [rightPanelState.surfaces], | 1275 | [rightPanelState.surfaces], |
| 1308 | ); | 1276 | ); |
| 1309 | const drawerServerOrderedTerminalIds = useMemo( | ||
| 1310 | () => activeServerOrderedTerminalIds.filter((terminalId) => !panelTerminalIds.has(terminalId)), | ||
| 1311 | [activeServerOrderedTerminalIds, panelTerminalIds], | ||
| 1312 | ); | ||
| 1313 | useEffect(() => { | ||
| 1314 | if (!activeThreadRef) { | ||
| 1315 | return; | ||
| 1316 | } | ||
| 1317 | if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) { | ||
| 1318 | return; | ||
| 1319 | } | ||
| 1320 | if ( | ||
| 1321 | serverTerminalIdsStrictSubsetOfClient( | ||
| 1322 | drawerServerOrderedTerminalIds, | ||
| 1323 | terminalUiState.terminalIds, | ||
| 1324 | ) | ||
| 1325 | ) { | ||
| 1326 | return; | ||
| 1327 | } | ||
| 1328 | reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds); | ||
| 1329 | }, [ | ||
| 1330 | activeThreadRef, | ||
| 1331 | drawerServerOrderedTerminalIds, | ||
| 1332 | reconcileTerminalIds, | ||
| 1333 | terminalUiState.terminalIds, | ||
| 1334 | ]); | ||
| 1335 | const planSidebarOpen = activeRightPanelKind === "plan"; | ||
| 1336 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); | 1277 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); |
| 1337 | const rightPanelOpen = rightPanelState.isOpen; | 1278 | const rightPanelOpen = rightPanelState.isOpen; |
| 1279 | const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; | ||
| 1338 | const rightPanelMaximized = | 1280 | const rightPanelMaximized = |
| 1339 | rightPanelOpen && !shouldUsePlanSidebarSheet && maximizedRightPanelThreadKey === routeThreadKey; | 1281 | canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; |
| 1340 | const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; | 1282 | const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; |
| 1341 | 1283 | ||
| 1342 | useEffect(() => { | 1284 | useEffect(() => { |
| ... | @@ -1346,33 +1288,67 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1346,33 +1288,67 @@ function ChatViewContent(props: ChatViewProps) { |
| 1346 | .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); | 1288 | .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); |
| 1347 | }, [activePreviewState.sessions, activeThreadRef]); | 1289 | }, [activePreviewState.sessions, activeThreadRef]); |
| 1348 | 1290 | ||
| 1291 | const planSidebarOpen = activeRightPanelKind === "plan"; | ||
| 1292 | |||
| 1349 | useEffect(() => { | 1293 | useEffect(() => { |
| 1350 | if (!activeThreadRef || !diffOpen) return; | 1294 | if (!activeThreadRef || !diffOpen) return; |
| 1351 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | 1295 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| 1352 | }, [activeThreadRef, diffOpen]); | 1296 | }, [activeThreadRef, diffOpen]); |
| 1297 | |||
| 1298 | const existingOpenTerminalThreadKeys = useMemo(() => { | ||
| 1299 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | ||
| 1300 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); | ||
| 1301 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); | ||
| 1353 | const activeLatestTurn = activeThread?.latestTurn ?? null; | 1302 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 1354 | const threadPlanCatalog = useThreadPlanCatalog( | 1303 | const sourcePlanThreadRef = useMemo(() => { |
| 1355 | useMemo(() => { | 1304 | const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; |
| 1356 | const threadIds: ThreadId[] = []; | 1305 | if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { |
| 1357 | if (activeThread?.id) { | 1306 | return null; |
| 1358 | threadIds.push(activeThread.id); | 1307 | } |
| 1359 | } | 1308 | return scopeThreadRef(activeThread.environmentId, sourceThreadId); |
| 1360 | const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; | 1309 | }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); |
| 1361 | if (sourceThreadId && sourceThreadId !== activeThread?.id) { | 1310 | const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); |
| 1362 | threadIds.push(sourceThreadId); | 1311 | const threadPlanCatalog = useMemo<ThreadPlanCatalogEntry[]>(() => { |
| 1363 | } | 1312 | if (!activeThread) { |
| 1364 | return threadIds; | 1313 | return []; |
| 1365 | }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), | 1314 | } |
| 1366 | ); | 1315 | const entries: ThreadPlanCatalogEntry[] = [ |
| 1316 | { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, | ||
| 1317 | ]; | ||
| 1318 | if (sourcePlanThreadRef) { | ||
| 1319 | entries.push({ | ||
| 1320 | id: sourcePlanThreadRef.threadId, | ||
| 1321 | proposedPlans: sourceThreadProposedPlans, | ||
| 1322 | }); | ||
| 1323 | } | ||
| 1324 | return entries; | ||
| 1325 | }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); | ||
| 1326 | useEffect(() => { | ||
| 1327 | setMountedTerminalThreadKeys((currentThreadIds) => { | ||
| 1328 | const nextThreadIds = reconcileMountedTerminalThreadIds({ | ||
| 1329 | currentThreadIds, | ||
| 1330 | openThreadIds: existingOpenTerminalThreadKeys, | ||
| 1331 | activeThreadId: activeThreadKey, | ||
| 1332 | activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), | ||
| 1333 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | ||
| 1334 | }); | ||
| 1335 | return currentThreadIds.length === nextThreadIds.length && | ||
| 1336 | currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) | ||
| 1337 | ? currentThreadIds | ||
| 1338 | : nextThreadIds; | ||
| 1339 | }); | ||
| 1340 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); | ||
| 1367 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 1341 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 1368 | const activeProjectRef = activeThread | 1342 | const activeProjectRef = activeThread |
| 1369 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) | 1343 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) |
| 1370 | : null; | 1344 | : null; |
| 1371 | const activeProject = useStore( | 1345 | const activeProject = useProject(activeProjectRef); |
| 1372 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), | 1346 | const activeEnvironmentShell = useEnvironmentQuery( |
| 1347 | activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, | ||
| 1373 | ); | 1348 | ); |
| 1349 | const activeEnvironmentBootstrapComplete = activeEnvironmentShell.data?.snapshot._tag === "Some"; | ||
| 1374 | const activeProjectKey = activeProject | 1350 | const activeProjectKey = activeProject |
| 1375 | ? `${activeProject.environmentId}:${activeProject.cwd}` | 1351 | ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` |
| 1376 | : null; | 1352 | : null; |
| 1377 | const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< | 1353 | const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< |
| 1378 | ReadonlyMap<string, ReadonlySet<string>> | 1354 | ReadonlyMap<string, ReadonlySet<string>> |
| ... | @@ -1387,30 +1363,17 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1387,30 +1363,17 @@ function ChatViewContent(props: ChatViewProps) { |
| 1387 | const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; | 1363 | const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; |
| 1388 | const surfaceId = `file:${relativePath}`; | 1364 | const surfaceId = `file:${relativePath}`; |
| 1389 | if (current.has(surfaceId) === pending) return currentByProject; | 1365 | if (current.has(surfaceId) === pending) return currentByProject; |
| 1390 | |||
| 1391 | const next = new Set(current); | 1366 | const next = new Set(current); |
| 1392 | if (pending) { | 1367 | if (pending) next.add(surfaceId); |
| 1393 | next.add(surfaceId); | 1368 | else next.delete(surfaceId); |
| 1394 | } else { | ||
| 1395 | next.delete(surfaceId); | ||
| 1396 | } | ||
| 1397 | |||
| 1398 | const nextByProject = new Map(currentByProject); | 1369 | const nextByProject = new Map(currentByProject); |
| 1399 | if (next.size === 0) { | 1370 | if (next.size === 0) nextByProject.delete(activeProjectKey); |
| 1400 | nextByProject.delete(activeProjectKey); | 1371 | else nextByProject.set(activeProjectKey, next); |
| 1401 | } else { | ||
| 1402 | nextByProject.set(activeProjectKey, next); | ||
| 1403 | } | ||
| 1404 | return nextByProject; | 1372 | return nextByProject; |
| 1405 | }); | 1373 | }); |
| 1406 | }, | 1374 | }, |
| 1407 | [activeProjectKey], | 1375 | [activeProjectKey], |
| 1408 | ); | 1376 | ); |
| 1409 | const activeEnvironmentBootstrapComplete = useStore((state) => | ||
| 1410 | activeThread | ||
| 1411 | ? selectEnvironmentState(state, activeThread.environmentId).bootstrapComplete | ||
| 1412 | : false, | ||
| 1413 | ); | ||
| 1414 | const configuredPreviewUrls = useMemo( | 1377 | const configuredPreviewUrls = useMemo( |
| 1415 | () => getConfiguredPreviewUrls(activeProject?.scripts), | 1378 | () => getConfiguredPreviewUrls(activeProject?.scripts), |
| 1416 | [activeProject?.scripts], | 1379 | [activeProject?.scripts], |
| ... | @@ -1418,83 +1381,35 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1418,83 +1381,35 @@ function ChatViewContent(props: ChatViewProps) { |
| 1418 | 1381 | ||
| 1419 | useEffect(() => { | 1382 | useEffect(() => { |
| 1420 | if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; | 1383 | if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; |
| 1421 | useRightPanelStore | 1384 | useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); |
| 1422 | .getState() | ||
| 1423 | .reconcileFileSurfaces(activeThreadRef, activeProject !== undefined); | ||
| 1424 | }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); | 1385 | }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); |
| 1425 | 1386 | ||
| 1426 | useEffect(() => { | ||
| 1427 | if (routeKind !== "server") { | ||
| 1428 | return; | ||
| 1429 | } | ||
| 1430 | return retainThreadDetailSubscription(environmentId, threadId); | ||
| 1431 | }, [environmentId, routeKind, threadId]); | ||
| 1432 | |||
| 1433 | // Compute the list of environments this logical project spans, used to | 1387 | // Compute the list of environments this logical project spans, used to |
| 1434 | // drive the environment picker in BranchToolbar. | 1388 | // drive the environment picker in BranchToolbar. |
| 1435 | const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); | 1389 | const allProjects = useProjects(); |
| 1436 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 1390 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 1437 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); | 1391 | const activeEnvironment = |
| 1438 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); | 1392 | activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); |
| 1439 | const activeSavedEnvironmentRecord = | 1393 | const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; |
| 1440 | activeThread && activeThread.environmentId !== primaryEnvironmentId | ||
| 1441 | ? (savedEnvironmentRegistry[activeThread.environmentId] ?? null) | ||
| 1442 | : null; | ||
| 1443 | const activeSavedEnvironmentRuntime = activeSavedEnvironmentRecord | ||
| 1444 | ? (savedEnvironmentRuntimeById[activeSavedEnvironmentRecord.environmentId] ?? null) | ||
| 1445 | : null; | ||
| 1446 | const activeSavedEnvironmentConnectionState = activeSavedEnvironmentRecord | ||
| 1447 | ? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected") | ||
| 1448 | : "connected"; | ||
| 1449 | const activeEnvironmentUnavailable = | 1394 | const activeEnvironmentUnavailable = |
| 1450 | activeSavedEnvironmentRecord !== null && activeSavedEnvironmentConnectionState !== "connected"; | 1395 | activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; |
| 1451 | const activeSavedEnvironmentId = activeSavedEnvironmentRecord?.environmentId ?? null; | 1396 | const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; |
| 1452 | const activeEnvironmentUnavailableLabel = activeSavedEnvironmentRecord | ||
| 1453 | ? resolveEnvironmentOptionLabel({ | ||
| 1454 | isPrimary: false, | ||
| 1455 | environmentId: activeSavedEnvironmentRecord.environmentId, | ||
| 1456 | runtimeLabel: activeSavedEnvironmentRuntime?.descriptor?.label ?? null, | ||
| 1457 | savedLabel: activeSavedEnvironmentRecord.label, | ||
| 1458 | }) | ||
| 1459 | : null; | ||
| 1460 | const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => { | 1397 | const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => { |
| 1461 | if ( | 1398 | if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { |
| 1462 | !activeEnvironmentUnavailable || | ||
| 1463 | !activeEnvironmentUnavailableLabel || | ||
| 1464 | !activeSavedEnvironmentId | ||
| 1465 | ) { | ||
| 1466 | return null; | 1399 | return null; |
| 1467 | } | 1400 | } |
| 1468 | 1401 | ||
| 1469 | return { | 1402 | return { |
| 1470 | environmentId: activeSavedEnvironmentId, | 1403 | environmentId: activeEnvironment.environmentId, |
| 1471 | label: activeEnvironmentUnavailableLabel, | 1404 | label: activeEnvironmentUnavailableLabel, |
| 1472 | connectionState: | 1405 | connection: activeEnvironment.connection, |
| 1473 | activeSavedEnvironmentConnectionState === "connecting" || | ||
| 1474 | activeSavedEnvironmentConnectionState === "error" | ||
| 1475 | ? activeSavedEnvironmentConnectionState | ||
| 1476 | : "disconnected", | ||
| 1477 | }; | 1406 | }; |
| 1478 | }, [ | 1407 | }, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]); |
| 1479 | activeEnvironmentUnavailable, | ||
| 1480 | activeEnvironmentUnavailableLabel, | ||
| 1481 | activeSavedEnvironmentConnectionState, | ||
| 1482 | activeSavedEnvironmentId, | ||
| 1483 | ]); | ||
| 1484 | const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState<EnvironmentId | null>( | ||
| 1485 | null, | ||
| 1486 | ); | ||
| 1487 | const handleReconnectActiveEnvironment = useCallback( | 1408 | const handleReconnectActiveEnvironment = useCallback( |
| 1488 | async (environmentId: EnvironmentId, label: string) => { | 1409 | async (environmentId: EnvironmentId) => { |
| 1489 | setReconnectingEnvironmentId(environmentId); | 1410 | const result = await retryEnvironment(environmentId); |
| 1490 | try { | 1411 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 1491 | await reconnectSavedEnvironment(environmentId); | 1412 | const error = squashAtomCommandFailure(result); |
| 1492 | toastManager.add({ | ||
| 1493 | type: "success", | ||
| 1494 | title: "Environment reconnected", | ||
| 1495 | description: `${label} is ready.`, | ||
| 1496 | }); | ||
| 1497 | } catch (error) { | ||
| 1498 | toastManager.add( | 1413 | toastManager.add( |
| 1499 | stackedThreadToast({ | 1414 | stackedThreadToast({ |
| 1500 | type: "error", | 1415 | type: "error", |
| ... | @@ -1502,11 +1417,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1502,11 +1417,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1502 | description: error instanceof Error ? error.message : "Failed to reconnect.", | 1417 | description: error instanceof Error ? error.message : "Failed to reconnect.", |
| 1503 | }), | 1418 | }), |
| 1504 | ); | 1419 | ); |
| 1505 | } finally { | ||
| 1506 | setReconnectingEnvironmentId(null); | ||
| 1507 | } | 1420 | } |
| 1508 | }, | 1421 | }, |
| 1509 | [], | 1422 | [retryEnvironment], |
| 1510 | ); | 1423 | ); |
| 1511 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); | 1424 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); |
| 1512 | const logicalProjectEnvironments = useMemo(() => { | 1425 | const logicalProjectEnvironments = useMemo(() => { |
| ... | @@ -1526,14 +1439,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1526,14 +1439,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1526 | if (seen.has(p.environmentId)) continue; | 1439 | if (seen.has(p.environmentId)) continue; |
| 1527 | seen.add(p.environmentId); | 1440 | seen.add(p.environmentId); |
| 1528 | const isPrimary = p.environmentId === primaryEnvironmentId; | 1441 | const isPrimary = p.environmentId === primaryEnvironmentId; |
| 1529 | const savedRecord = savedEnvironmentRegistry[p.environmentId]; | 1442 | const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; |
| 1530 | const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; | ||
| 1531 | const label = resolveEnvironmentOptionLabel({ | ||
| 1532 | isPrimary, | ||
| 1533 | environmentId: p.environmentId, | ||
| 1534 | runtimeLabel: runtimeState?.descriptor?.label ?? null, | ||
| 1535 | savedLabel: savedRecord?.label ?? null, | ||
| 1536 | }); | ||
| 1537 | envs.push({ | 1443 | envs.push({ |
| 1538 | environmentId: p.environmentId, | 1444 | environmentId: p.environmentId, |
| 1539 | projectId: p.id, | 1445 | projectId: p.id, |
| ... | @@ -1547,14 +1453,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1547,14 +1453,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1547 | return a.label.localeCompare(b.label); | 1453 | return a.label.localeCompare(b.label); |
| 1548 | }); | 1454 | }); |
| 1549 | return envs; | 1455 | return envs; |
| 1550 | }, [ | 1456 | }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); |
| 1551 | activeProject, | ||
| 1552 | allProjects, | ||
| 1553 | projectGroupingSettings, | ||
| 1554 | primaryEnvironmentId, | ||
| 1555 | savedEnvironmentRegistry, | ||
| 1556 | savedEnvironmentRuntimeById, | ||
| 1557 | ]); | ||
| 1558 | const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; | 1457 | const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; |
| 1559 | 1458 | ||
| 1560 | const openPullRequestDialog = useCallback( | 1459 | const openPullRequestDialog = useCallback( |
| ... | @@ -1664,24 +1563,21 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1664,24 +1563,21 @@ function ChatViewContent(props: ChatViewProps) { |
| 1664 | 1563 | ||
| 1665 | useEffect(() => { | 1564 | useEffect(() => { |
| 1666 | if (!serverThread?.id) return; | 1565 | if (!serverThread?.id) return; |
| 1667 | if (!latestTurnSettled) return; | 1566 | const threadUpdatedAt = Date.parse(serverThread.updatedAt); |
| 1668 | if (!activeLatestTurn?.completedAt) return; | 1567 | if (Number.isNaN(threadUpdatedAt)) return; |
| 1669 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); | ||
| 1670 | if (Number.isNaN(turnCompletedAt)) return; | ||
| 1671 | const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; | 1568 | const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; |
| 1672 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; | 1569 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; |
| 1673 | 1570 | ||
| 1674 | markThreadVisited( | 1571 | markThreadVisited( |
| 1675 | scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), | 1572 | scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), |
| 1676 | activeLatestTurn.completedAt, | 1573 | serverThread.updatedAt, |
| 1677 | ); | 1574 | ); |
| 1678 | }, [ | 1575 | }, [ |
| 1679 | activeLatestTurn?.completedAt, | ||
| 1680 | activeThreadLastVisitedAt, | 1576 | activeThreadLastVisitedAt, |
| 1681 | latestTurnSettled, | ||
| 1682 | markThreadVisited, | 1577 | markThreadVisited, |
| 1683 | serverThread?.environmentId, | 1578 | serverThread?.environmentId, |
| 1684 | serverThread?.id, | 1579 | serverThread?.id, |
| 1580 | serverThread?.updatedAt, | ||
| 1685 | ]); | 1581 | ]); |
| 1686 | 1582 | ||
| 1687 | const selectedProviderByThreadId = composerActiveProvider ?? null; | 1583 | const selectedProviderByThreadId = composerActiveProvider ?? null; |
| ... | @@ -1694,17 +1590,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1694,17 +1590,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1694 | selectedProvider: selectedProviderByThreadId, | 1590 | selectedProvider: selectedProviderByThreadId, |
| 1695 | threadProvider, | 1591 | threadProvider, |
| 1696 | }); | 1592 | }); |
| 1697 | const primaryServerConfig = useServerConfig(); | 1593 | const serverConfig = activeEnvironment?.serverConfig ?? primaryEnvironment?.serverConfig ?? null; |
| 1698 | const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => | ||
| 1699 | activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, | ||
| 1700 | ); | ||
| 1701 | // Use the server config for the thread's environment. For the primary | ||
| 1702 | // environment fall back to the global atom; for remote environments use | ||
| 1703 | // the runtime state stored by the environment manager. | ||
| 1704 | const serverConfig = | ||
| 1705 | primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId | ||
| 1706 | ? primaryServerConfig | ||
| 1707 | : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); | ||
| 1708 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); | 1594 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); |
| 1709 | const versionMismatchDismissKey = | 1595 | const versionMismatchDismissKey = |
| 1710 | versionMismatch && activeThread | 1596 | versionMismatch && activeThread |
| ... | @@ -1718,65 +1604,37 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1718,65 +1604,37 @@ function ChatViewContent(props: ChatViewProps) { |
| 1718 | isVersionMismatchDismissed(versionMismatchDismissKey); | 1604 | isVersionMismatchDismissed(versionMismatchDismissKey); |
| 1719 | const showVersionMismatchBanner = | 1605 | const showVersionMismatchBanner = |
| 1720 | versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; | 1606 | versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; |
| 1721 | const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0; | 1607 | const hasMultipleRegisteredEnvironments = environments.length > 1; |
| 1722 | const versionMismatchServerLabel = useMemo(() => { | 1608 | const versionMismatchServerLabel = |
| 1723 | if (!hasMultipleRegisteredEnvironments || !activeThread) { | 1609 | hasMultipleRegisteredEnvironments && activeThread |
| 1724 | return "server"; | 1610 | ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` |
| 1725 | } | 1611 | : "server"; |
| 1726 | |||
| 1727 | const isPrimary = activeThread.environmentId === primaryEnvironmentId; | ||
| 1728 | const savedRecord = savedEnvironmentRegistry[activeThread.environmentId]; | ||
| 1729 | const runtimeState = savedEnvironmentRuntimeById[activeThread.environmentId]; | ||
| 1730 | return `${resolveEnvironmentOptionLabel({ | ||
| 1731 | isPrimary, | ||
| 1732 | environmentId: activeThread.environmentId, | ||
| 1733 | runtimeLabel: runtimeState?.descriptor?.label ?? serverConfig?.environment.label ?? null, | ||
| 1734 | savedLabel: savedRecord?.label ?? null, | ||
| 1735 | })} server`; | ||
| 1736 | }, [ | ||
| 1737 | activeThread, | ||
| 1738 | hasMultipleRegisteredEnvironments, | ||
| 1739 | primaryEnvironmentId, | ||
| 1740 | savedEnvironmentRegistry, | ||
| 1741 | savedEnvironmentRuntimeById, | ||
| 1742 | serverConfig?.environment.label, | ||
| 1743 | ]); | ||
| 1744 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { | 1612 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 1745 | const items: ComposerBannerStackItem[] = []; | 1613 | const items: ComposerBannerStackItem[] = []; |
| 1746 | if (activeEnvironmentUnavailableState) { | 1614 | if (activeEnvironmentUnavailableState) { |
| 1615 | const connection = activeEnvironmentUnavailableState.connection; | ||
| 1616 | const isReconnecting = | ||
| 1617 | connection.phase === "connecting" || connection.phase === "reconnecting"; | ||
| 1747 | items.push({ | 1618 | items.push({ |
| 1748 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, | 1619 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, |
| 1749 | variant: | 1620 | variant: connection.phase === "error" ? "error" : "warning", |
| 1750 | activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning", | ||
| 1751 | icon: <WifiOffIcon />, | 1621 | icon: <WifiOffIcon />, |
| 1752 | title: ( | 1622 | title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusText(connection)}`, |
| 1753 | <> | 1623 | description: |
| 1754 | {activeEnvironmentUnavailableState.label} is{" "} | 1624 | connection.error ?? |
| 1755 | {activeEnvironmentUnavailableState.connectionState === "connecting" | 1625 | "Reconnect this environment before sending messages or running actions.", |
| 1756 | ? "connecting" | ||
| 1757 | : "disconnected"} | ||
| 1758 | </> | ||
| 1759 | ), | ||
| 1760 | description: "Reconnect this environment before sending messages or running actions.", | ||
| 1761 | actions: ( | 1626 | actions: ( |
| 1762 | <> | 1627 | <> |
| 1763 | <Button | 1628 | <Button |
| 1764 | size="xs" | 1629 | size="xs" |
| 1765 | disabled={ | 1630 | disabled={isReconnecting} |
| 1766 | activeEnvironmentUnavailableState.connectionState === "connecting" || | ||
| 1767 | reconnectingEnvironmentId === activeEnvironmentUnavailableState.environmentId | ||
| 1768 | } | ||
| 1769 | onClick={() => | 1631 | onClick={() => |
| 1770 | void handleReconnectActiveEnvironment( | 1632 | void handleReconnectActiveEnvironment( |
| 1771 | activeEnvironmentUnavailableState.environmentId, | 1633 | activeEnvironmentUnavailableState.environmentId, |
| 1772 | activeEnvironmentUnavailableState.label, | ||
| 1773 | ) | 1634 | ) |
| 1774 | } | 1635 | } |
| 1775 | > | 1636 | > |
| 1776 | {activeEnvironmentUnavailableState.connectionState === "connecting" || | 1637 | {isReconnecting ? "Reconnecting..." : "Reconnect"} |
| 1777 | reconnectingEnvironmentId === activeEnvironmentUnavailableState.environmentId | ||
| 1778 | ? "Reconnecting..." | ||
| 1779 | : "Reconnect"} | ||
| 1780 | </Button> | 1638 | </Button> |
| 1781 | <Button | 1639 | <Button |
| 1782 | size="xs" | 1640 | size="xs" |
| ... | @@ -1813,7 +1671,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1813,7 +1671,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1813 | activeEnvironmentUnavailableState, | 1671 | activeEnvironmentUnavailableState, |
| 1814 | handleReconnectActiveEnvironment, | 1672 | handleReconnectActiveEnvironment, |
| 1815 | navigate, | 1673 | navigate, |
| 1816 | reconnectingEnvironmentId, | ||
| 1817 | showVersionMismatchBanner, | 1674 | showVersionMismatchBanner, |
| 1818 | versionMismatch, | 1675 | versionMismatch, |
| 1819 | versionMismatchDismissKey, | 1676 | versionMismatchDismissKey, |
| ... | @@ -1911,7 +1768,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1911,7 +1768,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1911 | phase, | 1768 | phase, |
| 1912 | activePendingApproval: activePendingApproval?.requestId ?? null, | 1769 | activePendingApproval: activePendingApproval?.requestId ?? null, |
| 1913 | activePendingUserInput: activePendingUserInput?.requestId ?? null, | 1770 | activePendingUserInput: activePendingUserInput?.requestId ?? null, |
| 1914 | threadError: activeThread?.error, | 1771 | threadError, |
| 1915 | }); | 1772 | }); |
| 1916 | const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; | 1773 | const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; |
| 1917 | const activeWorkStartedAt = deriveActiveWorkStartedAt( | 1774 | const activeWorkStartedAt = deriveActiveWorkStartedAt( |
| ... | @@ -1980,68 +1837,60 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1980,68 +1837,60 @@ function ChatViewContent(props: ChatViewProps) { |
| 1980 | }); | 1837 | }); |
| 1981 | }, []); | 1838 | }, []); |
| 1982 | const serverMessages = activeThread?.messages; | 1839 | const serverMessages = activeThread?.messages; |
| 1983 | const [attachmentAssetUrlById, setAttachmentAssetUrlById] = useState<Record<string, string>>({}); | 1840 | const serverAttachmentIds = useMemo(() => { |
| 1984 | useEffect(() => { | 1841 | const attachmentIds = new Set<string>(); |
| 1985 | if (!serverMessages) return; | 1842 | for (const message of serverMessages ?? []) { |
| 1986 | const attachmentIds = [ | 1843 | for (const attachment of message.attachments ?? []) { |
| 1987 | ...new Set( | 1844 | attachmentIds.add(attachment.id); |
| 1988 | serverMessages.flatMap( | 1845 | } |
| 1989 | (message) => | ||
| 1990 | message.attachments?.flatMap((attachment) => | ||
| 1991 | attachment.type === "image" && !attachment.previewUrl ? [attachment.id] : [], | ||
| 1992 | ) ?? [], | ||
| 1993 | ), | ||
| 1994 | ), | ||
| 1995 | ].filter((attachmentId) => !attachmentAssetUrlById[attachmentId]); | ||
| 1996 | if (attachmentIds.length === 0) return; | ||
| 1997 | |||
| 1998 | let cancelled = false; | ||
| 1999 | void Promise.all( | ||
| 2000 | attachmentIds.map(async (attachmentId) => { | ||
| 2001 | const asset = await resolveAssetUrl(environmentId, { | ||
| 2002 | _tag: "attachment", | ||
| 2003 | attachmentId, | ||
| 2004 | }); | ||
| 2005 | return [attachmentId, asset.url] as const; | ||
| 2006 | }), | ||
| 2007 | ) | ||
| 2008 | .then((entries) => { | ||
| 2009 | if (!cancelled) { | ||
| 2010 | setAttachmentAssetUrlById((current) => ({ ...current, ...Object.fromEntries(entries) })); | ||
| 2011 | } | ||
| 2012 | }) | ||
| 2013 | .catch(() => undefined); | ||
| 2014 | |||
| 2015 | return () => { | ||
| 2016 | cancelled = true; | ||
| 2017 | }; | ||
| 2018 | }, [attachmentAssetUrlById, environmentId, serverMessages]); | ||
| 2019 | const serverMessagesWithAssetUrls = useMemo(() => { | ||
| 2020 | if (!serverMessages || Object.keys(attachmentAssetUrlById).length === 0) { | ||
| 2021 | return serverMessages; | ||
| 2022 | } | 1846 | } |
| 1847 | return [...attachmentIds]; | ||
| 1848 | }, [serverMessages]); | ||
| 1849 | const serverAttachmentResources = useMemo( | ||
| 1850 | () => | ||
| 1851 | serverAttachmentIds.map((attachmentId) => ({ | ||
| 1852 | _tag: "attachment" as const, | ||
| 1853 | attachmentId, | ||
| 1854 | })), | ||
| 1855 | [serverAttachmentIds], | ||
| 1856 | ); | ||
| 1857 | const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); | ||
| 1858 | const serverAttachmentUrlById = useMemo( | ||
| 1859 | () => | ||
| 1860 | new Map( | ||
| 1861 | serverAttachmentIds.flatMap((attachmentId, index) => { | ||
| 1862 | const url = serverAttachmentUrls[index]; | ||
| 1863 | return url ? [[attachmentId, url] as const] : []; | ||
| 1864 | }), | ||
| 1865 | ), | ||
| 1866 | [serverAttachmentIds, serverAttachmentUrls], | ||
| 1867 | ); | ||
| 1868 | const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => { | ||
| 1869 | if (!serverMessages) return []; | ||
| 2023 | return serverMessages.map((message) => { | 1870 | return serverMessages.map((message) => { |
| 2024 | if (!message.attachments) return message; | 1871 | if (!message.attachments || message.attachments.length === 0) { |
| 2025 | let changed = false; | 1872 | return message; |
| 2026 | const attachments = message.attachments.map((attachment) => { | 1873 | } |
| 2027 | const previewUrl = attachmentAssetUrlById[attachment.id]; | 1874 | return { |
| 2028 | if (!previewUrl || attachment.previewUrl === previewUrl) return attachment; | 1875 | ...message, |
| 2029 | changed = true; | 1876 | attachments: message.attachments.map((attachment) => { |
| 2030 | return { ...attachment, previewUrl }; | 1877 | const previewUrl = serverAttachmentUrlById.get(attachment.id); |
| 2031 | }); | 1878 | return previewUrl ? { ...attachment, previewUrl } : attachment; |
| 2032 | return changed ? { ...message, attachments } : message; | 1879 | }), |
| 1880 | }; | ||
| 2033 | }); | 1881 | }); |
| 2034 | }, [attachmentAssetUrlById, serverMessages]); | 1882 | }, [serverAttachmentUrlById, serverMessages]); |
| 2035 | useEffect(() => { | 1883 | useEffect(() => { |
| 2036 | if ( | 1884 | if (typeof Image === "undefined" || displayServerMessages.length === 0) { |
| 2037 | typeof Image === "undefined" || | ||
| 2038 | !serverMessagesWithAssetUrls || | ||
| 2039 | serverMessagesWithAssetUrls.length === 0 | ||
| 2040 | ) { | ||
| 2041 | return; | 1885 | return; |
| 2042 | } | 1886 | } |
| 2043 | 1887 | ||
| 2044 | const cleanups: Array<() => void> = []; | 1888 | const cleanups: Array<() => void> = []; |
| 1889 | const userMessagesById = new Map<string, ChatMessage>( | ||
| 1890 | displayServerMessages | ||
| 1891 | .filter((message) => message.role === "user") | ||
| 1892 | .map((message) => [String(message.id), message] as const), | ||
| 1893 | ); | ||
| 2045 | 1894 | ||
| 2046 | for (const [messageId, handoffPreviewUrls] of Object.entries( | 1895 | for (const [messageId, handoffPreviewUrls] of Object.entries( |
| 2047 | attachmentPreviewHandoffByMessageId, | 1896 | attachmentPreviewHandoffByMessageId, |
| ... | @@ -2050,9 +1899,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2050,9 +1899,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2050 | continue; | 1899 | continue; |
| 2051 | } | 1900 | } |
| 2052 | 1901 | ||
| 2053 | const serverMessage = serverMessagesWithAssetUrls.find( | 1902 | const serverMessage = userMessagesById.get(messageId); |
| 2054 | (message) => message.id === messageId && message.role === "user", | ||
| 2055 | ); | ||
| 2056 | if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { | 1903 | if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { |
| 2057 | continue; | 1904 | continue; |
| 2058 | } | 1905 | } |
| ... | @@ -2116,13 +1963,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2116,13 +1963,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 2116 | cleanup(); | 1963 | cleanup(); |
| 2117 | } | 1964 | } |
| 2118 | }; | 1965 | }; |
| 2119 | }, [ | 1966 | }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, displayServerMessages]); |
| 2120 | attachmentPreviewHandoffByMessageId, | ||
| 2121 | clearAttachmentPreviewHandoff, | ||
| 2122 | serverMessagesWithAssetUrls, | ||
| 2123 | ]); | ||
| 2124 | const timelineMessages = useMemo(() => { | 1967 | const timelineMessages = useMemo(() => { |
| 2125 | const messages = serverMessagesWithAssetUrls ?? []; | 1968 | const messages = displayServerMessages; |
| 2126 | const serverMessagesWithPreviewHandoff = | 1969 | const serverMessagesWithPreviewHandoff = |
| 2127 | Object.keys(attachmentPreviewHandoffByMessageId).length === 0 | 1970 | Object.keys(attachmentPreviewHandoffByMessageId).length === 0 |
| 2128 | ? messages | 1971 | ? messages |
| ... | @@ -2173,7 +2016,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2173,7 +2016,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2173 | return serverMessagesWithPreviewHandoff; | 2016 | return serverMessagesWithPreviewHandoff; |
| 2174 | } | 2017 | } |
| 2175 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; | 2018 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; |
| 2176 | }, [serverMessagesWithAssetUrls, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); | 2019 | }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); |
| 2177 | const timelineEntries = useMemo( | 2020 | const timelineEntries = useMemo( |
| 2178 | () => | 2021 | () => |
| 2179 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), | 2022 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), |
| ... | @@ -2224,13 +2067,20 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2224,13 +2067,20 @@ function ChatViewContent(props: ChatViewProps) { |
| 2224 | 2067 | ||
| 2225 | const gitCwd = activeProject | 2068 | const gitCwd = activeProject |
| 2226 | ? projectScriptCwd({ | 2069 | ? projectScriptCwd({ |
| 2227 | project: { cwd: activeProject.cwd }, | 2070 | project: { cwd: activeProject.workspaceRoot }, |
| 2228 | worktreePath: activeThread?.worktreePath ?? null, | 2071 | worktreePath: activeThread?.worktreePath ?? null, |
| 2229 | }) | 2072 | }) |
| 2230 | : null; | 2073 | : null; |
| 2231 | const gitStatusQuery = useVcsStatus({ environmentId, cwd: gitCwd }); | 2074 | const gitStatusQuery = useEnvironmentQuery( |
| 2232 | const keybindings = useServerKeybindings(); | 2075 | gitCwd === null |
| 2233 | const availableEditors = useServerAvailableEditors(); | 2076 | ? null |
| 2077 | : vcsEnvironment.status({ | ||
| 2078 | environmentId, | ||
| 2079 | input: { cwd: gitCwd }, | ||
| 2080 | }), | ||
| 2081 | ); | ||
| 2082 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | ||
| 2083 | const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); | ||
| 2234 | // Prefer an instance-id match so a custom Codex instance (e.g. | 2084 | // Prefer an instance-id match so a custom Codex instance (e.g. |
| 2235 | // `codex_personal`) surfaces its own status/message in the banner rather | 2085 | // `codex_personal`) surfaces its own status/message in the banner rather |
| 2236 | // than the default Codex's. Falls back to first-match-by-kind when no | 2086 | // than the default Codex's. Falls back to first-match-by-kind when no |
| ... | @@ -2253,7 +2103,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2253,7 +2103,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2253 | const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); | 2103 | const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); |
| 2254 | return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; | 2104 | return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; |
| 2255 | }, [activeProviderInstanceId, providerStatuses, selectedProvider]); | 2105 | }, [activeProviderInstanceId, providerStatuses, selectedProvider]); |
| 2256 | const activeProjectCwd = activeProject?.cwd ?? null; | 2106 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| 2257 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; | 2107 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; |
| 2258 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; | 2108 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; |
| 2259 | const activeTerminalLaunchContext = | 2109 | const activeTerminalLaunchContext = |
| ... | @@ -2269,8 +2119,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2269,8 +2119,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 2269 | }), | 2119 | }), |
| 2270 | [terminalUiState.terminalOpen], | 2120 | [terminalUiState.terminalOpen], |
| 2271 | ); | 2121 | ); |
| 2272 | const terminalToggleShortcutLabel = shortcutLabelForCommand(keybindings, "terminal.toggle"); | ||
| 2273 | const rightPanelToggleShortcutLabel = shortcutLabelForCommand(keybindings, "rightPanel.toggle"); | ||
| 2274 | const splitTerminalShortcutLabel = useMemo( | 2122 | const splitTerminalShortcutLabel = useMemo( |
| 2275 | () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), | 2123 | () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), |
| 2276 | [keybindings, terminalShortcutLabelOptions], | 2124 | [keybindings, terminalShortcutLabelOptions], |
| ... | @@ -2288,90 +2136,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2288,90 +2136,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 2288 | () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), | 2136 | () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), |
| 2289 | [keybindings, terminalShortcutLabelOptions], | 2137 | [keybindings, terminalShortcutLabelOptions], |
| 2290 | ); | 2138 | ); |
| 2291 | const createBrowserSurface = useCallback(() => { | ||
| 2292 | if (!activeThreadRef) return; | ||
| 2293 | const api = readEnvironmentApi(activeThreadRef.environmentId); | ||
| 2294 | if (!api) return; | ||
| 2295 | void api.preview | ||
| 2296 | .open({ threadId: activeThreadRef.threadId }) | ||
| 2297 | .then((snapshot) => { | ||
| 2298 | usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); | ||
| 2299 | useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); | ||
| 2300 | }) | ||
| 2301 | .catch(() => undefined); | ||
| 2302 | }, [activeThreadRef]); | ||
| 2303 | const addDiffSurface = useCallback(() => { | ||
| 2304 | if (!activeThreadRef || !isServerThread || !isGitRepo) return; | ||
| 2305 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | ||
| 2306 | onDiffPanelOpen?.(); | ||
| 2307 | void navigate({ | ||
| 2308 | to: "/$environmentId/$threadId", | ||
| 2309 | params: { environmentId, threadId }, | ||
| 2310 | replace: true, | ||
| 2311 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), | ||
| 2312 | }); | ||
| 2313 | }, [ | ||
| 2314 | activeThreadRef, | ||
| 2315 | environmentId, | ||
| 2316 | isGitRepo, | ||
| 2317 | isServerThread, | ||
| 2318 | navigate, | ||
| 2319 | onDiffPanelOpen, | ||
| 2320 | threadId, | ||
| 2321 | ]); | ||
| 2322 | const addFilesSurface = () => { | ||
| 2323 | if (!activeThreadRef || !activeProject) return; | ||
| 2324 | useRightPanelStore.getState().open(activeThreadRef, "files"); | ||
| 2325 | }; | ||
| 2326 | const openFileSurface = (relativePath: string) => { | ||
| 2327 | if (!activeThreadRef || !activeProject) return; | ||
| 2328 | useRightPanelStore.getState().openFile(activeThreadRef, relativePath); | ||
| 2329 | }; | ||
| 2330 | // Right-panel arbitration: | ||
| 2331 | // - The diff panel's openness is mirrored by the `?diff=1` URL search | ||
| 2332 | // param so it deep-links cleanly. The store still records preview/plan | ||
| 2333 | // openness; when both fight, `selectActiveRightPanelKindWithUrl` lets | ||
| 2334 | // diff win (URL is truth). | ||
| 2335 | // - The two toggles below treat the panels as mutually exclusive: opening | ||
| 2336 | // one updates BOTH the URL and the store. Without this, e.g. clicking the | ||
| 2337 | // browser button while diff is URL-pinned would just toggle the | ||
| 2338 | // (overridden) store value and look like a no-op. | ||
| 2339 | const onTogglePreview = useCallback(() => { | ||
| 2340 | if (!activeThreadRef) return; | ||
| 2341 | if (previewPanelOpen) { | ||
| 2342 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 2343 | return; | ||
| 2344 | } | ||
| 2345 | if (diffOpen) { | ||
| 2346 | void navigate({ | ||
| 2347 | to: "/$environmentId/$threadId", | ||
| 2348 | params: { environmentId, threadId }, | ||
| 2349 | replace: true, | ||
| 2350 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), | ||
| 2351 | }); | ||
| 2352 | } | ||
| 2353 | const activeTabId = activePreviewState.activeTabId; | ||
| 2354 | if (!activeTabId) { | ||
| 2355 | createBrowserSurface(); | ||
| 2356 | return; | ||
| 2357 | } | ||
| 2358 | useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); | ||
| 2359 | }, [ | ||
| 2360 | activePreviewState.activeTabId, | ||
| 2361 | activeThreadRef, | ||
| 2362 | createBrowserSurface, | ||
| 2363 | diffOpen, | ||
| 2364 | environmentId, | ||
| 2365 | navigate, | ||
| 2366 | previewPanelOpen, | ||
| 2367 | threadId, | ||
| 2368 | ]); | ||
| 2369 | const onToggleDiff = useCallback(() => { | 2139 | const onToggleDiff = useCallback(() => { |
| 2370 | if (!isServerThread) { | 2140 | if (!isServerThread) { |
| 2371 | return; | 2141 | return; |
| 2372 | } | 2142 | } |
| 2373 | const diffPanelOpen = activeRightPanelKind === "diff"; | 2143 | if (!diffOpen) { |
| 2374 | if (!diffPanelOpen) { | ||
| 2375 | onDiffPanelOpen?.(); | 2144 | onDiffPanelOpen?.(); |
| 2376 | } | 2145 | } |
| 2377 | if (activeThreadRef) { | 2146 | if (activeThreadRef) { |
| ... | @@ -2386,12 +2155,12 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2386,12 +2155,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 2386 | replace: true, | 2155 | replace: true, |
| 2387 | search: (previous) => { | 2156 | search: (previous) => { |
| 2388 | const rest = stripDiffSearchParams(previous); | 2157 | const rest = stripDiffSearchParams(previous); |
| 2389 | return diffPanelOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; | 2158 | return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; |
| 2390 | }, | 2159 | }, |
| 2391 | }); | 2160 | }); |
| 2392 | }, [ | 2161 | }, [ |
| 2393 | activeRightPanelKind, | ||
| 2394 | activeThreadRef, | 2162 | activeThreadRef, |
| 2163 | diffOpen, | ||
| 2395 | environmentId, | 2164 | environmentId, |
| 2396 | isServerThread, | 2165 | isServerThread, |
| 2397 | navigate, | 2166 | navigate, |
| ... | @@ -2399,20 +2168,10 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2399,20 +2168,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 2399 | threadId, | 2168 | threadId, |
| 2400 | ]); | 2169 | ]); |
| 2401 | 2170 | ||
| 2402 | // Route the global mod+shift+J shortcut (dispatched from `routes/_chat.tsx` | ||
| 2403 | // via `previewActionBus`) through the URL-aware toggle defined above. | ||
| 2404 | useEffect(() => { | ||
| 2405 | return subscribePreviewAction((action) => { | ||
| 2406 | if (action !== "toggle-panel") return; | ||
| 2407 | if (!isPreviewSupportedInRuntime()) return; | ||
| 2408 | onTogglePreview(); | ||
| 2409 | }); | ||
| 2410 | }, [onTogglePreview]); | ||
| 2411 | |||
| 2412 | const envLocked = Boolean( | 2171 | const envLocked = Boolean( |
| 2413 | activeThread && | 2172 | activeThread && |
| 2414 | (activeThread.messages.length > 0 || | 2173 | (activeThread.messages.length > 0 || |
| 2415 | (activeThread.session !== null && activeThread.session.status !== "closed")), | 2174 | (activeThread.session !== null && activeThread.session.status !== "stopped")), |
| 2416 | ); | 2175 | ); |
| 2417 | 2176 | ||
| 2418 | // Handle environment change for draft threads. When the user picks a | 2177 | // Handle environment change for draft threads. When the user picks a |
| ... | @@ -2446,13 +2205,21 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2446,13 +2205,21 @@ function ChatViewContent(props: ChatViewProps) { |
| 2446 | (targetThreadId: ThreadId | null, error: string | null) => { | 2205 | (targetThreadId: ThreadId | null, error: string | null) => { |
| 2447 | if (!targetThreadId) return; | 2206 | if (!targetThreadId) return; |
| 2448 | const nextError = sanitizeThreadErrorMessage(error); | 2207 | const nextError = sanitizeThreadErrorMessage(error); |
| 2449 | const isCurrentServerThread = shouldWriteThreadErrorToCurrentServerThread({ | 2208 | if ( |
| 2450 | serverThread, | 2209 | serverThread && |
| 2451 | routeThreadRef, | 2210 | targetThreadId === routeThreadRef.threadId && |
| 2452 | targetThreadId, | 2211 | serverThread.environmentId === routeThreadRef.environmentId && |
| 2453 | }); | 2212 | serverThread.id === targetThreadId |
| 2454 | if (isCurrentServerThread) { | 2213 | ) { |
| 2455 | setStoreThreadError(targetThreadId, nextError); | 2214 | setLocalServerErrorsByThreadKey((existing) => { |
| 2215 | if ((existing[routeThreadKey] ?? null) === nextError) { | ||
| 2216 | return existing; | ||
| 2217 | } | ||
| 2218 | return { | ||
| 2219 | ...existing, | ||
| 2220 | [routeThreadKey]: nextError, | ||
| 2221 | }; | ||
| 2222 | }); | ||
| 2456 | return; | 2223 | return; |
| 2457 | } | 2224 | } |
| 2458 | const localDraftErrorKey = draftId ?? targetThreadId; | 2225 | const localDraftErrorKey = draftId ?? targetThreadId; |
| ... | @@ -2466,7 +2233,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2466,7 +2233,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2466 | }; | 2233 | }; |
| 2467 | }); | 2234 | }); |
| 2468 | }, | 2235 | }, |
| 2469 | [draftId, routeThreadRef, serverThread, setStoreThreadError], | 2236 | [draftId, routeThreadKey, routeThreadRef, serverThread], |
| 2470 | ); | 2237 | ); |
| 2471 | 2238 | ||
| 2472 | const focusComposer = useCallback(() => { | 2239 | const focusComposer = useCallback(() => { |
| ... | @@ -2486,147 +2253,63 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2486,147 +2253,63 @@ function ChatViewContent(props: ChatViewProps) { |
| 2486 | const setTerminalOpen = useCallback( | 2253 | const setTerminalOpen = useCallback( |
| 2487 | (open: boolean) => { | 2254 | (open: boolean) => { |
| 2488 | if (!activeThreadRef) return; | 2255 | if (!activeThreadRef) return; |
| 2489 | if (open && terminalUiState.terminalIds.length === 0) { | ||
| 2490 | storeNewTerminal( | ||
| 2491 | activeThreadRef, | ||
| 2492 | nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]), | ||
| 2493 | ); | ||
| 2494 | return; | ||
| 2495 | } | ||
| 2496 | storeSetTerminalOpen(activeThreadRef, open); | 2256 | storeSetTerminalOpen(activeThreadRef, open); |
| 2497 | }, | 2257 | }, |
| 2498 | [ | 2258 | [activeThreadRef, storeSetTerminalOpen], |
| 2499 | activeKnownTerminalIds, | ||
| 2500 | activeThreadRef, | ||
| 2501 | panelTerminalIds, | ||
| 2502 | storeNewTerminal, | ||
| 2503 | storeSetTerminalOpen, | ||
| 2504 | terminalUiState.terminalIds.length, | ||
| 2505 | ], | ||
| 2506 | ); | 2259 | ); |
| 2507 | const addTerminalSurface = useCallback(() => { | 2260 | const toggleTerminalVisibility = useCallback(() => { |
| 2508 | if (!activeThreadRef || !activeThreadId || !activeProject) return; | 2261 | if (!activeThreadRef) return; |
| 2509 | const api = readEnvironmentApi(activeThreadRef.environmentId); | 2262 | const nextOpen = !terminalUiState.terminalOpen; |
| 2510 | const cwd = gitCwd ?? activeProject.cwd; | 2263 | if (nextOpen && terminalUiState.terminalIds.length === 0) { |
| 2511 | if (!api || !cwd) return; | 2264 | if (!activeThreadId || !activeProject) { |
| 2512 | const panelIds = selectThreadRightPanelState( | 2265 | return; |
| 2513 | useRightPanelStore.getState().byThreadKey, | 2266 | } |
| 2514 | activeThreadRef, | 2267 | const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; |
| 2515 | ).surfaces.flatMap((surface) => (surface.kind === "terminal" ? surface.terminalIds : [])); | 2268 | if (!cwdForOpen) { |
| 2516 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelIds]); | ||
| 2517 | useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); | ||
| 2518 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2519 | void api.terminal | ||
| 2520 | .open({ | ||
| 2521 | threadId: activeThreadId, | ||
| 2522 | terminalId, | ||
| 2523 | cwd, | ||
| 2524 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | ||
| 2525 | env: projectScriptRuntimeEnv({ | ||
| 2526 | project: { cwd: activeProject.cwd }, | ||
| 2527 | worktreePath: activeThreadWorktreePath, | ||
| 2528 | }), | ||
| 2529 | }) | ||
| 2530 | .catch(() => undefined); | ||
| 2531 | }, [ | ||
| 2532 | activeKnownTerminalIds, | ||
| 2533 | activeProject, | ||
| 2534 | activeThreadId, | ||
| 2535 | activeThreadRef, | ||
| 2536 | activeThreadWorktreePath, | ||
| 2537 | gitCwd, | ||
| 2538 | ]); | ||
| 2539 | const splitPanelTerminal = useCallback( | ||
| 2540 | (direction: "horizontal" | "vertical" = "horizontal") => { | ||
| 2541 | if ( | ||
| 2542 | !activeThreadRef || | ||
| 2543 | !activeThreadId || | ||
| 2544 | !activeProject || | ||
| 2545 | activeRightPanelSurface?.kind !== "terminal" || | ||
| 2546 | activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP | ||
| 2547 | ) { | ||
| 2548 | return; | 2269 | return; |
| 2549 | } | 2270 | } |
| 2550 | const api = readEnvironmentApi(activeThreadRef.environmentId); | ||
| 2551 | const cwd = gitCwd ?? activeProject.cwd; | ||
| 2552 | if (!api || !cwd) return; | ||
| 2553 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | 2271 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); |
| 2554 | useRightPanelStore | 2272 | storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); |
| 2555 | .getState() | 2273 | void openTerminal({ |
| 2556 | .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); | 2274 | environmentId, |
| 2557 | setTerminalFocusRequestId((value) => value + 1); | 2275 | input: { |
| 2558 | void api.terminal | ||
| 2559 | .open({ | ||
| 2560 | threadId: activeThreadId, | 2276 | threadId: activeThreadId, |
| 2561 | terminalId, | 2277 | terminalId, |
| 2562 | cwd, | 2278 | cwd: cwdForOpen, |
| 2563 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | 2279 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), |
| 2564 | env: projectScriptRuntimeEnv({ | 2280 | env: projectScriptRuntimeEnv({ |
| 2565 | project: { cwd: activeProject.cwd }, | 2281 | project: { cwd: activeProject.workspaceRoot }, |
| 2566 | worktreePath: activeThreadWorktreePath, | 2282 | worktreePath: activeThreadWorktreePath, |
| 2567 | }), | 2283 | }), |
| 2568 | }) | 2284 | }, |
| 2569 | .catch(() => undefined); | 2285 | }); |
| 2570 | }, | 2286 | return; |
| 2571 | [ | 2287 | } |
| 2572 | activeKnownTerminalIds, | 2288 | setTerminalOpen(nextOpen); |
| 2573 | activeProject, | 2289 | }, [ |
| 2574 | activeRightPanelSurface, | 2290 | activeKnownTerminalIds, |
| 2575 | activeThreadId, | 2291 | activeProject, |
| 2576 | activeThreadRef, | 2292 | activeThreadId, |
| 2577 | activeThreadWorktreePath, | 2293 | activeThreadRef, |
| 2578 | gitCwd, | 2294 | activeThreadWorktreePath, |
| 2579 | panelTerminalIds, | 2295 | environmentId, |
| 2580 | ], | 2296 | gitCwd, |
| 2581 | ); | 2297 | openTerminal, |
| 2582 | const splitPanelTerminalVertical = useCallback(() => { | 2298 | panelTerminalIds, |
| 2583 | splitPanelTerminal("vertical"); | 2299 | setTerminalOpen, |
| 2584 | }, [splitPanelTerminal]); | 2300 | storeEnsureTerminal, |
| 2585 | const activatePanelTerminal = useCallback( | 2301 | terminalUiState.terminalIds.length, |
| 2586 | (terminalId: string) => { | 2302 | terminalUiState.terminalOpen, |
| 2587 | if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; | 2303 | ]); |
| 2588 | useRightPanelStore | ||
| 2589 | .getState() | ||
| 2590 | .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); | ||
| 2591 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2592 | }, | ||
| 2593 | [activeRightPanelSurface, activeThreadRef], | ||
| 2594 | ); | ||
| 2595 | const closePanelTerminal = useCallback( | ||
| 2596 | (terminalId: string) => { | ||
| 2597 | if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; | ||
| 2598 | const api = readEnvironmentApi(activeThreadRef.environmentId); | ||
| 2599 | void api?.terminal | ||
| 2600 | .close({ | ||
| 2601 | threadId: activeThreadRef.threadId, | ||
| 2602 | terminalId, | ||
| 2603 | deleteHistory: true, | ||
| 2604 | }) | ||
| 2605 | .catch(() => undefined); | ||
| 2606 | useRightPanelStore | ||
| 2607 | .getState() | ||
| 2608 | .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); | ||
| 2609 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2610 | }, | ||
| 2611 | [activeRightPanelSurface, activeThreadRef], | ||
| 2612 | ); | ||
| 2613 | const toggleTerminalVisibility = useCallback(() => { | ||
| 2614 | if (!activeThreadRef) return; | ||
| 2615 | setTerminalOpen(!terminalUiState.terminalOpen); | ||
| 2616 | }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); | ||
| 2617 | const splitTerminal = useCallback( | 2304 | const splitTerminal = useCallback( |
| 2618 | (direction: "horizontal" | "vertical" = "horizontal") => { | 2305 | (direction: "horizontal" | "vertical" = "horizontal") => { |
| 2619 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { | 2306 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { |
| 2620 | return; | 2307 | return; |
| 2621 | } | 2308 | } |
| 2622 | const cwdForOpen = gitCwd ?? activeProject.cwd; | 2309 | const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; |
| 2623 | if (!cwdForOpen) { | 2310 | if (!cwdForOpen) { |
| 2624 | return; | 2311 | return; |
| 2625 | } | 2312 | } |
| 2626 | const api = readEnvironmentApi(environmentId); | ||
| 2627 | if (!api) { | ||
| 2628 | return; | ||
| 2629 | } | ||
| 2630 | const terminalId = nextTerminalId(activeKnownTerminalIds); | 2313 | const terminalId = nextTerminalId(activeKnownTerminalIds); |
| 2631 | if (direction === "vertical") { | 2314 | if (direction === "vertical") { |
| 2632 | storeSplitTerminalVertical(activeThreadRef, terminalId); | 2315 | storeSplitTerminalVertical(activeThreadRef, terminalId); |
| ... | @@ -2634,28 +2317,26 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2634,28 +2317,26 @@ function ChatViewContent(props: ChatViewProps) { |
| 2634 | storeSplitTerminal(activeThreadRef, terminalId); | 2317 | storeSplitTerminal(activeThreadRef, terminalId); |
| 2635 | } | 2318 | } |
| 2636 | setTerminalFocusRequestId((value) => value + 1); | 2319 | setTerminalFocusRequestId((value) => value + 1); |
| 2637 | void (async () => { | 2320 | void openTerminal({ |
| 2638 | try { | 2321 | environmentId, |
| 2639 | await api.terminal.open({ | 2322 | input: { |
| 2640 | threadId: activeThreadId, | 2323 | threadId: activeThreadId, |
| 2641 | terminalId, | 2324 | terminalId, |
| 2642 | cwd: cwdForOpen, | 2325 | cwd: cwdForOpen, |
| 2643 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | 2326 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), |
| 2644 | env: projectScriptRuntimeEnv({ | 2327 | env: projectScriptRuntimeEnv({ |
| 2645 | project: { cwd: activeProject.cwd }, | 2328 | project: { cwd: activeProject.workspaceRoot }, |
| 2646 | worktreePath: activeThreadWorktreePath, | 2329 | worktreePath: activeThreadWorktreePath, |
| 2647 | }), | 2330 | }), |
| 2648 | }); | 2331 | }, |
| 2649 | } catch { | 2332 | }); |
| 2650 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 2651 | } | ||
| 2652 | })(); | ||
| 2653 | }, | 2333 | }, |
| 2654 | [ | 2334 | [ |
| 2655 | activeProject, | 2335 | activeProject, |
| 2656 | activeKnownTerminalIds, | 2336 | activeKnownTerminalIds, |
| 2657 | activeThreadId, | 2337 | activeThreadId, |
| 2658 | activeThreadRef, | 2338 | activeThreadRef, |
| 2339 | openTerminal, | ||
| 2659 | activeThreadWorktreePath, | 2340 | activeThreadWorktreePath, |
| 2660 | environmentId, | 2341 | environmentId, |
| 2661 | gitCwd, | 2342 | gitCwd, |
| ... | @@ -2668,38 +2349,32 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2668,38 +2349,32 @@ function ChatViewContent(props: ChatViewProps) { |
| 2668 | if (!activeThreadRef || !activeThreadId || !activeProject) { | 2349 | if (!activeThreadRef || !activeThreadId || !activeProject) { |
| 2669 | return; | 2350 | return; |
| 2670 | } | 2351 | } |
| 2671 | const cwdForOpen = gitCwd ?? activeProject.cwd; | 2352 | const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; |
| 2672 | if (!cwdForOpen) { | 2353 | if (!cwdForOpen) { |
| 2673 | return; | 2354 | return; |
| 2674 | } | 2355 | } |
| 2675 | const api = readEnvironmentApi(environmentId); | ||
| 2676 | if (!api) { | ||
| 2677 | return; | ||
| 2678 | } | ||
| 2679 | const terminalId = nextTerminalId(activeKnownTerminalIds); | 2356 | const terminalId = nextTerminalId(activeKnownTerminalIds); |
| 2680 | storeNewTerminal(activeThreadRef, terminalId); | 2357 | storeNewTerminal(activeThreadRef, terminalId); |
| 2681 | setTerminalFocusRequestId((value) => value + 1); | 2358 | setTerminalFocusRequestId((value) => value + 1); |
| 2682 | void (async () => { | 2359 | void openTerminal({ |
| 2683 | try { | 2360 | environmentId, |
| 2684 | await api.terminal.open({ | 2361 | input: { |
| 2685 | threadId: activeThreadId, | 2362 | threadId: activeThreadId, |
| 2686 | terminalId, | 2363 | terminalId, |
| 2687 | cwd: cwdForOpen, | 2364 | cwd: cwdForOpen, |
| 2688 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | 2365 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), |
| 2689 | env: projectScriptRuntimeEnv({ | 2366 | env: projectScriptRuntimeEnv({ |
| 2690 | project: { cwd: activeProject.cwd }, | 2367 | project: { cwd: activeProject.workspaceRoot }, |
| 2691 | worktreePath: activeThreadWorktreePath, | 2368 | worktreePath: activeThreadWorktreePath, |
| 2692 | }), | 2369 | }), |
| 2693 | }); | 2370 | }, |
| 2694 | } catch { | 2371 | }); |
| 2695 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 2696 | } | ||
| 2697 | })(); | ||
| 2698 | }, [ | 2372 | }, [ |
| 2699 | activeProject, | 2373 | activeProject, |
| 2700 | activeKnownTerminalIds, | 2374 | activeKnownTerminalIds, |
| 2701 | activeThreadId, | 2375 | activeThreadId, |
| 2702 | activeThreadRef, | 2376 | activeThreadRef, |
| 2377 | openTerminal, | ||
| 2703 | activeThreadWorktreePath, | 2378 | activeThreadWorktreePath, |
| 2704 | environmentId, | 2379 | environmentId, |
| 2705 | gitCwd, | 2380 | gitCwd, |
| ... | @@ -2707,33 +2382,36 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2707,33 +2382,36 @@ function ChatViewContent(props: ChatViewProps) { |
| 2707 | ]); | 2382 | ]); |
| 2708 | const closeTerminal = useCallback( | 2383 | const closeTerminal = useCallback( |
| 2709 | (terminalId: string) => { | 2384 | (terminalId: string) => { |
| 2710 | const api = readEnvironmentApi(environmentId); | 2385 | if (!activeThreadId || !activeThreadRef) return; |
| 2711 | if (!activeThreadId || !api || !activeThreadRef) return; | ||
| 2712 | const isFinalTerminal = activeKnownTerminalIds.length <= 1; | ||
| 2713 | const fallbackExitWrite = () => | 2386 | const fallbackExitWrite = () => |
| 2714 | api.terminal | 2387 | writeTerminal({ |
| 2715 | .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) | 2388 | environmentId, |
| 2716 | .catch(() => undefined); | 2389 | input: { threadId: activeThreadId, terminalId, data: "exit\n" }, |
| 2717 | if ("close" in api.terminal && typeof api.terminal.close === "function") { | 2390 | }); |
| 2718 | void (async () => { | 2391 | void (async () => { |
| 2719 | if (isFinalTerminal) { | 2392 | const closeResult = await closeTerminalMutation({ |
| 2720 | await api.terminal | 2393 | environmentId, |
| 2721 | .clear({ threadId: activeThreadId, terminalId }) | 2394 | input: { |
| 2722 | .catch(() => undefined); | ||
| 2723 | } | ||
| 2724 | await api.terminal.close({ | ||
| 2725 | threadId: activeThreadId, | 2395 | threadId: activeThreadId, |
| 2726 | terminalId, | 2396 | terminalId, |
| 2727 | deleteHistory: true, | 2397 | deleteHistory: true, |
| 2728 | }); | 2398 | }, |
| 2729 | })().catch(() => fallbackExitWrite()); | 2399 | }); |
| 2730 | } else { | 2400 | if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { |
| 2731 | void fallbackExitWrite(); | 2401 | await fallbackExitWrite(); |
| 2732 | } | 2402 | } |
| 2403 | })(); | ||
| 2733 | storeCloseTerminal(activeThreadRef, terminalId); | 2404 | storeCloseTerminal(activeThreadRef, terminalId); |
| 2734 | setTerminalFocusRequestId((value) => value + 1); | 2405 | setTerminalFocusRequestId((value) => value + 1); |
| 2735 | }, | 2406 | }, |
| 2736 | [activeThreadId, activeThreadRef, activeKnownTerminalIds, environmentId, storeCloseTerminal], | 2407 | [ |
| 2408 | activeThreadId, | ||
| 2409 | activeThreadRef, | ||
| 2410 | closeTerminalMutation, | ||
| 2411 | environmentId, | ||
| 2412 | storeCloseTerminal, | ||
| 2413 | writeTerminal, | ||
| 2414 | ], | ||
| 2737 | ); | 2415 | ); |
| 2738 | const runProjectScript = useCallback( | 2416 | const runProjectScript = useCallback( |
| 2739 | async ( | 2417 | async ( |
| ... | @@ -2746,15 +2424,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2746,15 +2424,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 2746 | rememberAsLastInvoked?: boolean; | 2424 | rememberAsLastInvoked?: boolean; |
| 2747 | }, | 2425 | }, |
| 2748 | ) => { | 2426 | ) => { |
| 2749 | const api = readEnvironmentApi(environmentId); | 2427 | if (!activeThreadId || !activeProject || !activeThread) return; |
| 2750 | if (!api || !activeThreadId || !activeProject || !activeThread) return; | ||
| 2751 | if (options?.rememberAsLastInvoked !== false) { | 2428 | if (options?.rememberAsLastInvoked !== false) { |
| 2752 | setLastInvokedScriptByProjectId((current) => { | 2429 | setLastInvokedScriptByProjectId((current) => { |
| 2753 | if (current[activeProject.id] === script.id) return current; | 2430 | if (current[activeProject.id] === script.id) return current; |
| 2754 | return { ...current, [activeProject.id]: script.id }; | 2431 | return { ...current, [activeProject.id]: script.id }; |
| 2755 | }); | 2432 | }); |
| 2756 | } | 2433 | } |
| 2757 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; | 2434 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; |
| 2758 | const baseTerminalId = | 2435 | const baseTerminalId = |
| 2759 | terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; | 2436 | terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; |
| 2760 | const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); | 2437 | const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); |
| ... | @@ -2775,7 +2452,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2775,7 +2452,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2775 | 2452 | ||
| 2776 | const runtimeEnv = projectScriptRuntimeEnv({ | 2453 | const runtimeEnv = projectScriptRuntimeEnv({ |
| 2777 | project: { | 2454 | project: { |
| 2778 | cwd: activeProject.cwd, | 2455 | cwd: activeProject.workspaceRoot, |
| 2779 | }, | 2456 | }, |
| 2780 | worktreePath: targetWorktreePath, | 2457 | worktreePath: targetWorktreePath, |
| 2781 | ...(options?.env ? { extraEnv: options.env } : {}), | 2458 | ...(options?.env ? { extraEnv: options.env } : {}), |
| ... | @@ -2807,31 +2484,28 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2807,31 +2484,28 @@ function ChatViewContent(props: ChatViewProps) { |
| 2807 | storeSetActiveTerminal(activeThreadRef, targetTerminalId); | 2484 | storeSetActiveTerminal(activeThreadRef, targetTerminalId); |
| 2808 | } | 2485 | } |
| 2809 | 2486 | ||
| 2810 | try { | 2487 | const openResult = await openTerminal({ environmentId, input: openTerminalInput }); |
| 2811 | await api.terminal.open(openTerminalInput); | 2488 | if (openResult._tag === "Failure") { |
| 2812 | await api.terminal.write({ | 2489 | if (!isAtomCommandInterrupted(openResult)) { |
| 2490 | const error = squashAtomCommandFailure(openResult); | ||
| 2491 | setThreadError( | ||
| 2492 | activeThreadId, | ||
| 2493 | error instanceof Error ? error.message : `Failed to run script "${script.name}".`, | ||
| 2494 | ); | ||
| 2495 | } | ||
| 2496 | return; | ||
| 2497 | } | ||
| 2498 | |||
| 2499 | const writeResult = await writeTerminal({ | ||
| 2500 | environmentId, | ||
| 2501 | input: { | ||
| 2813 | threadId: activeThreadId, | 2502 | threadId: activeThreadId, |
| 2814 | terminalId: targetTerminalId, | 2503 | terminalId: targetTerminalId, |
| 2815 | data: `${script.command}\r`, | 2504 | data: `${script.command}\r`, |
| 2816 | }); | 2505 | }, |
| 2817 | if ( | 2506 | }); |
| 2818 | script.autoOpenPreview && | 2507 | if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { |
| 2819 | script.previewUrl && | 2508 | const error = squashAtomCommandFailure(writeResult); |
| 2820 | isPreviewSupportedInRuntime() && | ||
| 2821 | activeThreadRef | ||
| 2822 | ) { | ||
| 2823 | try { | ||
| 2824 | const snapshot = await api.preview.open({ | ||
| 2825 | threadId: activeThreadId, | ||
| 2826 | url: script.previewUrl, | ||
| 2827 | }); | ||
| 2828 | usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); | ||
| 2829 | useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); | ||
| 2830 | } catch { | ||
| 2831 | // Preview open failures are surfaced via the panel itself. | ||
| 2832 | } | ||
| 2833 | } | ||
| 2834 | } catch (error) { | ||
| 2835 | setThreadError( | 2509 | setThreadError( |
| 2836 | activeThreadId, | 2510 | activeThreadId, |
| 2837 | error instanceof Error ? error.message : `Failed to run script "${script.name}".`, | 2511 | error instanceof Error ? error.message : `Failed to run script "${script.name}".`, |
| ... | @@ -2850,9 +2524,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2850,9 +2524,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 2850 | storeSetActiveTerminal, | 2524 | storeSetActiveTerminal, |
| 2851 | setLastInvokedScriptByProjectId, | 2525 | setLastInvokedScriptByProjectId, |
| 2852 | environmentId, | 2526 | environmentId, |
| 2527 | openTerminal, | ||
| 2853 | activeKnownTerminalIds, | 2528 | activeKnownTerminalIds, |
| 2854 | runningTerminalIds, | 2529 | runningTerminalIds, |
| 2855 | terminalUiState.activeTerminalId, | 2530 | terminalUiState.activeTerminalId, |
| 2531 | writeTerminal, | ||
| 2856 | ], | 2532 | ], |
| 2857 | ); | 2533 | ); |
| 2858 | 2534 | ||
| ... | @@ -2860,20 +2536,24 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2860,20 +2536,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 2860 | async (input: { | 2536 | async (input: { |
| 2861 | projectId: ProjectId; | 2537 | projectId: ProjectId; |
| 2862 | projectCwd: string; | 2538 | projectCwd: string; |
| 2863 | previousScripts: ProjectScript[]; | 2539 | previousScripts: ReadonlyArray<ProjectScript>; |
| 2864 | nextScripts: ProjectScript[]; | 2540 | nextScripts: ReadonlyArray<ProjectScript>; |
| 2865 | keybinding?: string | null; | 2541 | keybinding?: string | null; |
| 2866 | keybindingCommand: KeybindingCommand; | 2542 | keybindingCommand: KeybindingCommand; |
| 2867 | }) => { | 2543 | }): Promise<AtomCommandResult<void, unknown>> => { |
| 2868 | const api = readEnvironmentApi(environmentId); | 2544 | const updateResult = mapAtomCommandResult( |
| 2869 | if (!api) return; | 2545 | await updateProject({ |
| 2870 | 2546 | environmentId, | |
| 2871 | await api.orchestration.dispatchCommand({ | 2547 | input: { |
| 2872 | type: "project.meta.update", | 2548 | projectId: input.projectId, |
| 2873 | commandId: newCommandId(), | 2549 | scripts: input.nextScripts, |
| 2874 | projectId: input.projectId, | 2550 | }, |
| 2875 | scripts: input.nextScripts, | 2551 | }), |
| 2876 | }); | 2552 | () => undefined, |
| 2553 | ); | ||
| 2554 | if (updateResult._tag === "Failure") { | ||
| 2555 | return updateResult; | ||
| 2556 | } | ||
| 2877 | 2557 | ||
| 2878 | const keybindingRule = decodeProjectScriptKeybindingRule({ | 2558 | const keybindingRule = decodeProjectScriptKeybindingRule({ |
| 2879 | keybinding: input.keybinding, | 2559 | keybinding: input.keybinding, |
| ... | @@ -2881,18 +2561,23 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2881,18 +2561,23 @@ function ChatViewContent(props: ChatViewProps) { |
| 2881 | }); | 2561 | }); |
| 2882 | 2562 | ||
| 2883 | if (isElectron && keybindingRule) { | 2563 | if (isElectron && keybindingRule) { |
| 2884 | const localApi = readLocalApi(); | 2564 | return mapAtomCommandResult( |
| 2885 | if (!localApi) { | 2565 | await upsertKeybinding({ |
| 2886 | throw new Error("Local API unavailable."); | 2566 | environmentId, |
| 2887 | } | 2567 | input: keybindingRule, |
| 2888 | await localApi.server.upsertKeybinding(keybindingRule); | 2568 | }), |
| 2569 | () => undefined, | ||
| 2570 | ); | ||
| 2889 | } | 2571 | } |
| 2572 | return updateResult; | ||
| 2890 | }, | 2573 | }, |
| 2891 | [environmentId], | 2574 | [environmentId, updateProject, upsertKeybinding], |
| 2892 | ); | 2575 | ); |
| 2893 | const saveProjectScript = useCallback( | 2576 | const saveProjectScript = useCallback( |
| 2894 | async (input: NewProjectScriptInput) => { | 2577 | async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => { |
| 2895 | if (!activeProject) return; | 2578 | if (!activeProject) { |
| 2579 | return AsyncResult.success(undefined); | ||
| 2580 | } | ||
| 2896 | const nextId = nextProjectScriptId( | 2581 | const nextId = nextProjectScriptId( |
| 2897 | input.name, | 2582 | input.name, |
| 2898 | activeProject.scripts.map((script) => script.id), | 2583 | activeProject.scripts.map((script) => script.id), |
| ... | @@ -2903,8 +2588,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2903,8 +2588,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 2903 | command: input.command, | 2588 | command: input.command, |
| 2904 | icon: input.icon, | 2589 | icon: input.icon, |
| 2905 | runOnWorktreeCreate: input.runOnWorktreeCreate, | 2590 | runOnWorktreeCreate: input.runOnWorktreeCreate, |
| 2906 | ...(input.previewUrl ? { previewUrl: input.previewUrl } : {}), | ||
| 2907 | ...(input.autoOpenPreview ? { autoOpenPreview: input.autoOpenPreview } : {}), | ||
| 2908 | }; | 2591 | }; |
| 2909 | const nextScripts = input.runOnWorktreeCreate | 2592 | const nextScripts = input.runOnWorktreeCreate |
| 2910 | ? [ | 2593 | ? [ |
| ... | @@ -2915,9 +2598,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2915,9 +2598,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 2915 | ] | 2598 | ] |
| 2916 | : [...activeProject.scripts, nextScript]; | 2599 | : [...activeProject.scripts, nextScript]; |
| 2917 | 2600 | ||
| 2918 | await persistProjectScripts({ | 2601 | return persistProjectScripts({ |
| 2919 | projectId: activeProject.id, | 2602 | projectId: activeProject.id, |
| 2920 | projectCwd: activeProject.cwd, | 2603 | projectCwd: activeProject.workspaceRoot, |
| 2921 | previousScripts: activeProject.scripts, | 2604 | previousScripts: activeProject.scripts, |
| 2922 | nextScripts, | 2605 | nextScripts, |
| 2923 | keybinding: input.keybinding, | 2606 | keybinding: input.keybinding, |
| ... | @@ -2927,11 +2610,16 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2927,11 +2610,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 2927 | [activeProject, persistProjectScripts], | 2610 | [activeProject, persistProjectScripts], |
| 2928 | ); | 2611 | ); |
| 2929 | const updateProjectScript = useCallback( | 2612 | const updateProjectScript = useCallback( |
| 2930 | async (scriptId: string, input: NewProjectScriptInput) => { | 2613 | async ( |
| 2931 | if (!activeProject) return; | 2614 | scriptId: string, |
| 2615 | input: NewProjectScriptInput, | ||
| 2616 | ): Promise<AtomCommandResult<void, unknown>> => { | ||
| 2617 | if (!activeProject) { | ||
| 2618 | return AsyncResult.success(undefined); | ||
| 2619 | } | ||
| 2932 | const existingScript = activeProject.scripts.find((script) => script.id === scriptId); | 2620 | const existingScript = activeProject.scripts.find((script) => script.id === scriptId); |
| 2933 | if (!existingScript) { | 2621 | if (!existingScript) { |
| 2934 | throw new Error("Script not found."); | 2622 | return AsyncResult.failure(Cause.fail(new Error("Script not found."))); |
| 2935 | } | 2623 | } |
| 2936 | 2624 | ||
| 2937 | const updatedScript: ProjectScript = { | 2625 | const updatedScript: ProjectScript = { |
| ... | @@ -2940,10 +2628,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2940,10 +2628,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 2940 | command: input.command, | 2628 | command: input.command, |
| 2941 | icon: input.icon, | 2629 | icon: input.icon, |
| 2942 | runOnWorktreeCreate: input.runOnWorktreeCreate, | 2630 | runOnWorktreeCreate: input.runOnWorktreeCreate, |
| 2943 | ...(input.previewUrl ? { previewUrl: input.previewUrl } : { previewUrl: undefined }), | ||
| 2944 | ...(input.autoOpenPreview | ||
| 2945 | ? { autoOpenPreview: input.autoOpenPreview } | ||
| 2946 | : { autoOpenPreview: undefined }), | ||
| 2947 | }; | 2631 | }; |
| 2948 | const nextScripts = activeProject.scripts.map((script) => | 2632 | const nextScripts = activeProject.scripts.map((script) => |
| 2949 | script.id === scriptId | 2633 | script.id === scriptId |
| ... | @@ -2953,9 +2637,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2953,9 +2637,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 2953 | : script, | 2637 | : script, |
| 2954 | ); | 2638 | ); |
| 2955 | 2639 | ||
| 2956 | await persistProjectScripts({ | 2640 | return persistProjectScripts({ |
| 2957 | projectId: activeProject.id, | 2641 | projectId: activeProject.id, |
| 2958 | projectCwd: activeProject.cwd, | 2642 | projectCwd: activeProject.workspaceRoot, |
| 2959 | previousScripts: activeProject.scripts, | 2643 | previousScripts: activeProject.scripts, |
| 2960 | nextScripts, | 2644 | nextScripts, |
| 2961 | keybinding: input.keybinding, | 2645 | keybinding: input.keybinding, |
| ... | @@ -2965,26 +2649,29 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2965,26 +2649,29 @@ function ChatViewContent(props: ChatViewProps) { |
| 2965 | [activeProject, persistProjectScripts], | 2649 | [activeProject, persistProjectScripts], |
| 2966 | ); | 2650 | ); |
| 2967 | const deleteProjectScript = useCallback( | 2651 | const deleteProjectScript = useCallback( |
| 2968 | async (scriptId: string) => { | 2652 | async (scriptId: string): Promise<AtomCommandResult<void, unknown>> => { |
| 2969 | if (!activeProject) return; | 2653 | if (!activeProject) { |
| 2654 | return AsyncResult.success(undefined); | ||
| 2655 | } | ||
| 2970 | const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); | 2656 | const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); |
| 2971 | 2657 | ||
| 2972 | const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; | 2658 | const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; |
| 2973 | 2659 | ||
| 2974 | try { | 2660 | const result = await persistProjectScripts({ |
| 2975 | await persistProjectScripts({ | 2661 | projectId: activeProject.id, |
| 2976 | projectId: activeProject.id, | 2662 | projectCwd: activeProject.workspaceRoot, |
| 2977 | projectCwd: activeProject.cwd, | 2663 | previousScripts: activeProject.scripts, |
| 2978 | previousScripts: activeProject.scripts, | 2664 | nextScripts, |
| 2979 | nextScripts, | 2665 | keybinding: null, |
| 2980 | keybinding: null, | 2666 | keybindingCommand: commandForProjectScript(scriptId), |
| 2981 | keybindingCommand: commandForProjectScript(scriptId), | 2667 | }); |
| 2982 | }); | 2668 | if (result._tag === "Success") { |
| 2983 | toastManager.add({ | 2669 | toastManager.add({ |
| 2984 | type: "success", | 2670 | type: "success", |
| 2985 | title: `Deleted action "${deletedName ?? "Unknown"}"`, | 2671 | title: `Deleted action "${deletedName ?? "Unknown"}"`, |
| 2986 | }); | 2672 | }); |
| 2987 | } catch (error) { | 2673 | } else if (!isAtomCommandInterrupted(result)) { |
| 2674 | const error = squashAtomCommandFailure(result); | ||
| 2988 | toastManager.add( | 2675 | toastManager.add( |
| 2989 | stackedThreadToast({ | 2676 | stackedThreadToast({ |
| 2990 | type: "error", | 2677 | type: "error", |
| ... | @@ -2993,6 +2680,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2993,6 +2680,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2993 | }), | 2680 | }), |
| 2994 | ); | 2681 | ); |
| 2995 | } | 2682 | } |
| 2683 | return result; | ||
| 2996 | }, | 2684 | }, |
| 2997 | [activeProject, persistProjectScripts], | 2685 | [activeProject, persistProjectScripts], |
| 2998 | ); | 2686 | ); |
| ... | @@ -3015,52 +2703,226 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3015,52 +2703,226 @@ function ChatViewContent(props: ChatViewProps) { |
| 3015 | setDraftThreadContext, | 2703 | setDraftThreadContext, |
| 3016 | ], | 2704 | ], |
| 3017 | ); | 2705 | ); |
| 3018 | 2706 | ||
| 3019 | const handleInteractionModeChange = useCallback( | 2707 | const handleInteractionModeChange = useCallback( |
| 3020 | (mode: ProviderInteractionMode) => { | 2708 | (mode: ProviderInteractionMode) => { |
| 3021 | if (mode === interactionMode) return; | 2709 | if (mode === interactionMode) return; |
| 3022 | setComposerDraftInteractionMode(composerDraftTarget, mode); | 2710 | setComposerDraftInteractionMode(composerDraftTarget, mode); |
| 3023 | if (isLocalDraftThread) { | 2711 | if (isLocalDraftThread) { |
| 3024 | setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); | 2712 | setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); |
| 3025 | } | 2713 | } |
| 3026 | scheduleComposerFocus(); | 2714 | scheduleComposerFocus(); |
| 2715 | }, | ||
| 2716 | [ | ||
| 2717 | interactionMode, | ||
| 2718 | isLocalDraftThread, | ||
| 2719 | scheduleComposerFocus, | ||
| 2720 | composerDraftTarget, | ||
| 2721 | setComposerDraftInteractionMode, | ||
| 2722 | setDraftThreadContext, | ||
| 2723 | ], | ||
| 2724 | ); | ||
| 2725 | const toggleInteractionMode = useCallback(() => { | ||
| 2726 | handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); | ||
| 2727 | }, [handleInteractionModeChange, interactionMode]); | ||
| 2728 | const dismissPlanSidebarForCurrentTurn = useCallback(() => { | ||
| 2729 | planSidebarDismissedForTurnRef.current = | ||
| 2730 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | ||
| 2731 | }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); | ||
| 2732 | const togglePlanSidebar = useCallback(() => { | ||
| 2733 | if (!activeThreadRef) return; | ||
| 2734 | if (planSidebarOpen) { | ||
| 2735 | dismissPlanSidebarForCurrentTurn(); | ||
| 2736 | } else { | ||
| 2737 | planSidebarDismissedForTurnRef.current = null; | ||
| 2738 | } | ||
| 2739 | useRightPanelStore.getState().toggle(activeThreadRef, "plan"); | ||
| 2740 | }, [activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); | ||
| 2741 | const closePlanSidebar = useCallback(() => { | ||
| 2742 | if (!activeThreadRef) return; | ||
| 2743 | setMaximizedRightPanelThreadKey(null); | ||
| 2744 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 2745 | dismissPlanSidebarForCurrentTurn(); | ||
| 2746 | }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); | ||
| 2747 | const createBrowserSurface = useCallback(() => { | ||
| 2748 | if (!activeThreadRef) return; | ||
| 2749 | const activeTabId = activePreviewState.activeTabId; | ||
| 2750 | if (activeTabId) { | ||
| 2751 | useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); | ||
| 2752 | return; | ||
| 2753 | } | ||
| 2754 | void (async () => { | ||
| 2755 | const result = await openPreview({ | ||
| 2756 | environmentId: activeThreadRef.environmentId, | ||
| 2757 | input: { threadId: activeThreadRef.threadId }, | ||
| 2758 | }); | ||
| 2759 | if (result._tag === "Failure") { | ||
| 2760 | return; | ||
| 2761 | } | ||
| 2762 | applyPreviewServerSnapshot(activeThreadRef, result.value); | ||
| 2763 | useRightPanelStore.getState().openBrowser(activeThreadRef, result.value.tabId); | ||
| 2764 | })(); | ||
| 2765 | }, [activePreviewState.activeTabId, activeThreadRef, openPreview]); | ||
| 2766 | const addDiffSurface = useCallback(() => { | ||
| 2767 | if (!activeThreadRef || !isServerThread || !isGitRepo) return; | ||
| 2768 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | ||
| 2769 | onDiffPanelOpen?.(); | ||
| 2770 | void navigate({ | ||
| 2771 | to: "/$environmentId/$threadId", | ||
| 2772 | params: { environmentId, threadId }, | ||
| 2773 | replace: true, | ||
| 2774 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), | ||
| 2775 | }); | ||
| 2776 | }, [ | ||
| 2777 | activeThreadRef, | ||
| 2778 | environmentId, | ||
| 2779 | isGitRepo, | ||
| 2780 | isServerThread, | ||
| 2781 | navigate, | ||
| 2782 | onDiffPanelOpen, | ||
| 2783 | threadId, | ||
| 2784 | ]); | ||
| 2785 | const addFilesSurface = useCallback(() => { | ||
| 2786 | if (!activeThreadRef || !activeProject) return; | ||
| 2787 | useRightPanelStore.getState().open(activeThreadRef, "files"); | ||
| 2788 | }, [activeProject, activeThreadRef]); | ||
| 2789 | const openFileSurface = useCallback( | ||
| 2790 | (relativePath: string) => { | ||
| 2791 | if (!activeThreadRef || !activeProject) return; | ||
| 2792 | useRightPanelStore.getState().openFile(activeThreadRef, relativePath); | ||
| 2793 | }, | ||
| 2794 | [activeProject, activeThreadRef], | ||
| 2795 | ); | ||
| 2796 | const togglePreviewPanel = useCallback(() => { | ||
| 2797 | if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; | ||
| 2798 | if (previewPanelOpen) { | ||
| 2799 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 2800 | return; | ||
| 2801 | } | ||
| 2802 | if (diffOpen) { | ||
| 2803 | void navigate({ | ||
| 2804 | to: "/$environmentId/$threadId", | ||
| 2805 | params: { environmentId, threadId }, | ||
| 2806 | replace: true, | ||
| 2807 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), | ||
| 2808 | }); | ||
| 2809 | } | ||
| 2810 | createBrowserSurface(); | ||
| 2811 | }, [ | ||
| 2812 | activeThreadRef, | ||
| 2813 | createBrowserSurface, | ||
| 2814 | diffOpen, | ||
| 2815 | environmentId, | ||
| 2816 | navigate, | ||
| 2817 | previewPanelOpen, | ||
| 2818 | threadId, | ||
| 2819 | ]); | ||
| 2820 | const closePreviewPanel = useCallback(() => { | ||
| 2821 | if (activeThreadRef) { | ||
| 2822 | setMaximizedRightPanelThreadKey(null); | ||
| 2823 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 2824 | } | ||
| 2825 | }, [activeThreadRef]); | ||
| 2826 | const addTerminalSurface = useCallback(() => { | ||
| 2827 | if (!activeThreadRef || !activeThreadId || !activeProject) return; | ||
| 2828 | const cwd = gitCwd ?? activeProject.workspaceRoot; | ||
| 2829 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | ||
| 2830 | useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); | ||
| 2831 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2832 | void openTerminal({ | ||
| 2833 | environmentId: activeThreadRef.environmentId, | ||
| 2834 | input: { | ||
| 2835 | threadId: activeThreadId, | ||
| 2836 | terminalId, | ||
| 2837 | cwd, | ||
| 2838 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | ||
| 2839 | env: projectScriptRuntimeEnv({ | ||
| 2840 | project: { cwd: activeProject.workspaceRoot }, | ||
| 2841 | worktreePath: activeThreadWorktreePath, | ||
| 2842 | }), | ||
| 2843 | }, | ||
| 2844 | }); | ||
| 2845 | }, [ | ||
| 2846 | activeKnownTerminalIds, | ||
| 2847 | activeProject, | ||
| 2848 | activeThreadId, | ||
| 2849 | activeThreadRef, | ||
| 2850 | activeThreadWorktreePath, | ||
| 2851 | gitCwd, | ||
| 2852 | openTerminal, | ||
| 2853 | panelTerminalIds, | ||
| 2854 | ]); | ||
| 2855 | const splitPanelTerminal = useCallback( | ||
| 2856 | (direction: "horizontal" | "vertical" = "horizontal") => { | ||
| 2857 | if ( | ||
| 2858 | !activeThreadRef || | ||
| 2859 | !activeThreadId || | ||
| 2860 | !activeProject || | ||
| 2861 | activeRightPanelSurface?.kind !== "terminal" || | ||
| 2862 | activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP | ||
| 2863 | ) { | ||
| 2864 | return; | ||
| 2865 | } | ||
| 2866 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | ||
| 2867 | const cwd = gitCwd ?? activeProject.workspaceRoot; | ||
| 2868 | useRightPanelStore | ||
| 2869 | .getState() | ||
| 2870 | .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); | ||
| 2871 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2872 | void openTerminal({ | ||
| 2873 | environmentId: activeThreadRef.environmentId, | ||
| 2874 | input: { | ||
| 2875 | threadId: activeThreadId, | ||
| 2876 | terminalId, | ||
| 2877 | cwd, | ||
| 2878 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | ||
| 2879 | env: projectScriptRuntimeEnv({ | ||
| 2880 | project: { cwd: activeProject.workspaceRoot }, | ||
| 2881 | worktreePath: activeThreadWorktreePath, | ||
| 2882 | }), | ||
| 2883 | }, | ||
| 2884 | }); | ||
| 2885 | }, | ||
| 2886 | [ | ||
| 2887 | activeKnownTerminalIds, | ||
| 2888 | activeProject, | ||
| 2889 | activeRightPanelSurface, | ||
| 2890 | activeThreadId, | ||
| 2891 | activeThreadRef, | ||
| 2892 | activeThreadWorktreePath, | ||
| 2893 | gitCwd, | ||
| 2894 | openTerminal, | ||
| 2895 | panelTerminalIds, | ||
| 2896 | ], | ||
| 2897 | ); | ||
| 2898 | const splitPanelTerminalVertical = useCallback(() => { | ||
| 2899 | splitPanelTerminal("vertical"); | ||
| 2900 | }, [splitPanelTerminal]); | ||
| 2901 | const activatePanelTerminal = useCallback( | ||
| 2902 | (terminalId: string) => { | ||
| 2903 | if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; | ||
| 2904 | useRightPanelStore | ||
| 2905 | .getState() | ||
| 2906 | .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); | ||
| 2907 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2908 | }, | ||
| 2909 | [activeRightPanelSurface, activeThreadRef], | ||
| 2910 | ); | ||
| 2911 | const closePanelTerminal = useCallback( | ||
| 2912 | (terminalId: string) => { | ||
| 2913 | if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; | ||
| 2914 | void closeTerminalMutation({ | ||
| 2915 | environmentId: activeThreadRef.environmentId, | ||
| 2916 | input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, | ||
| 2917 | }); | ||
| 2918 | storeCloseTerminal(activeThreadRef, terminalId); | ||
| 2919 | useRightPanelStore | ||
| 2920 | .getState() | ||
| 2921 | .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); | ||
| 2922 | setTerminalFocusRequestId((value) => value + 1); | ||
| 3027 | }, | 2923 | }, |
| 3028 | [ | 2924 | [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], |
| 3029 | interactionMode, | ||
| 3030 | isLocalDraftThread, | ||
| 3031 | scheduleComposerFocus, | ||
| 3032 | composerDraftTarget, | ||
| 3033 | setComposerDraftInteractionMode, | ||
| 3034 | setDraftThreadContext, | ||
| 3035 | ], | ||
| 3036 | ); | 2925 | ); |
| 3037 | const toggleInteractionMode = useCallback(() => { | ||
| 3038 | handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); | ||
| 3039 | }, [handleInteractionModeChange, interactionMode]); | ||
| 3040 | const dismissPlanSidebarForCurrentTurn = useCallback(() => { | ||
| 3041 | planSidebarDismissedForTurnRef.current = | ||
| 3042 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | ||
| 3043 | }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); | ||
| 3044 | const togglePlanSidebar = useCallback(() => { | ||
| 3045 | if (!activeThreadRef) return; | ||
| 3046 | if (planSidebarOpen) { | ||
| 3047 | dismissPlanSidebarForCurrentTurn(); | ||
| 3048 | } else { | ||
| 3049 | planSidebarDismissedForTurnRef.current = null; | ||
| 3050 | } | ||
| 3051 | useRightPanelStore.getState().toggle(activeThreadRef, "plan"); | ||
| 3052 | }, [activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); | ||
| 3053 | const closePlanSidebar = useCallback(() => { | ||
| 3054 | if (!activeThreadRef) return; | ||
| 3055 | setMaximizedRightPanelThreadKey(null); | ||
| 3056 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 3057 | dismissPlanSidebarForCurrentTurn(); | ||
| 3058 | }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); | ||
| 3059 | const closePreviewPanel = useCallback(() => { | ||
| 3060 | if (!activeThreadRef) return; | ||
| 3061 | setMaximizedRightPanelThreadKey(null); | ||
| 3062 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 3063 | }, [activeThreadRef]); | ||
| 3064 | const activateRightPanelSurface = useCallback( | 2926 | const activateRightPanelSurface = useCallback( |
| 3065 | (surface: RightPanelSurface) => { | 2927 | (surface: RightPanelSurface) => { |
| 3066 | if (!activeThreadRef) return; | 2928 | if (!activeThreadRef) return; |
| ... | @@ -3071,7 +2933,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3071,7 +2933,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3071 | } | 2933 | } |
| 3072 | useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); | 2934 | useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); |
| 3073 | if (surface.kind === "preview" && surface.resourceId) { | 2935 | if (surface.kind === "preview" && surface.resourceId) { |
| 3074 | usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); | 2936 | setActivePreviewTab(activeThreadRef, surface.resourceId); |
| 3075 | } | 2937 | } |
| 3076 | if (surface.kind === "terminal") { | 2938 | if (surface.kind === "terminal") { |
| 3077 | setTerminalFocusRequestId((value) => value + 1); | 2939 | setTerminalFocusRequestId((value) => value + 1); |
| ... | @@ -3116,12 +2978,12 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3116,12 +2978,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 3116 | } | 2978 | } |
| 3117 | useRightPanelStore.getState().toggleVisibility(activeThreadRef); | 2979 | useRightPanelStore.getState().toggleVisibility(activeThreadRef); |
| 3118 | }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); | 2980 | }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); |
| 3119 | const toggleRightPanelMaximized = () => { | 2981 | const toggleRightPanelMaximized = useCallback(() => { |
| 3120 | if (!rightPanelOpen || shouldUsePlanSidebarSheet) return; | 2982 | if (!canMaximizeRightPanel) return; |
| 3121 | setMaximizedRightPanelThreadKey((threadKey) => | 2983 | setMaximizedRightPanelThreadKey((threadKey) => |
| 3122 | threadKey === routeThreadKey ? null : routeThreadKey, | 2984 | threadKey === routeThreadKey ? null : routeThreadKey, |
| 3123 | ); | 2985 | ); |
| 3124 | }; | 2986 | }, [canMaximizeRightPanel, routeThreadKey]); |
| 3125 | const cleanupRightPanelSurfaces = useCallback( | 2987 | const cleanupRightPanelSurfaces = useCallback( |
| 3126 | (surfaces: readonly RightPanelSurface[]) => { | 2988 | (surfaces: readonly RightPanelSurface[]) => { |
| 3127 | if (!activeThreadRef) return; | 2989 | if (!activeThreadRef) return; |
| ... | @@ -3129,26 +2991,24 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3129,26 +2991,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 3129 | dismissPlanSidebarForCurrentTurn(); | 2991 | dismissPlanSidebarForCurrentTurn(); |
| 3130 | } | 2992 | } |
| 3131 | 2993 | ||
| 3132 | const api = readEnvironmentApi(activeThreadRef.environmentId); | ||
| 3133 | for (const surface of surfaces) { | 2994 | for (const surface of surfaces) { |
| 3134 | if (surface.kind === "preview" && surface.resourceId) { | 2995 | if (surface.kind === "preview" && surface.resourceId) { |
| 3135 | usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId); | 2996 | removePreviewSession(activeThreadRef, surface.resourceId); |
| 3136 | void api?.preview | 2997 | void closePreview({ |
| 3137 | .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) | 2998 | environmentId: activeThreadRef.environmentId, |
| 3138 | .catch(() => undefined); | 2999 | input: { threadId: activeThreadRef.threadId, tabId: surface.resourceId }, |
| 3000 | }); | ||
| 3139 | } | 3001 | } |
| 3140 | if (surface.kind !== "terminal") continue; | 3002 | if (surface.kind === "terminal") { |
| 3141 | for (const terminalId of surface.terminalIds) { | 3003 | for (const terminalId of surface.terminalIds) { |
| 3142 | void api?.terminal | 3004 | storeCloseTerminal(activeThreadRef, terminalId); |
| 3143 | .close({ | 3005 | void closeTerminalMutation({ |
| 3144 | threadId: activeThreadRef.threadId, | 3006 | environmentId: activeThreadRef.environmentId, |
| 3145 | terminalId, | 3007 | input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, |
| 3146 | deleteHistory: true, | 3008 | }); |
| 3147 | }) | 3009 | } |
| 3148 | .catch(() => undefined); | ||
| 3149 | } | 3010 | } |
| 3150 | } | 3011 | } |
| 3151 | |||
| 3152 | if (diffOpen && surfaces.some((surface) => surface.kind === "diff")) { | 3012 | if (diffOpen && surfaces.some((surface) => surface.kind === "diff")) { |
| 3153 | void navigate({ | 3013 | void navigate({ |
| 3154 | to: "/$environmentId/$threadId", | 3014 | to: "/$environmentId/$threadId", |
| ... | @@ -3160,10 +3020,13 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3160,10 +3020,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 3160 | }, | 3020 | }, |
| 3161 | [ | 3021 | [ |
| 3162 | activeThreadRef, | 3022 | activeThreadRef, |
| 3023 | closePreview, | ||
| 3024 | closeTerminalMutation, | ||
| 3163 | diffOpen, | 3025 | diffOpen, |
| 3164 | dismissPlanSidebarForCurrentTurn, | 3026 | dismissPlanSidebarForCurrentTurn, |
| 3165 | environmentId, | 3027 | environmentId, |
| 3166 | navigate, | 3028 | navigate, |
| 3029 | storeCloseTerminal, | ||
| 3167 | threadId, | 3030 | threadId, |
| 3168 | ], | 3031 | ], |
| 3169 | ); | 3032 | ); |
| ... | @@ -3174,7 +3037,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3174,7 +3037,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3174 | activeThreadRef, | 3037 | activeThreadRef, |
| 3175 | ); | 3038 | ); |
| 3176 | if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { | 3039 | if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { |
| 3177 | usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId); | 3040 | setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); |
| 3178 | } | 3041 | } |
| 3179 | }, [activeThreadRef]); | 3042 | }, [activeThreadRef]); |
| 3180 | const closeRightPanelSurface = useCallback( | 3043 | const closeRightPanelSurface = useCallback( |
| ... | @@ -3254,6 +3117,13 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3254,6 +3117,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 3254 | }, | 3117 | }, |
| 3255 | ); | 3118 | ); |
| 3256 | }, []); | 3119 | }, []); |
| 3120 | useEffect( | ||
| 3121 | () => | ||
| 3122 | subscribePreviewAction((action) => { | ||
| 3123 | if (action === "toggle-panel") togglePreviewPanel(); | ||
| 3124 | }), | ||
| 3125 | [togglePreviewPanel], | ||
| 3126 | ); | ||
| 3257 | const persistThreadSettingsForNextTurn = useCallback( | 3127 | const persistThreadSettingsForNextTurn = useCallback( |
| 3258 | async (input: { | 3128 | async (input: { |
| 3259 | threadId: ThreadId; | 3129 | threadId: ThreadId; |
| ... | @@ -3261,15 +3131,12 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3261,15 +3131,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 3261 | modelSelection?: ModelSelection; | 3131 | modelSelection?: ModelSelection; |
| 3262 | runtimeMode: RuntimeMode; | 3132 | runtimeMode: RuntimeMode; |
| 3263 | interactionMode: ProviderInteractionMode; | 3133 | interactionMode: ProviderInteractionMode; |
| 3264 | }) => { | 3134 | }): Promise<AtomCommandResult<void, unknown>> => { |
| 3265 | if (!serverThread) { | 3135 | if (!serverThread) { |
| 3266 | return; | 3136 | return AsyncResult.success(undefined); |
| 3267 | } | ||
| 3268 | const api = readEnvironmentApi(environmentId); | ||
| 3269 | if (!api) { | ||
| 3270 | return; | ||
| 3271 | } | 3137 | } |
| 3272 | 3138 | ||
| 3139 | let result: AtomCommandResult<void, unknown> = AsyncResult.success(undefined); | ||
| 3273 | if ( | 3140 | if ( |
| 3274 | input.modelSelection !== undefined && | 3141 | input.modelSelection !== undefined && |
| 3275 | (input.modelSelection.model !== serverThread.modelSelection.model || | 3142 | (input.modelSelection.model !== serverThread.modelSelection.model || |
| ... | @@ -3277,35 +3144,60 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3277,35 +3144,60 @@ function ChatViewContent(props: ChatViewProps) { |
| 3277 | JSON.stringify(input.modelSelection.options ?? null) !== | 3144 | JSON.stringify(input.modelSelection.options ?? null) !== |
| 3278 | JSON.stringify(serverThread.modelSelection.options ?? null)) | 3145 | JSON.stringify(serverThread.modelSelection.options ?? null)) |
| 3279 | ) { | 3146 | ) { |
| 3280 | await api.orchestration.dispatchCommand({ | 3147 | result = mapAtomCommandResult( |
| 3281 | type: "thread.meta.update", | 3148 | await updateThreadMetadata({ |
| 3282 | commandId: newCommandId(), | 3149 | environmentId, |
| 3283 | threadId: input.threadId, | 3150 | input: { |
| 3284 | modelSelection: input.modelSelection, | 3151 | threadId: input.threadId, |
| 3285 | }); | 3152 | modelSelection: input.modelSelection, |
| 3153 | }, | ||
| 3154 | }), | ||
| 3155 | () => undefined, | ||
| 3156 | ); | ||
| 3157 | if (result._tag === "Failure") { | ||
| 3158 | return result; | ||
| 3159 | } | ||
| 3286 | } | 3160 | } |
| 3287 | 3161 | ||
| 3288 | if (input.runtimeMode !== serverThread.runtimeMode) { | 3162 | if (input.runtimeMode !== serverThread.runtimeMode) { |
| 3289 | await api.orchestration.dispatchCommand({ | 3163 | result = mapAtomCommandResult( |
| 3290 | type: "thread.runtime-mode.set", | 3164 | await setThreadRuntimeMode({ |
| 3291 | commandId: newCommandId(), | 3165 | environmentId, |
| 3292 | threadId: input.threadId, | 3166 | input: { |
| 3293 | runtimeMode: input.runtimeMode, | 3167 | threadId: input.threadId, |
| 3294 | createdAt: input.createdAt, | 3168 | runtimeMode: input.runtimeMode, |
| 3295 | }); | 3169 | createdAt: input.createdAt, |
| 3170 | }, | ||
| 3171 | }), | ||
| 3172 | () => undefined, | ||
| 3173 | ); | ||
| 3174 | if (result._tag === "Failure") { | ||
| 3175 | return result; | ||
| 3176 | } | ||
| 3296 | } | 3177 | } |
| 3297 | 3178 | ||
| 3298 | if (input.interactionMode !== serverThread.interactionMode) { | 3179 | if (input.interactionMode !== serverThread.interactionMode) { |
| 3299 | await api.orchestration.dispatchCommand({ | 3180 | result = mapAtomCommandResult( |
| 3300 | type: "thread.interaction-mode.set", | 3181 | await setThreadInteractionMode({ |
| 3301 | commandId: newCommandId(), | 3182 | environmentId, |
| 3302 | threadId: input.threadId, | 3183 | input: { |
| 3303 | interactionMode: input.interactionMode, | 3184 | threadId: input.threadId, |
| 3304 | createdAt: input.createdAt, | 3185 | interactionMode: input.interactionMode, |
| 3305 | }); | 3186 | createdAt: input.createdAt, |
| 3187 | }, | ||
| 3188 | }), | ||
| 3189 | () => undefined, | ||
| 3190 | ); | ||
| 3306 | } | 3191 | } |
| 3192 | return result; | ||
| 3307 | }, | 3193 | }, |
| 3308 | [environmentId, serverThread], | 3194 | [ |
| 3195 | environmentId, | ||
| 3196 | serverThread, | ||
| 3197 | setThreadInteractionMode, | ||
| 3198 | setThreadRuntimeMode, | ||
| 3199 | updateThreadMetadata, | ||
| 3200 | ], | ||
| 3309 | ); | 3201 | ); |
| 3310 | 3202 | ||
| 3311 | // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. | 3203 | // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. |
| ... | @@ -3342,7 +3234,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3342,7 +3234,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 3342 | } | 3234 | } |
| 3343 | } | 3235 | } |
| 3344 | planSidebarDismissedForTurnRef.current = null; | 3236 | planSidebarDismissedForTurnRef.current = null; |
| 3345 | // eslint-disable-next-line react-hooks/exhaustive-deps -- activeThreadRef is reset transitively | 3237 | // activeThreadRef resets transitively with the active thread. |
| 3238 | // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| 3346 | }, [activeThread?.id]); | 3239 | }, [activeThread?.id]); |
| 3347 | 3240 | ||
| 3348 | // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. | 3241 | // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. |
| ... | @@ -3529,10 +3422,13 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3529,10 +3422,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 3529 | 3422 | ||
| 3530 | useEffect(() => { | 3423 | useEffect(() => { |
| 3531 | const handler = (event: globalThis.KeyboardEvent) => { | 3424 | const handler = (event: globalThis.KeyboardEvent) => { |
| 3532 | if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { | 3425 | if (!activeThreadId || isCommandPaletteOpen()) { |
| 3533 | return; | 3426 | return; |
| 3534 | } | 3427 | } |
| 3535 | const terminalFocusOwner = getTerminalFocusOwner(); | 3428 | const terminalFocusOwner = getTerminalFocusOwner(); |
| 3429 | if (event.defaultPrevented && terminalFocusOwner === null) { | ||
| 3430 | return; | ||
| 3431 | } | ||
| 3536 | const shortcutContext = { | 3432 | const shortcutContext = { |
| 3537 | terminalFocus: terminalFocusOwner !== null, | 3433 | terminalFocus: terminalFocusOwner !== null, |
| 3538 | terminalOpen: Boolean(terminalUiState.terminalOpen), | 3434 | terminalOpen: Boolean(terminalUiState.terminalOpen), |
| ... | @@ -3649,14 +3545,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3649,14 +3545,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 3649 | window.addEventListener("keydown", handler, true); | 3545 | window.addEventListener("keydown", handler, true); |
| 3650 | return () => window.removeEventListener("keydown", handler, true); | 3546 | return () => window.removeEventListener("keydown", handler, true); |
| 3651 | }, [ | 3547 | }, [ |
| 3652 | activeRightPanelSurface, | ||
| 3653 | activeProject, | 3548 | activeProject, |
| 3549 | activeRightPanelSurface, | ||
| 3654 | addTerminalSurface, | 3550 | addTerminalSurface, |
| 3655 | terminalUiState.terminalOpen, | 3551 | terminalUiState.terminalOpen, |
| 3656 | terminalUiState.activeTerminalId, | 3552 | terminalUiState.activeTerminalId, |
| 3657 | activeThreadId, | 3553 | activeThreadId, |
| 3658 | closePanelTerminal, | ||
| 3659 | closeTerminal, | 3554 | closeTerminal, |
| 3555 | closePanelTerminal, | ||
| 3660 | createNewTerminal, | 3556 | createNewTerminal, |
| 3661 | setTerminalOpen, | 3557 | setTerminalOpen, |
| 3662 | runProjectScript, | 3558 | runProjectScript, |
| ... | @@ -3671,9 +3567,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3671,9 +3567,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 3671 | 3567 | ||
| 3672 | const onRevertToTurnCount = useCallback( | 3568 | const onRevertToTurnCount = useCallback( |
| 3673 | async (turnCount: number) => { | 3569 | async (turnCount: number) => { |
| 3674 | const api = readEnvironmentApi(environmentId); | ||
| 3675 | const localApi = readLocalApi(); | 3570 | const localApi = readLocalApi(); |
| 3676 | if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; | 3571 | if (!localApi || !activeThread || isRevertingCheckpoint) return; |
| 3677 | 3572 | ||
| 3678 | if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { | 3573 | if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { |
| 3679 | setThreadError( | 3574 | setThreadError( |
| ... | @@ -3699,18 +3594,18 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3699,18 +3594,18 @@ function ChatViewContent(props: ChatViewProps) { |
| 3699 | 3594 | ||
| 3700 | setIsRevertingCheckpoint(true); | 3595 | setIsRevertingCheckpoint(true); |
| 3701 | setThreadError(activeThread.id, null); | 3596 | setThreadError(activeThread.id, null); |
| 3702 | try { | 3597 | const result = await revertThreadCheckpoint({ |
| 3703 | await api.orchestration.dispatchCommand({ | 3598 | environmentId, |
| 3704 | type: "thread.checkpoint.revert", | 3599 | input: { |
| 3705 | commandId: newCommandId(), | ||
| 3706 | threadId: activeThread.id, | 3600 | threadId: activeThread.id, |
| 3707 | turnCount, | 3601 | turnCount, |
| 3708 | createdAt: new Date().toISOString(), | 3602 | }, |
| 3709 | }); | 3603 | }); |
| 3710 | } catch (err) { | 3604 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 3605 | const error = squashAtomCommandFailure(result); | ||
| 3711 | setThreadError( | 3606 | setThreadError( |
| 3712 | activeThread.id, | 3607 | activeThread.id, |
| 3713 | err instanceof Error ? err.message : "Failed to revert thread state.", | 3608 | error instanceof Error ? error.message : "Failed to revert thread state.", |
| 3714 | ); | 3609 | ); |
| 3715 | } | 3610 | } |
| 3716 | setIsRevertingCheckpoint(false); | 3611 | setIsRevertingCheckpoint(false); |
| ... | @@ -3724,15 +3619,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3724,15 +3619,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 3724 | isRevertingCheckpoint, | 3619 | isRevertingCheckpoint, |
| 3725 | isSendBusy, | 3620 | isSendBusy, |
| 3726 | phase, | 3621 | phase, |
| 3622 | revertThreadCheckpoint, | ||
| 3727 | setThreadError, | 3623 | setThreadError, |
| 3728 | ], | 3624 | ], |
| 3729 | ); | 3625 | ); |
| 3730 | 3626 | ||
| 3731 | const onSend = async (e?: { preventDefault: () => void }) => { | 3627 | const onSend = async (e?: { preventDefault: () => void }) => { |
| 3732 | e?.preventDefault(); | 3628 | e?.preventDefault(); |
| 3733 | const api = readEnvironmentApi(environmentId); | ||
| 3734 | if ( | 3629 | if ( |
| 3735 | !api || | ||
| 3736 | !activeThread || | 3630 | !activeThread || |
| 3737 | isSendBusy || | 3631 | isSendBusy || |
| 3738 | isConnecting || | 3632 | isConnecting || |
| ... | @@ -3896,7 +3790,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3896,7 +3790,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 3896 | role: "user", | 3790 | role: "user", |
| 3897 | text: outgoingMessageText, | 3791 | text: outgoingMessageText, |
| 3898 | ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), | 3792 | ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), |
| 3793 | turnId: null, | ||
| 3899 | createdAt: messageCreatedAt, | 3794 | createdAt: messageCreatedAt, |
| 3795 | updatedAt: messageCreatedAt, | ||
| 3900 | streaming: false, | 3796 | streaming: false, |
| 3901 | }, | 3797 | }, |
| 3902 | ]); | 3798 | ]); |
| ... | @@ -3919,55 +3815,67 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3919,55 +3815,67 @@ function ChatViewContent(props: ChatViewProps) { |
| 3919 | clearComposerDraftContent(composerDraftTarget); | 3815 | clearComposerDraftContent(composerDraftTarget); |
| 3920 | composerRef.current?.resetCursorState(); | 3816 | composerRef.current?.resetCursorState(); |
| 3921 | 3817 | ||
| 3922 | let turnStartSucceeded = false; | 3818 | let firstComposerImageName: string | null = null; |
| 3923 | await (async () => { | 3819 | if (composerImagesSnapshot.length > 0) { |
| 3924 | let firstComposerImageName: string | null = null; | 3820 | const firstComposerImage = composerImagesSnapshot[0]; |
| 3925 | if (composerImagesSnapshot.length > 0) { | 3821 | if (firstComposerImage) { |
| 3926 | const firstComposerImage = composerImagesSnapshot[0]; | 3822 | firstComposerImageName = firstComposerImage.name; |
| 3927 | if (firstComposerImage) { | ||
| 3928 | firstComposerImageName = firstComposerImage.name; | ||
| 3929 | } | ||
| 3930 | } | 3823 | } |
| 3931 | let titleSeed = trimmed; | 3824 | } |
| 3932 | if (!titleSeed) { | 3825 | let titleSeed = trimmed; |
| 3933 | if (firstComposerImageName) { | 3826 | if (!titleSeed) { |
| 3934 | titleSeed = `Image: ${firstComposerImageName}`; | 3827 | if (firstComposerImageName) { |
| 3935 | } else if (composerTerminalContextsSnapshot.length > 0) { | 3828 | titleSeed = `Image: ${firstComposerImageName}`; |
| 3936 | titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); | 3829 | } else if (composerTerminalContextsSnapshot.length > 0) { |
| 3937 | } else if (composerElementContextsSnapshot.length > 0) { | 3830 | titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); |
| 3938 | titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!); | 3831 | } else if (composerElementContextsSnapshot.length > 0) { |
| 3939 | } else { | 3832 | titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!); |
| 3940 | titleSeed = "New thread"; | 3833 | } else { |
| 3941 | } | 3834 | titleSeed = "New thread"; |
| 3942 | } | 3835 | } |
| 3943 | const title = truncate(titleSeed); | 3836 | } |
| 3944 | const threadCreateModelSelection = createModelSelection( | 3837 | const title = truncate(titleSeed); |
| 3945 | ctxSelectedModelSelection.instanceId, | 3838 | const threadCreateModelSelection = createModelSelection( |
| 3946 | ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, | 3839 | ctxSelectedModelSelection.instanceId, |
| 3947 | ctxSelectedModelSelection.options, | 3840 | ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, |
| 3948 | ); | 3841 | ctxSelectedModelSelection.options, |
| 3842 | ); | ||
| 3949 | 3843 | ||
| 3950 | // Auto-title from first message | 3844 | let failure: AtomCommandResult<unknown, unknown> | null = null; |
| 3951 | if (isFirstMessage && isServerThread) { | 3845 | // Auto-title from first message |
| 3952 | await api.orchestration.dispatchCommand({ | 3846 | if (isFirstMessage && isServerThread) { |
| 3953 | type: "thread.meta.update", | 3847 | const titleResult = await updateThreadMetadata({ |
| 3954 | commandId: newCommandId(), | 3848 | environmentId, |
| 3849 | input: { | ||
| 3955 | threadId: threadIdForSend, | 3850 | threadId: threadIdForSend, |
| 3956 | title, | 3851 | title, |
| 3957 | }); | 3852 | }, |
| 3853 | }); | ||
| 3854 | if (titleResult._tag === "Failure") { | ||
| 3855 | failure = titleResult; | ||
| 3958 | } | 3856 | } |
| 3857 | } | ||
| 3959 | 3858 | ||
| 3960 | if (isServerThread) { | 3859 | if (failure === null && isServerThread) { |
| 3961 | await persistThreadSettingsForNextTurn({ | 3860 | const settingsResult = await persistThreadSettingsForNextTurn({ |
| 3962 | threadId: threadIdForSend, | 3861 | threadId: threadIdForSend, |
| 3963 | createdAt: messageCreatedAt, | 3862 | createdAt: messageCreatedAt, |
| 3964 | ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), | 3863 | ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), |
| 3965 | runtimeMode, | 3864 | runtimeMode, |
| 3966 | interactionMode, | 3865 | interactionMode, |
| 3967 | }); | 3866 | }); |
| 3867 | if (settingsResult._tag === "Failure") { | ||
| 3868 | failure = settingsResult; | ||
| 3968 | } | 3869 | } |
| 3870 | } | ||
| 3871 | |||
| 3872 | const turnAttachmentsResult = await settlePromise(() => turnAttachmentsPromise); | ||
| 3873 | if (failure === null && turnAttachmentsResult._tag === "Failure") { | ||
| 3874 | failure = turnAttachmentsResult; | ||
| 3875 | } | ||
| 3969 | 3876 | ||
| 3970 | const turnAttachments = await turnAttachmentsPromise; | 3877 | let turnStartSucceeded = false; |
| 3878 | if (failure === null && turnAttachmentsResult._tag === "Success") { | ||
| 3971 | const bootstrap = | 3879 | const bootstrap = |
| 3972 | isLocalDraftThread || baseBranchForWorktree | 3880 | isLocalDraftThread || baseBranchForWorktree |
| 3973 | ? { | 3881 | ? { |
| ... | @@ -3988,7 +3896,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3988,7 +3896,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3988 | ...(baseBranchForWorktree | 3896 | ...(baseBranchForWorktree |
| 3989 | ? { | 3897 | ? { |
| 3990 | prepareWorktree: { | 3898 | prepareWorktree: { |
| 3991 | projectCwd: activeProject.cwd, | 3899 | projectCwd: activeProject.workspaceRoot, |
| 3992 | baseBranch: baseBranchForWorktree, | 3900 | baseBranch: baseBranchForWorktree, |
| 3993 | branch: buildTemporaryWorktreeBranchName(randomHex), | 3901 | branch: buildTemporaryWorktreeBranchName(randomHex), |
| 3994 | }, | 3902 | }, |
| ... | @@ -3998,27 +3906,33 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3998,27 +3906,33 @@ function ChatViewContent(props: ChatViewProps) { |
| 3998 | } | 3906 | } |
| 3999 | : undefined; | 3907 | : undefined; |
| 4000 | beginLocalDispatch({ preparingWorktree: false }); | 3908 | beginLocalDispatch({ preparingWorktree: false }); |
| 4001 | await api.orchestration.dispatchCommand({ | 3909 | const startResult = await startThreadTurn({ |
| 4002 | type: "thread.turn.start", | 3910 | environmentId, |
| 4003 | commandId: newCommandId(), | 3911 | input: { |
| 4004 | threadId: threadIdForSend, | 3912 | threadId: threadIdForSend, |
| 4005 | message: { | 3913 | message: { |
| 4006 | messageId: messageIdForSend, | 3914 | messageId: messageIdForSend, |
| 4007 | role: "user", | 3915 | role: "user", |
| 4008 | text: outgoingMessageText, | 3916 | text: outgoingMessageText, |
| 4009 | attachments: turnAttachments, | 3917 | attachments: turnAttachmentsResult.value, |
| 3918 | }, | ||
| 3919 | modelSelection: ctxSelectedModelSelection, | ||
| 3920 | titleSeed: title, | ||
| 3921 | runtimeMode, | ||
| 3922 | interactionMode, | ||
| 3923 | ...(bootstrap ? { bootstrap } : {}), | ||
| 3924 | createdAt: messageCreatedAt, | ||
| 4010 | }, | 3925 | }, |
| 4011 | modelSelection: ctxSelectedModelSelection, | ||
| 4012 | titleSeed: title, | ||
| 4013 | runtimeMode, | ||
| 4014 | interactionMode, | ||
| 4015 | ...(bootstrap ? { bootstrap } : {}), | ||
| 4016 | createdAt: messageCreatedAt, | ||
| 4017 | }); | 3926 | }); |
| 4018 | turnStartSucceeded = true; | 3927 | if (startResult._tag === "Failure") { |
| 4019 | })().catch(async (err: unknown) => { | 3928 | failure = startResult; |
| 3929 | } else { | ||
| 3930 | turnStartSucceeded = true; | ||
| 3931 | } | ||
| 3932 | } | ||
| 3933 | |||
| 3934 | if (failure !== null) { | ||
| 4020 | if ( | 3935 | if ( |
| 4021 | !turnStartSucceeded && | ||
| 4022 | promptRef.current.length === 0 && | 3936 | promptRef.current.length === 0 && |
| 4023 | composerImagesRef.current.length === 0 && | 3937 | composerImagesRef.current.length === 0 && |
| 4024 | composerTerminalContextsRef.current.length === 0 && | 3938 | composerTerminalContextsRef.current.length === 0 && |
| ... | @@ -4053,11 +3967,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4053,11 +3967,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 4053 | detectTrigger: true, | 3967 | detectTrigger: true, |
| 4054 | }); | 3968 | }); |
| 4055 | } | 3969 | } |
| 4056 | setThreadError( | 3970 | if (!isAtomCommandInterrupted(failure)) { |
| 4057 | threadIdForSend, | 3971 | const error = squashAtomCommandFailure(failure); |
| 4058 | err instanceof Error ? err.message : "Failed to send message.", | 3972 | setThreadError( |
| 4059 | ); | 3973 | threadIdForSend, |
| 4060 | }); | 3974 | error instanceof Error ? error.message : "Failed to send message.", |
| 3975 | ); | ||
| 3976 | } | ||
| 3977 | } | ||
| 4061 | sendInFlightRef.current = false; | 3978 | sendInFlightRef.current = false; |
| 4062 | if (!turnStartSucceeded) { | 3979 | if (!turnStartSucceeded) { |
| 4063 | resetLocalDispatch(); | 3980 | resetLocalDispatch(); |
| ... | @@ -4065,70 +3982,76 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4065,70 +3982,76 @@ function ChatViewContent(props: ChatViewProps) { |
| 4065 | }; | 3982 | }; |
| 4066 | 3983 | ||
| 4067 | const onInterrupt = async () => { | 3984 | const onInterrupt = async () => { |
| 4068 | const api = readEnvironmentApi(environmentId); | 3985 | if (!activeThread) return; |
| 4069 | if (!api || !activeThread) return; | 3986 | const result = await interruptThreadTurn({ |
| 4070 | await api.orchestration.dispatchCommand({ | 3987 | environmentId, |
| 4071 | type: "thread.turn.interrupt", | 3988 | input: { |
| 4072 | commandId: newCommandId(), | 3989 | threadId: activeThread.id, |
| 4073 | threadId: activeThread.id, | 3990 | }, |
| 4074 | createdAt: new Date().toISOString(), | ||
| 4075 | }); | 3991 | }); |
| 3992 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 3993 | const error = squashAtomCommandFailure(result); | ||
| 3994 | setThreadError( | ||
| 3995 | activeThread.id, | ||
| 3996 | error instanceof Error ? error.message : "Failed to interrupt the current turn.", | ||
| 3997 | ); | ||
| 3998 | } | ||
| 4076 | }; | 3999 | }; |
| 4077 | 4000 | ||
| 4078 | const onRespondToApproval = useCallback( | 4001 | const onRespondToApproval = useCallback( |
| 4079 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { | 4002 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { |
| 4080 | const api = readEnvironmentApi(environmentId); | 4003 | if (!activeThreadId) return; |
| 4081 | if (!api || !activeThreadId) return; | ||
| 4082 | 4004 | ||
| 4083 | setRespondingRequestIds((existing) => | 4005 | setRespondingRequestIds((existing) => |
| 4084 | existing.includes(requestId) ? existing : [...existing, requestId], | 4006 | existing.includes(requestId) ? existing : [...existing, requestId], |
| 4085 | ); | 4007 | ); |
| 4086 | await api.orchestration | 4008 | const result = await respondToThreadApproval({ |
| 4087 | .dispatchCommand({ | 4009 | environmentId, |
| 4088 | type: "thread.approval.respond", | 4010 | input: { |
| 4089 | commandId: newCommandId(), | ||
| 4090 | threadId: activeThreadId, | 4011 | threadId: activeThreadId, |
| 4091 | requestId, | 4012 | requestId, |
| 4092 | decision, | 4013 | decision, |
| 4093 | createdAt: new Date().toISOString(), | 4014 | }, |
| 4094 | }) | 4015 | }); |
| 4095 | .catch((err: unknown) => { | 4016 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 4096 | setThreadError( | 4017 | const error = squashAtomCommandFailure(result); |
| 4097 | activeThreadId, | 4018 | setThreadError( |
| 4098 | err instanceof Error ? err.message : "Failed to submit approval decision.", | 4019 | activeThreadId, |
| 4099 | ); | 4020 | error instanceof Error ? error.message : "Failed to submit approval decision.", |
| 4100 | }); | 4021 | ); |
| 4022 | } | ||
| 4101 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | 4023 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 4024 | return result; | ||
| 4102 | }, | 4025 | }, |
| 4103 | [activeThreadId, environmentId, setThreadError], | 4026 | [activeThreadId, environmentId, respondToThreadApproval, setThreadError], |
| 4104 | ); | 4027 | ); |
| 4105 | 4028 | ||
| 4106 | const onRespondToUserInput = useCallback( | 4029 | const onRespondToUserInput = useCallback( |
| 4107 | async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { | 4030 | async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { |
| 4108 | const api = readEnvironmentApi(environmentId); | 4031 | if (!activeThreadId) return; |
| 4109 | if (!api || !activeThreadId) return; | ||
| 4110 | 4032 | ||
| 4111 | setRespondingUserInputRequestIds((existing) => | 4033 | setRespondingUserInputRequestIds((existing) => |
| 4112 | existing.includes(requestId) ? existing : [...existing, requestId], | 4034 | existing.includes(requestId) ? existing : [...existing, requestId], |
| 4113 | ); | 4035 | ); |
| 4114 | await api.orchestration | 4036 | const result = await respondToThreadUserInput({ |
| 4115 | .dispatchCommand({ | 4037 | environmentId, |
| 4116 | type: "thread.user-input.respond", | 4038 | input: { |
| 4117 | commandId: newCommandId(), | ||
| 4118 | threadId: activeThreadId, | 4039 | threadId: activeThreadId, |
| 4119 | requestId, | 4040 | requestId, |
| 4120 | answers, | 4041 | answers, |
| 4121 | createdAt: new Date().toISOString(), | 4042 | }, |
| 4122 | }) | 4043 | }); |
| 4123 | .catch((err: unknown) => { | 4044 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 4124 | setThreadError( | 4045 | const error = squashAtomCommandFailure(result); |
| 4125 | activeThreadId, | 4046 | setThreadError( |
| 4126 | err instanceof Error ? err.message : "Failed to submit user input.", | 4047 | activeThreadId, |
| 4127 | ); | 4048 | error instanceof Error ? error.message : "Failed to submit user input.", |
| 4128 | }); | 4049 | ); |
| 4050 | } | ||
| 4129 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); | 4051 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 4052 | return result; | ||
| 4130 | }, | 4053 | }, |
| 4131 | [activeThreadId, environmentId, setThreadError], | 4054 | [activeThreadId, environmentId, respondToThreadUserInput, setThreadError], |
| 4132 | ); | 4055 | ); |
| 4133 | 4056 | ||
| 4134 | const setActivePendingUserInputQuestionIndex = useCallback( | 4057 | const setActivePendingUserInputQuestionIndex = useCallback( |
| ... | @@ -4245,9 +4168,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4245,9 +4168,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4245 | text: string; | 4168 | text: string; |
| 4246 | interactionMode: "default" | "plan"; | 4169 | interactionMode: "default" | "plan"; |
| 4247 | }) => { | 4170 | }) => { |
| 4248 | const api = readEnvironmentApi(environmentId); | ||
| 4249 | if ( | 4171 | if ( |
| 4250 | !api || | ||
| 4251 | !activeThread || | 4172 | !activeThread || |
| 4252 | !isServerThread || | 4173 | !isServerThread || |
| 4253 | isSendBusy || | 4174 | isSendBusy || |
| ... | @@ -4301,20 +4222,24 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4301,20 +4222,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 4301 | id: messageIdForSend, | 4222 | id: messageIdForSend, |
| 4302 | role: "user", | 4223 | role: "user", |
| 4303 | text: outgoingMessageText, | 4224 | text: outgoingMessageText, |
| 4225 | turnId: null, | ||
| 4304 | createdAt: messageCreatedAt, | 4226 | createdAt: messageCreatedAt, |
| 4227 | updatedAt: messageCreatedAt, | ||
| 4305 | streaming: false, | 4228 | streaming: false, |
| 4306 | }, | 4229 | }, |
| 4307 | ]); | 4230 | ]); |
| 4308 | 4231 | ||
| 4309 | try { | 4232 | const settingsResult = await persistThreadSettingsForNextTurn({ |
| 4310 | await persistThreadSettingsForNextTurn({ | 4233 | threadId: threadIdForSend, |
| 4311 | threadId: threadIdForSend, | 4234 | createdAt: messageCreatedAt, |
| 4312 | createdAt: messageCreatedAt, | 4235 | modelSelection: ctxSelectedModelSelection, |
| 4313 | modelSelection: ctxSelectedModelSelection, | 4236 | runtimeMode, |
| 4314 | runtimeMode, | 4237 | interactionMode: nextInteractionMode, |
| 4315 | interactionMode: nextInteractionMode, | 4238 | }); |
| 4316 | }); | 4239 | let failure: AtomCommandResult<unknown, unknown> | null = |
| 4240 | settingsResult._tag === "Failure" ? settingsResult : null; | ||
| 4317 | 4241 | ||
| 4242 | if (failure === null) { | ||
| 4318 | // Keep the mode toggle and plan-follow-up banner in sync immediately | 4243 | // Keep the mode toggle and plan-follow-up banner in sync immediately |
| 4319 | // while the same-thread implementation turn is starting. | 4244 | // while the same-thread implementation turn is starting. |
| 4320 | setComposerDraftInteractionMode( | 4245 | setComposerDraftInteractionMode( |
| ... | @@ -4322,30 +4247,35 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4322,30 +4247,35 @@ function ChatViewContent(props: ChatViewProps) { |
| 4322 | nextInteractionMode, | 4247 | nextInteractionMode, |
| 4323 | ); | 4248 | ); |
| 4324 | 4249 | ||
| 4325 | await api.orchestration.dispatchCommand({ | 4250 | const startResult = await startThreadTurn({ |
| 4326 | type: "thread.turn.start", | 4251 | environmentId, |
| 4327 | commandId: newCommandId(), | 4252 | input: { |
| 4328 | threadId: threadIdForSend, | 4253 | threadId: threadIdForSend, |
| 4329 | message: { | 4254 | message: { |
| 4330 | messageId: messageIdForSend, | 4255 | messageId: messageIdForSend, |
| 4331 | role: "user", | 4256 | role: "user", |
| 4332 | text: outgoingMessageText, | 4257 | text: outgoingMessageText, |
| 4333 | attachments: [], | 4258 | attachments: [], |
| 4259 | }, | ||
| 4260 | modelSelection: ctxSelectedModelSelection, | ||
| 4261 | titleSeed: activeThread.title, | ||
| 4262 | runtimeMode, | ||
| 4263 | interactionMode: nextInteractionMode, | ||
| 4264 | ...(nextInteractionMode === "default" && activeProposedPlan | ||
| 4265 | ? { | ||
| 4266 | sourceProposedPlan: { | ||
| 4267 | threadId: activeThread.id, | ||
| 4268 | planId: activeProposedPlan.id, | ||
| 4269 | }, | ||
| 4270 | } | ||
| 4271 | : {}), | ||
| 4272 | createdAt: messageCreatedAt, | ||
| 4334 | }, | 4273 | }, |
| 4335 | modelSelection: ctxSelectedModelSelection, | ||
| 4336 | titleSeed: activeThread.title, | ||
| 4337 | runtimeMode, | ||
| 4338 | interactionMode: nextInteractionMode, | ||
| 4339 | ...(nextInteractionMode === "default" && activeProposedPlan | ||
| 4340 | ? { | ||
| 4341 | sourceProposedPlan: { | ||
| 4342 | threadId: activeThread.id, | ||
| 4343 | planId: activeProposedPlan.id, | ||
| 4344 | }, | ||
| 4345 | } | ||
| 4346 | : {}), | ||
| 4347 | createdAt: messageCreatedAt, | ||
| 4348 | }); | 4274 | }); |
| 4275 | failure = startResult._tag === "Failure" ? startResult : null; | ||
| 4276 | } | ||
| 4277 | |||
| 4278 | if (failure === null) { | ||
| 4349 | // Optimistically open the plan sidebar when implementing (not refining). | 4279 | // Optimistically open the plan sidebar when implementing (not refining). |
| 4350 | // "default" mode here means the agent is executing the plan, which produces | 4280 | // "default" mode here means the agent is executing the plan, which produces |
| 4351 | // step-tracking activities that the sidebar will display. | 4281 | // step-tracking activities that the sidebar will display. |
| ... | @@ -4356,17 +4286,21 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4356,17 +4286,21 @@ function ChatViewContent(props: ChatViewProps) { |
| 4356 | } | 4286 | } |
| 4357 | } | 4287 | } |
| 4358 | sendInFlightRef.current = false; | 4288 | sendInFlightRef.current = false; |
| 4359 | } catch (err) { | 4289 | return; |
| 4360 | setOptimisticUserMessages((existing) => | 4290 | } |
| 4361 | existing.filter((message) => message.id !== messageIdForSend), | 4291 | |
| 4362 | ); | 4292 | setOptimisticUserMessages((existing) => |
| 4293 | existing.filter((message) => message.id !== messageIdForSend), | ||
| 4294 | ); | ||
| 4295 | if (!isAtomCommandInterrupted(failure)) { | ||
| 4296 | const error = squashAtomCommandFailure(failure); | ||
| 4363 | setThreadError( | 4297 | setThreadError( |
| 4364 | threadIdForSend, | 4298 | threadIdForSend, |
| 4365 | err instanceof Error ? err.message : "Failed to send plan follow-up.", | 4299 | error instanceof Error ? error.message : "Failed to send plan follow-up.", |
| 4366 | ); | 4300 | ); |
| 4367 | sendInFlightRef.current = false; | ||
| 4368 | resetLocalDispatch(); | ||
| 4369 | } | 4301 | } |
| 4302 | sendInFlightRef.current = false; | ||
| 4303 | resetLocalDispatch(); | ||
| 4370 | }, | 4304 | }, |
| 4371 | [ | 4305 | [ |
| 4372 | activeThread, | 4306 | activeThread, |
| ... | @@ -4380,6 +4314,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4380,6 +4314,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4380 | runtimeMode, | 4314 | runtimeMode, |
| 4381 | setComposerDraftInteractionMode, | 4315 | setComposerDraftInteractionMode, |
| 4382 | setThreadError, | 4316 | setThreadError, |
| 4317 | startThreadTurn, | ||
| 4383 | autoOpenPlanSidebar, | 4318 | autoOpenPlanSidebar, |
| 4384 | environmentId, | 4319 | environmentId, |
| 4385 | composerRef, | 4320 | composerRef, |
| ... | @@ -4387,9 +4322,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4387,9 +4322,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4387 | ); | 4322 | ); |
| 4388 | 4323 | ||
| 4389 | const onImplementPlanInNewThread = useCallback(async () => { | 4324 | const onImplementPlanInNewThread = useCallback(async () => { |
| 4390 | const api = readEnvironmentApi(environmentId); | ||
| 4391 | if ( | 4325 | if ( |
| 4392 | !api || | ||
| 4393 | !activeThread || | 4326 | !activeThread || |
| 4394 | !activeProject || | 4327 | !activeProject || |
| 4395 | !activeProposedPlan || | 4328 | !activeProposedPlan || |
| ... | @@ -4435,10 +4368,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4435,10 +4368,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 4435 | resetLocalDispatch(); | 4368 | resetLocalDispatch(); |
| 4436 | }; | 4369 | }; |
| 4437 | 4370 | ||
| 4438 | await api.orchestration | 4371 | const createResult = await createThread({ |
| 4439 | .dispatchCommand({ | 4372 | environmentId, |
| 4440 | type: "thread.create", | 4373 | input: { |
| 4441 | commandId: newCommandId(), | ||
| 4442 | threadId: nextThreadId, | 4374 | threadId: nextThreadId, |
| 4443 | projectId: activeProject.id, | 4375 | projectId: activeProject.id, |
| 4444 | title: nextThreadTitle, | 4376 | title: nextThreadTitle, |
| ... | @@ -4448,11 +4380,15 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4448,11 +4380,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 4448 | branch: activeThreadBranch, | 4380 | branch: activeThreadBranch, |
| 4449 | worktreePath: activeThread.worktreePath, | 4381 | worktreePath: activeThread.worktreePath, |
| 4450 | createdAt, | 4382 | createdAt, |
| 4451 | }) | 4383 | }, |
| 4452 | .then(() => { | 4384 | }); |
| 4453 | return api.orchestration.dispatchCommand({ | 4385 | let failure: AtomCommandResult<unknown, unknown> | null = |
| 4454 | type: "thread.turn.start", | 4386 | createResult._tag === "Failure" ? createResult : null; |
| 4455 | commandId: newCommandId(), | 4387 | |
| 4388 | if (failure === null) { | ||
| 4389 | const startResult = await startThreadTurn({ | ||
| 4390 | environmentId, | ||
| 4391 | input: { | ||
| 4456 | threadId: nextThreadId, | 4392 | threadId: nextThreadId, |
| 4457 | message: { | 4393 | message: { |
| 4458 | messageId: newMessageId(), | 4394 | messageId: newMessageId(), |
| ... | @@ -4469,42 +4405,61 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4469,42 +4405,61 @@ function ChatViewContent(props: ChatViewProps) { |
| 4469 | planId: activeProposedPlan.id, | 4405 | planId: activeProposedPlan.id, |
| 4470 | }, | 4406 | }, |
| 4471 | createdAt, | 4407 | createdAt, |
| 4472 | }); | 4408 | }, |
| 4473 | }) | 4409 | }); |
| 4474 | .then(() => { | 4410 | failure = startResult._tag === "Failure" ? startResult : null; |
| 4475 | return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); | 4411 | } |
| 4476 | }) | 4412 | |
| 4477 | .then(() => { | 4413 | if (failure === null) { |
| 4478 | // Signal that the plan sidebar should open on the new thread when enabled. | 4414 | const startedResult = await settlePromise(() => |
| 4479 | planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; | 4415 | waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)), |
| 4480 | return navigate({ | 4416 | ); |
| 4417 | failure = startedResult._tag === "Failure" ? startedResult : null; | ||
| 4418 | } | ||
| 4419 | |||
| 4420 | if (failure === null) { | ||
| 4421 | // Signal that the plan sidebar should open on the new thread when enabled. | ||
| 4422 | planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; | ||
| 4423 | const navigateResult = await settlePromise(() => | ||
| 4424 | navigate({ | ||
| 4481 | to: "/$environmentId/$threadId", | 4425 | to: "/$environmentId/$threadId", |
| 4482 | params: { | 4426 | params: { |
| 4483 | environmentId: activeThread.environmentId, | 4427 | environmentId: activeThread.environmentId, |
| 4484 | threadId: nextThreadId, | 4428 | threadId: nextThreadId, |
| 4485 | }, | 4429 | }, |
| 4486 | }); | 4430 | }), |
| 4487 | }) | 4431 | ); |
| 4488 | .catch(async (err: unknown) => { | 4432 | failure = navigateResult._tag === "Failure" ? navigateResult : null; |
| 4489 | await api.orchestration | 4433 | } |
| 4490 | .dispatchCommand({ | 4434 | |
| 4491 | type: "thread.delete", | 4435 | if (failure !== null) { |
| 4492 | commandId: newCommandId(), | 4436 | const cleanupResult = await deleteThread({ |
| 4493 | threadId: nextThreadId, | 4437 | environmentId, |
| 4494 | }) | 4438 | input: { |
| 4495 | .catch(() => undefined); | 4439 | threadId: nextThreadId, |
| 4440 | }, | ||
| 4441 | }); | ||
| 4442 | if (cleanupResult._tag === "Failure" && !isAtomCommandInterrupted(cleanupResult)) { | ||
| 4443 | console.warn( | ||
| 4444 | "Failed to clean up implementation thread after start failure.", | ||
| 4445 | squashAtomCommandFailure(cleanupResult), | ||
| 4446 | ); | ||
| 4447 | } | ||
| 4448 | if (!isAtomCommandInterrupted(failure)) { | ||
| 4449 | const error = squashAtomCommandFailure(failure); | ||
| 4496 | toastManager.add( | 4450 | toastManager.add( |
| 4497 | stackedThreadToast({ | 4451 | stackedThreadToast({ |
| 4498 | type: "error", | 4452 | type: "error", |
| 4499 | title: "Could not start implementation thread", | 4453 | title: "Could not start implementation thread", |
| 4500 | description: | 4454 | description: |
| 4501 | err instanceof Error | 4455 | error instanceof Error |
| 4502 | ? err.message | 4456 | ? error.message |
| 4503 | : "An error occurred while creating the new thread.", | 4457 | : "An error occurred while creating the new thread.", |
| 4504 | }), | 4458 | }), |
| 4505 | ); | 4459 | ); |
| 4506 | }) | 4460 | } |
| 4507 | .then(finish, finish); | 4461 | } |
| 4462 | finish(); | ||
| 4508 | }, [ | 4463 | }, [ |
| 4509 | activeProject, | 4464 | activeProject, |
| 4510 | activeProposedPlan, | 4465 | activeProposedPlan, |
| ... | @@ -4512,12 +4467,15 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4512,12 +4467,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 4512 | activeThread, | 4467 | activeThread, |
| 4513 | beginLocalDispatch, | 4468 | beginLocalDispatch, |
| 4514 | activeEnvironmentUnavailable, | 4469 | activeEnvironmentUnavailable, |
| 4470 | createThread, | ||
| 4471 | deleteThread, | ||
| 4515 | isConnecting, | 4472 | isConnecting, |
| 4516 | isSendBusy, | 4473 | isSendBusy, |
| 4517 | isServerThread, | 4474 | isServerThread, |
| 4518 | navigate, | 4475 | navigate, |
| 4519 | resetLocalDispatch, | 4476 | resetLocalDispatch, |
| 4520 | runtimeMode, | 4477 | runtimeMode, |
| 4478 | startThreadTurn, | ||
| 4521 | autoOpenPlanSidebar, | 4479 | autoOpenPlanSidebar, |
| 4522 | environmentId, | 4480 | environmentId, |
| 4523 | composerRef, | 4481 | composerRef, |
| ... | @@ -4688,12 +4646,12 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4688,12 +4646,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 4688 | 4646 | ||
| 4689 | const panelToggleControls = ( | 4647 | const panelToggleControls = ( |
| 4690 | <PanelLayoutControls | 4648 | <PanelLayoutControls |
| 4691 | terminalAvailable={Boolean(activeProject)} | 4649 | terminalAvailable={activeProject !== null} |
| 4692 | terminalOpen={Boolean(terminalUiState.terminalOpen)} | 4650 | terminalOpen={terminalUiState.terminalOpen} |
| 4693 | terminalShortcutLabel={terminalToggleShortcutLabel} | 4651 | terminalShortcutLabel={shortcutLabelForCommand(keybindings, "terminal.toggle")} |
| 4694 | rightPanelAvailable={Boolean(activeProject)} | 4652 | rightPanelAvailable={activeProject !== null} |
| 4695 | rightPanelOpen={rightPanelOpen} | 4653 | rightPanelOpen={rightPanelOpen} |
| 4696 | rightPanelShortcutLabel={rightPanelToggleShortcutLabel} | 4654 | rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} |
| 4697 | onToggleTerminal={toggleTerminalVisibility} | 4655 | onToggleTerminal={toggleTerminalVisibility} |
| 4698 | onToggleRightPanel={toggleRightPanel} | 4656 | onToggleRightPanel={toggleRightPanel} |
| 4699 | /> | 4657 | /> |
| ... | @@ -4762,7 +4720,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4762,7 +4720,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4762 | key={`${activeProject.environmentId}:${activeWorkspaceRoot}`} | 4720 | key={`${activeProject.environmentId}:${activeWorkspaceRoot}`} |
| 4763 | environmentId={activeProject.environmentId} | 4721 | environmentId={activeProject.environmentId} |
| 4764 | cwd={activeWorkspaceRoot} | 4722 | cwd={activeWorkspaceRoot} |
| 4765 | projectName={activeProject.name} | 4723 | projectName={activeProject.title} |
| 4766 | threadRef={activeThreadRef} | 4724 | threadRef={activeThreadRef} |
| 4767 | composerDraftTarget={composerDraftTarget} | 4725 | composerDraftTarget={composerDraftTarget} |
| 4768 | keybindings={keybindings} | 4726 | keybindings={keybindings} |
| ... | @@ -4787,7 +4745,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4787,7 +4745,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4787 | {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} | 4745 | {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} |
| 4788 | <div | 4746 | <div |
| 4789 | className={cn( | 4747 | className={cn( |
| 4790 | "flex min-h-0 min-w-0 flex-col overflow-hidden", | 4748 | "flex min-h-0 min-w-0 flex-col overflow-x-hidden", |
| 4791 | rightPanelMaximized ? "w-0 flex-none" : "flex-1", | 4749 | rightPanelMaximized ? "w-0 flex-none" : "flex-1", |
| 4792 | )} | 4750 | )} |
| 4793 | data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} | 4751 | data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} |
| ... | @@ -4813,7 +4771,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4813,7 +4771,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4813 | activeThreadId={activeThread.id} | 4771 | activeThreadId={activeThread.id} |
| 4814 | {...(routeKind === "draft" && draftId ? { draftId } : {})} | 4772 | {...(routeKind === "draft" && draftId ? { draftId } : {})} |
| 4815 | activeThreadTitle={activeThread.title} | 4773 | activeThreadTitle={activeThread.title} |
| 4816 | activeProjectName={activeProject?.name} | 4774 | activeProjectName={activeProject?.title} |
| 4817 | openInCwd={gitCwd} | 4775 | openInCwd={gitCwd} |
| 4818 | activeProjectScripts={activeProject?.scripts} | 4776 | activeProjectScripts={activeProject?.scripts} |
| 4819 | preferredScriptId={ | 4777 | preferredScriptId={ |
| ... | @@ -4821,19 +4779,19 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4821,19 +4779,19 @@ function ChatViewContent(props: ChatViewProps) { |
| 4821 | } | 4779 | } |
| 4822 | keybindings={keybindings} | 4780 | keybindings={keybindings} |
| 4823 | availableEditors={availableEditors} | 4781 | availableEditors={availableEditors} |
| 4782 | rightPanelOpen={rightPanelOpen} | ||
| 4824 | gitCwd={gitCwd} | 4783 | gitCwd={gitCwd} |
| 4825 | onRunProjectScript={runProjectScript} | 4784 | onRunProjectScript={runProjectScript} |
| 4826 | onAddProjectScript={saveProjectScript} | 4785 | onAddProjectScript={saveProjectScript} |
| 4827 | onUpdateProjectScript={updateProjectScript} | 4786 | onUpdateProjectScript={updateProjectScript} |
| 4828 | onDeleteProjectScript={deleteProjectScript} | 4787 | onDeleteProjectScript={deleteProjectScript} |
| 4829 | rightPanelOpen={rightPanelOpen} | ||
| 4830 | /> | 4788 | /> |
| 4831 | </header> | 4789 | </header> |
| 4832 | 4790 | ||
| 4833 | {/* Error banner */} | 4791 | {/* Error banner */} |
| 4834 | <ProviderStatusBanner status={activeProviderStatus} /> | 4792 | <ProviderStatusBanner status={activeProviderStatus} /> |
| 4835 | <ThreadErrorBanner | 4793 | <ThreadErrorBanner |
| 4836 | error={activeThread.error} | 4794 | error={threadError} |
| 4837 | onDismiss={() => setThreadError(activeThread.id, null)} | 4795 | onDismiss={() => setThreadError(activeThread.id, null)} |
| 4838 | /> | 4796 | /> |
| 4839 | {/* Main content area with optional plan sidebar */} | 4797 | {/* Main content area with optional plan sidebar */} |
| ... | @@ -4999,7 +4957,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4999,7 +4957,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4999 | open | 4957 | open |
| 5000 | environmentId={activeThread.environmentId} | 4958 | environmentId={activeThread.environmentId} |
| 5001 | threadId={activeThread.id} | 4959 | threadId={activeThread.id} |
| 5002 | cwd={activeProject?.cwd ?? null} | 4960 | cwd={activeProject?.workspaceRoot ?? null} |
| 5003 | initialReference={pullRequestDialogState.initialReference} | 4961 | initialReference={pullRequestDialogState.initialReference} |
| 5004 | onOpenChange={(open) => { | 4962 | onOpenChange={(open) => { |
| 5005 | if (!open) { | 4963 | if (!open) { |
| ... | @@ -5013,13 +4971,17 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5013,13 +4971,17 @@ function ChatViewContent(props: ChatViewProps) { |
| 5013 | {/* end chat column */} | 4971 | {/* end chat column */} |
| 5014 | </div> | 4972 | </div> |
| 5015 | {/* end horizontal flex container */} | 4973 | {/* end horizontal flex container */} |
| 5016 | {activeThreadRef ? ( | 4974 | |
| 4975 | {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( | ||
| 5017 | <PersistentThreadTerminalDrawer | 4976 | <PersistentThreadTerminalDrawer |
| 5018 | threadRef={activeThreadRef} | 4977 | key={mountedThreadKey} |
| 5019 | threadId={activeThreadRef.threadId} | 4978 | threadRef={mountedThreadRef} |
| 5020 | visible={terminalUiState.terminalOpen} | 4979 | threadId={mountedThreadRef.threadId} |
| 5021 | launchContext={activeTerminalLaunchContext ?? null} | 4980 | visible={mountedThreadKey === activeThreadKey && terminalUiState.terminalOpen} |
| 5022 | focusRequestId={terminalFocusRequestId} | 4981 | launchContext={ |
| 4982 | mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null | ||
| 4983 | } | ||
| 4984 | focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0} | ||
| 5023 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | 4985 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} |
| 5024 | splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} | 4986 | splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} |
| 5025 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | 4987 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} |
| ... | @@ -5027,7 +4989,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5027,7 +4989,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5027 | keybindings={keybindings} | 4989 | keybindings={keybindings} |
| 5028 | onAddTerminalContext={addTerminalContextToDraft} | 4990 | onAddTerminalContext={addTerminalContextToDraft} |
| 5029 | /> | 4991 | /> |
| 5030 | ) : null} | 4992 | ))} |
| 5031 | </div> | 4993 | </div> |
| 5032 | 4994 | ||
| 5033 | {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( | 4995 | {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( |
| ... | @@ -5051,12 +5013,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5051,12 +5013,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 5051 | onAddFiles={addFilesSurface} | 5013 | onAddFiles={addFilesSurface} |
| 5052 | browserAvailable={isPreviewSupportedInRuntime()} | 5014 | browserAvailable={isPreviewSupportedInRuntime()} |
| 5053 | diffAvailable={isServerThread && isGitRepo} | 5015 | diffAvailable={isServerThread && isGitRepo} |
| 5054 | filesAvailable={Boolean(activeProject)} | 5016 | filesAvailable={activeProject !== null} |
| 5055 | > | 5017 | > |
| 5056 | {rightPanelContent} | 5018 | {rightPanelContent} |
| 5057 | </RightPanelTabs> | 5019 | </RightPanelTabs> |
| 5058 | ) : null} | 5020 | ) : null} |
| 5059 | |||
| 5060 | {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( | 5021 | {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( |
| 5061 | <RightPanelSheet open onClose={planSidebarOpen ? closePlanSidebar : closePreviewPanel}> | 5022 | <RightPanelSheet open onClose={planSidebarOpen ? closePlanSidebar : closePreviewPanel}> |
| 5062 | <RightPanelTabs | 5023 | <RightPanelTabs |
| ... | @@ -5079,7 +5040,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5079,7 +5040,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5079 | onAddFiles={addFilesSurface} | 5040 | onAddFiles={addFilesSurface} |
| 5080 | browserAvailable={isPreviewSupportedInRuntime()} | 5041 | browserAvailable={isPreviewSupportedInRuntime()} |
| 5081 | diffAvailable={isServerThread && isGitRepo} | 5042 | diffAvailable={isServerThread && isGitRepo} |
| 5082 | filesAvailable={Boolean(activeProject)} | 5043 | filesAvailable={activeProject !== null} |
| 5083 | > | 5044 | > |
| 5084 | {rightPanelContent} | 5045 | {rightPanelContent} |
| 5085 | </RightPanelTabs> | 5046 | </RightPanelTabs> |
| ... | @@ -5087,7 +5048,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5087,7 +5048,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 5087 | ) : null} | 5048 | ) : null} |
| 5088 | 5049 | ||
| 5089 | {expandedImage && ( | 5050 | {expandedImage && ( |
| 5090 | <ExpandedImageDialog preview={expandedImage} onClose={closeExpandedImage} /> | 5051 | <ExpandedImageDialog |
| 5052 | key={`${expandedImage.images[expandedImage.index]?.src ?? "image"}:${expandedImage.index}`} | ||
| 5053 | preview={expandedImage} | ||
| 5054 | onClose={closeExpandedImage} | ||
| 5055 | /> | ||
| 5091 | )} | 5056 | )} |
| 5092 | </div> | 5057 | </div> |
| 5093 | ); | 5058 | ); |
apps/web/src/components/CommandPalette.logic.test.ts+2-3| ... | @@ -14,7 +14,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -14,7 +14,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 14 | return { | 14 | return { |
| 15 | id: ThreadId.make("thread-1"), | 15 | id: ThreadId.make("thread-1"), |
| 16 | environmentId: LOCAL_ENVIRONMENT_ID, | 16 | environmentId: LOCAL_ENVIRONMENT_ID, |
| 17 | codexThreadId: null, | ||
| 18 | projectId: PROJECT_ID, | 17 | projectId: PROJECT_ID, |
| 19 | title: "Thread", | 18 | title: "Thread", |
| 20 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | 19 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| ... | @@ -23,14 +22,14 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -23,14 +22,14 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 23 | session: null, | 22 | session: null, |
| 24 | messages: [], | 23 | messages: [], |
| 25 | proposedPlans: [], | 24 | proposedPlans: [], |
| 26 | error: null, | ||
| 27 | createdAt: "2026-03-01T00:00:00.000Z", | 25 | createdAt: "2026-03-01T00:00:00.000Z", |
| 28 | archivedAt: null, | 26 | archivedAt: null, |
| 27 | deletedAt: null, | ||
| 29 | updatedAt: "2026-03-01T00:00:00.000Z", | 28 | updatedAt: "2026-03-01T00:00:00.000Z", |
| 30 | latestTurn: null, | 29 | latestTurn: null, |
| 31 | branch: null, | 30 | branch: null, |
| 32 | worktreePath: null, | 31 | worktreePath: null, |
| 33 | turnDiffSummaries: [], | 32 | checkpoints: [], |
| 34 | activities: [], | 33 | activities: [], |
| 35 | ...overrides, | 34 | ...overrides, |
| 36 | }; | 35 | }; |
apps/web/src/components/CommandPalette.logic.ts+4-4| ... | @@ -100,9 +100,9 @@ export function buildProjectActionItems(input: { | ... | @@ -100,9 +100,9 @@ export function buildProjectActionItems(input: { |
| 100 | return input.projects.map((project) => ({ | 100 | return input.projects.map((project) => ({ |
| 101 | kind: "action", | 101 | kind: "action", |
| 102 | value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, | 102 | value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, |
| 103 | searchTerms: [project.name, project.cwd], | 103 | searchTerms: [project.title, project.workspaceRoot], |
| 104 | title: project.name, | 104 | title: project.title, |
| 105 | description: project.cwd, | 105 | description: project.workspaceRoot, |
| 106 | icon: input.icon(project), | 106 | icon: input.icon(project), |
| 107 | ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), | 107 | ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), |
| 108 | run: async () => { | 108 | run: async () => { |
| ... | @@ -115,7 +115,7 @@ export type BuildThreadActionItemsThread = Pick< | ... | @@ -115,7 +115,7 @@ export type BuildThreadActionItemsThread = Pick< |
| 115 | SidebarThreadSummary, | 115 | SidebarThreadSummary, |
| 116 | "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" | 116 | "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" |
| 117 | > & { | 117 | > & { |
| 118 | updatedAt?: string | undefined; | 118 | updatedAt: string; |
| 119 | latestUserMessageAt?: string | null; | 119 | latestUserMessageAt?: string | null; |
| 120 | }; | 120 | }; |
| 121 | 121 |
apps/web/src/components/CommandPalette.tsx+270-267| ... | @@ -1,6 +1,11 @@ | ... | @@ -1,6 +1,11 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 4 | import { | ||
| 5 | isAtomCommandInterrupted, | ||
| 6 | settlePromise, | ||
| 7 | squashAtomCommandFailure, | ||
| 8 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 4 | import { | 9 | import { |
| 5 | DEFAULT_MODEL, | 10 | DEFAULT_MODEL, |
| 6 | type EnvironmentId, | 11 | type EnvironmentId, |
| ... | @@ -11,7 +16,6 @@ import { | ... | @@ -11,7 +16,6 @@ import { |
| 11 | type SourceControlProviderKind, | 16 | type SourceControlProviderKind, |
| 12 | type SourceControlRepositoryInfo, | 17 | type SourceControlRepositoryInfo, |
| 13 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 14 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| 15 | import { useNavigate, useParams } from "@tanstack/react-router"; | 19 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 16 | import * as Option from "effect/Option"; | 20 | import * as Option from "effect/Option"; |
| 17 | import { | 21 | import { |
| ... | @@ -32,26 +36,25 @@ import { | ... | @@ -32,26 +36,25 @@ import { |
| 32 | useEffect, | 36 | useEffect, |
| 33 | useLayoutEffect, | 37 | useLayoutEffect, |
| 34 | useMemo, | 38 | useMemo, |
| 39 | useReducer, | ||
| 35 | useRef, | 40 | useRef, |
| 36 | useState, | 41 | useState, |
| 37 | type KeyboardEvent, | 42 | type KeyboardEvent, |
| 38 | type ReactNode, | 43 | type ReactNode, |
| 39 | } from "react"; | 44 | } from "react"; |
| 40 | import { useShallow } from "zustand/react/shallow"; | 45 | import { useAtomValue } from "@effect/atom-react"; |
| 41 | import { useCommandPaletteStore } from "../commandPaletteStore"; | 46 | import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; |
| 42 | import { readEnvironmentApi } from "../environmentApi"; | ||
| 43 | import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary"; | ||
| 44 | import { | ||
| 45 | useSavedEnvironmentRegistryStore, | ||
| 46 | useSavedEnvironmentRuntimeStore, | ||
| 47 | } from "../environments/runtime"; | ||
| 48 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 47 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 49 | import { useSettings } from "../hooks/useSettings"; | 48 | import { useSettings } from "../hooks/useSettings"; |
| 50 | import { readLocalApi } from "../localApi"; | 49 | import { readLocalApi } from "../localApi"; |
| 51 | import { | 50 | import { filesystemEnvironment } from "../state/filesystem"; |
| 52 | getSourceControlDiscoverySnapshot, | 51 | import { projectEnvironment } from "../state/projects"; |
| 53 | refreshSourceControlDiscovery, | 52 | import { useEnvironmentQuery } from "../state/query"; |
| 54 | } from "../lib/sourceControlDiscoveryState"; | 53 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| 54 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 55 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | ||
| 56 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; | ||
| 57 | import { useProjects, useThreadShells } from "../state/entities"; | ||
| 55 | import { | 58 | import { |
| 56 | startNewThreadInProjectFromContext, | 59 | startNewThreadInProjectFromContext, |
| 57 | startNewThreadFromContext, | 60 | startNewThreadFromContext, |
| ... | @@ -73,12 +76,7 @@ import { | ... | @@ -73,12 +76,7 @@ import { |
| 73 | } from "../lib/projectPaths"; | 76 | } from "../lib/projectPaths"; |
| 74 | import { isTerminalFocused } from "../lib/terminalFocus"; | 77 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 75 | import { getLatestThreadForProject } from "../lib/threadSort"; | 78 | import { getLatestThreadForProject } from "../lib/threadSort"; |
| 76 | import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils"; | 79 | import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; |
| 77 | import { | ||
| 78 | selectProjectsAcrossEnvironments, | ||
| 79 | selectSidebarThreadsAcrossEnvironments, | ||
| 80 | useStore, | ||
| 81 | } from "../store"; | ||
| 82 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 80 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 83 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; | 81 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; |
| 84 | import { | 82 | import { |
| ... | @@ -102,7 +100,7 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; | ... | @@ -102,7 +100,7 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; |
| 102 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; | 100 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; |
| 103 | import { ProjectFavicon } from "./ProjectFavicon"; | 101 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 104 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; | 102 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 105 | import { useServerKeybindings } from "../rpc/serverState"; | 103 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 106 | import { resolveShortcutCommand } from "../keybindings"; | 104 | import { resolveShortcutCommand } from "../keybindings"; |
| 107 | import { | 105 | import { |
| 108 | Command, | 106 | Command, |
| ... | @@ -120,7 +118,6 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand | ... | @@ -120,7 +118,6 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand |
| 120 | import type { ChatComposerHandle } from "./chat/ChatComposer"; | 118 | import type { ChatComposerHandle } from "./chat/ChatComposer"; |
| 121 | 119 | ||
| 122 | const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; | 120 | const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; |
| 123 | const BROWSE_STALE_TIME_MS = 30_000; | ||
| 124 | 121 | ||
| 125 | function getLocalFileManagerName(platform: string): string { | 122 | function getLocalFileManagerName(platform: string): string { |
| 126 | if (isMacPlatform(platform)) { | 123 | if (isMacPlatform(platform)) { |
| ... | @@ -326,11 +323,50 @@ function errorMessage(error: unknown): string { | ... | @@ -326,11 +323,50 @@ function errorMessage(error: unknown): string { |
| 326 | return "An error occurred."; | 323 | return "An error occurred."; |
| 327 | } | 324 | } |
| 328 | 325 | ||
| 326 | interface CommandPaletteOpenIntent { | ||
| 327 | readonly kind: "add-project"; | ||
| 328 | } | ||
| 329 | |||
| 330 | interface CommandPaletteUiState { | ||
| 331 | readonly open: boolean; | ||
| 332 | readonly openIntent: CommandPaletteOpenIntent | null; | ||
| 333 | } | ||
| 334 | |||
| 335 | type CommandPaletteUiAction = | ||
| 336 | | { readonly _tag: "SetOpen"; readonly open: boolean } | ||
| 337 | | { readonly _tag: "Toggle" } | ||
| 338 | | { readonly _tag: "OpenAddProject" } | ||
| 339 | | { readonly _tag: "ClearOpenIntent" }; | ||
| 340 | |||
| 341 | function reduceCommandPaletteUiState( | ||
| 342 | state: CommandPaletteUiState, | ||
| 343 | action: CommandPaletteUiAction, | ||
| 344 | ): CommandPaletteUiState { | ||
| 345 | switch (action._tag) { | ||
| 346 | case "SetOpen": | ||
| 347 | return { | ||
| 348 | open: action.open, | ||
| 349 | openIntent: action.open ? state.openIntent : null, | ||
| 350 | }; | ||
| 351 | case "Toggle": | ||
| 352 | return { open: !state.open, openIntent: null }; | ||
| 353 | case "OpenAddProject": | ||
| 354 | return { open: true, openIntent: { kind: "add-project" } }; | ||
| 355 | case "ClearOpenIntent": | ||
| 356 | return state.openIntent ? { ...state, openIntent: null } : state; | ||
| 357 | } | ||
| 358 | } | ||
| 359 | |||
| 329 | export function CommandPalette({ children }: { children: ReactNode }) { | 360 | export function CommandPalette({ children }: { children: ReactNode }) { |
| 330 | const open = useCommandPaletteStore((store) => store.open); | 361 | const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { |
| 331 | const setOpen = useCommandPaletteStore((store) => store.setOpen); | 362 | open: false, |
| 332 | const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen); | 363 | openIntent: null, |
| 333 | const keybindings = useServerKeybindings(); | 364 | }); |
| 365 | const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); | ||
| 366 | const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); | ||
| 367 | const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); | ||
| 368 | const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); | ||
| 369 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | ||
| 334 | const composerHandleRef = useRef<ChatComposerHandle | null>(null); | 370 | const composerHandleRef = useRef<ChatComposerHandle | null>(null); |
| 335 | const routeTarget = useParams({ | 371 | const routeTarget = useParams({ |
| 336 | strict: false, | 372 | strict: false, |
| ... | @@ -364,49 +400,70 @@ export function CommandPalette({ children }: { children: ReactNode }) { | ... | @@ -364,49 +400,70 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 364 | }, [keybindings, terminalOpen, toggleOpen]); | 400 | }, [keybindings, terminalOpen, toggleOpen]); |
| 365 | 401 | ||
| 366 | return ( | 402 | return ( |
| 367 | <ComposerHandleContext value={composerHandleRef}> | 403 | <OpenAddProjectCommandPaletteProvider openAddProject={openAddProject}> |
| 368 | <CommandDialog open={open} onOpenChange={setOpen}> | 404 | <ComposerHandleContext value={composerHandleRef}> |
| 369 | {children} | 405 | <CommandDialog open={state.open} onOpenChange={setOpen}> |
| 370 | <CommandPaletteDialog /> | 406 | {children} |
| 371 | </CommandDialog> | 407 | <CommandPaletteDialog |
| 372 | </ComposerHandleContext> | 408 | open={state.open} |
| 409 | openIntent={state.openIntent} | ||
| 410 | setOpen={setOpen} | ||
| 411 | clearOpenIntent={clearOpenIntent} | ||
| 412 | /> | ||
| 413 | </CommandDialog> | ||
| 414 | </ComposerHandleContext> | ||
| 415 | </OpenAddProjectCommandPaletteProvider> | ||
| 373 | ); | 416 | ); |
| 374 | } | 417 | } |
| 375 | 418 | ||
| 376 | function CommandPaletteDialog() { | 419 | function CommandPaletteDialog(props: { |
| 377 | const open = useCommandPaletteStore((store) => store.open); | 420 | readonly open: boolean; |
| 378 | const setOpen = useCommandPaletteStore((store) => store.setOpen); | 421 | readonly openIntent: CommandPaletteOpenIntent | null; |
| 379 | 422 | readonly setOpen: (open: boolean) => void; | |
| 380 | useEffect(() => { | 423 | readonly clearOpenIntent: () => void; |
| 381 | return () => { | 424 | }) { |
| 382 | setOpen(false); | 425 | if (!props.open) { |
| 383 | }; | ||
| 384 | }, [setOpen]); | ||
| 385 | |||
| 386 | if (!open) { | ||
| 387 | return null; | 426 | return null; |
| 388 | } | 427 | } |
| 389 | 428 | ||
| 390 | return <OpenCommandPaletteDialog />; | 429 | return ( |
| 430 | <OpenCommandPaletteDialog | ||
| 431 | openIntent={props.openIntent} | ||
| 432 | setOpen={props.setOpen} | ||
| 433 | clearOpenIntent={props.clearOpenIntent} | ||
| 434 | /> | ||
| 435 | ); | ||
| 391 | } | 436 | } |
| 392 | 437 | ||
| 393 | function OpenCommandPaletteDialog() { | 438 | function OpenCommandPaletteDialog(props: { |
| 439 | readonly openIntent: CommandPaletteOpenIntent | null; | ||
| 440 | readonly setOpen: (open: boolean) => void; | ||
| 441 | readonly clearOpenIntent: () => void; | ||
| 442 | }) { | ||
| 394 | const navigate = useNavigate(); | 443 | const navigate = useNavigate(); |
| 395 | const setOpen = useCommandPaletteStore((store) => store.setOpen); | 444 | const { clearOpenIntent, openIntent, setOpen } = props; |
| 396 | const openIntent = useCommandPaletteStore((store) => store.openIntent); | ||
| 397 | const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent); | ||
| 398 | const composerHandleRef = useComposerHandleContext(); | 445 | const composerHandleRef = useComposerHandleContext(); |
| 399 | const [query, setQuery] = useState(""); | 446 | const [query, setQuery] = useState(""); |
| 400 | const deferredQuery = useDeferredValue(query); | 447 | const deferredQuery = useDeferredValue(query); |
| 401 | const isActionsOnly = deferredQuery.startsWith(">"); | 448 | const isActionsOnly = deferredQuery.startsWith(">"); |
| 402 | const queryClient = useQueryClient(); | ||
| 403 | const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); | 449 | const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); |
| 404 | const settings = useSettings(); | 450 | const settings = useSettings(); |
| 451 | const createProject = useAtomCommand(projectEnvironment.create, { | ||
| 452 | reportFailure: false, | ||
| 453 | }); | ||
| 454 | const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { | ||
| 455 | reportFailure: false, | ||
| 456 | }); | ||
| 457 | const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { | ||
| 458 | reportFailure: false, | ||
| 459 | }); | ||
| 460 | const { environments } = useEnvironments(); | ||
| 461 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 405 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = | 462 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = |
| 406 | useHandleNewThread(); | 463 | useHandleNewThread(); |
| 407 | const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); | 464 | const projects = useProjects(); |
| 408 | const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); | 465 | const threads = useThreadShells(); |
| 409 | const keybindings = useServerKeybindings(); | 466 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 410 | const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]); | 467 | const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]); |
| 411 | const currentView = viewStack.at(-1) ?? null; | 468 | const currentView = viewStack.at(-1) ?? null; |
| 412 | const [browseGeneration, setBrowseGeneration] = useState(0); | 469 | const [browseGeneration, setBrowseGeneration] = useState(0); |
| ... | @@ -417,45 +474,21 @@ function OpenCommandPaletteDialog() { | ... | @@ -417,45 +474,21 @@ function OpenCommandPaletteDialog() { |
| 417 | const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null); | 474 | const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null); |
| 418 | const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); | 475 | const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); |
| 419 | const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); | 476 | const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); |
| 420 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 477 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 421 | const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; | ||
| 422 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); | ||
| 423 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); | ||
| 424 | 478 | ||
| 425 | const addProjectEnvironmentOptions = useMemo(() => { | 479 | const addProjectEnvironmentOptions = useMemo(() => { |
| 426 | const options: AddProjectEnvironmentOption[] = []; | 480 | const options = environments.map((environment): AddProjectEnvironmentOption => { |
| 427 | const seenEnvironmentIds = new Set<EnvironmentId>(); | 481 | const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; |
| 428 | 482 | return { | |
| 429 | if (primaryEnvironmentId) { | 483 | environmentId: environment.environmentId, |
| 430 | seenEnvironmentIds.add(primaryEnvironmentId); | ||
| 431 | options.push({ | ||
| 432 | environmentId: primaryEnvironmentId, | ||
| 433 | label: resolveEnvironmentOptionLabel({ | 484 | label: resolveEnvironmentOptionLabel({ |
| 434 | isPrimary: true, | 485 | isPrimary, |
| 435 | environmentId: primaryEnvironmentId, | 486 | environmentId: environment.environmentId, |
| 436 | runtimeLabel: primaryEnvironmentLabel, | 487 | runtimeLabel: environment.label, |
| 437 | }), | 488 | }), |
| 438 | isPrimary: true, | 489 | isPrimary, |
| 439 | }); | 490 | }; |
| 440 | } | 491 | }); |
| 441 | |||
| 442 | for (const record of Object.values(savedEnvironmentRegistry)) { | ||
| 443 | if (seenEnvironmentIds.has(record.environmentId)) { | ||
| 444 | continue; | ||
| 445 | } | ||
| 446 | |||
| 447 | const runtimeState = savedEnvironmentRuntimeById[record.environmentId]; | ||
| 448 | options.push({ | ||
| 449 | environmentId: record.environmentId, | ||
| 450 | label: resolveEnvironmentOptionLabel({ | ||
| 451 | isPrimary: false, | ||
| 452 | environmentId: record.environmentId, | ||
| 453 | runtimeLabel: runtimeState?.descriptor?.label ?? null, | ||
| 454 | savedLabel: record.label, | ||
| 455 | }), | ||
| 456 | isPrimary: false, | ||
| 457 | }); | ||
| 458 | } | ||
| 459 | 492 | ||
| 460 | options.sort((left, right) => { | 493 | options.sort((left, right) => { |
| 461 | if (left.isPrimary !== right.isPrimary) { | 494 | if (left.isPrimary !== right.isPrimary) { |
| ... | @@ -465,26 +498,22 @@ function OpenCommandPaletteDialog() { | ... | @@ -465,26 +498,22 @@ function OpenCommandPaletteDialog() { |
| 465 | }); | 498 | }); |
| 466 | 499 | ||
| 467 | return options; | 500 | return options; |
| 468 | }, [ | 501 | }, [environments]); |
| 469 | primaryEnvironmentId, | ||
| 470 | primaryEnvironmentLabel, | ||
| 471 | savedEnvironmentRegistry, | ||
| 472 | savedEnvironmentRuntimeById, | ||
| 473 | ]); | ||
| 474 | const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; | 502 | const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; |
| 475 | const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; | 503 | const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; |
| 476 | const browseEnvironmentPlatform = useMemo(() => { | 504 | const browseEnvironment = |
| 477 | const os = | 505 | environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; |
| 478 | browseEnvironmentId && primaryEnvironmentId && browseEnvironmentId === primaryEnvironmentId | 506 | const sourceControlDiscovery = useEnvironmentQuery( |
| 479 | ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null) | 507 | browseEnvironmentId === null |
| 480 | : browseEnvironmentId | 508 | ? null |
| 481 | ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ?? | 509 | : sourceControlEnvironment.discovery({ |
| 482 | savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform | 510 | environmentId: browseEnvironmentId, |
| 483 | .os ?? | 511 | input: {}, |
| 484 | null) | 512 | }), |
| 485 | : null; | 513 | ); |
| 486 | return getEnvironmentBrowsePlatform(os); | 514 | const browseEnvironmentPlatform = getEnvironmentBrowsePlatform( |
| 487 | }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); | 515 | browseEnvironment?.serverConfig?.environment.platform.os, |
| 516 | ); | ||
| 488 | const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; | 517 | const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; |
| 489 | const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; | 518 | const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; |
| 490 | const isBrowsing = | 519 | const isBrowsing = |
| ... | @@ -492,27 +521,28 @@ function OpenCommandPaletteDialog() { | ... | @@ -492,27 +521,28 @@ function OpenCommandPaletteDialog() { |
| 492 | const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); | 521 | const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); |
| 493 | const getAddProjectInitialQueryForEnvironment = useCallback( | 522 | const getAddProjectInitialQueryForEnvironment = useCallback( |
| 494 | (environmentId: EnvironmentId | null): string => { | 523 | (environmentId: EnvironmentId | null): string => { |
| 524 | const environment = environments.find( | ||
| 525 | (candidate) => candidate.environmentId === environmentId, | ||
| 526 | ); | ||
| 495 | const environmentSettings = | 527 | const environmentSettings = |
| 496 | environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId | 528 | environment?.serverConfig?.settings ?? |
| 497 | ? settings | 529 | (environmentId === primaryEnvironmentId ? settings : null); |
| 498 | : environmentId | ||
| 499 | ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings | ||
| 500 | : null; | ||
| 501 | const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; | 530 | const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; |
| 502 | if (baseDirectory.length === 0) { | 531 | if (baseDirectory.length === 0) { |
| 503 | return "~/"; | 532 | return "~/"; |
| 504 | } | 533 | } |
| 505 | return ensureBrowseDirectoryPath(baseDirectory); | 534 | return ensureBrowseDirectoryPath(baseDirectory); |
| 506 | }, | 535 | }, |
| 507 | [primaryEnvironmentId, savedEnvironmentRuntimeById, settings], | 536 | [environments, primaryEnvironmentId, settings], |
| 508 | ); | 537 | ); |
| 509 | 538 | ||
| 510 | const projectCwdById = useMemo( | 539 | const projectCwdById = useMemo( |
| 511 | () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.cwd])), | 540 | () => |
| 541 | new Map<ProjectId, string>(projects.map((project) => [project.id, project.workspaceRoot])), | ||
| 512 | [projects], | 542 | [projects], |
| 513 | ); | 543 | ); |
| 514 | const projectTitleById = useMemo( | 544 | const projectTitleById = useMemo( |
| 515 | () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.name])), | 545 | () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])), |
| 516 | [projects], | 546 | [projects], |
| 517 | ); | 547 | ); |
| 518 | 548 | ||
| ... | @@ -532,69 +562,28 @@ function OpenCommandPaletteDialog() { | ... | @@ -532,69 +562,28 @@ function OpenCommandPaletteDialog() { |
| 532 | const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; | 562 | const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; |
| 533 | const browseFilterQuery = | 563 | const browseFilterQuery = |
| 534 | isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; | 564 | isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; |
| 535 | 565 | const browseQuery = useEnvironmentQuery( | |
| 536 | const fetchBrowseResult = useCallback( | 566 | isBrowsing && |
| 537 | async (partialPath: string): Promise<FilesystemBrowseResult | null> => { | ||
| 538 | if (!browseEnvironmentId) return null; | ||
| 539 | const api = readEnvironmentApi(browseEnvironmentId); | ||
| 540 | if (!api) return null; | ||
| 541 | return api.filesystem.browse({ | ||
| 542 | partialPath, | ||
| 543 | ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), | ||
| 544 | }); | ||
| 545 | }, | ||
| 546 | [browseEnvironmentId, currentProjectCwdForBrowse], | ||
| 547 | ); | ||
| 548 | |||
| 549 | const { data: browseResult, isPending: isBrowsePending } = useQuery({ | ||
| 550 | queryKey: [ | ||
| 551 | "filesystemBrowse", | ||
| 552 | browseEnvironmentId, | ||
| 553 | browseDirectoryPath, | ||
| 554 | currentProjectCwdForBrowse, | ||
| 555 | ], | ||
| 556 | queryFn: () => fetchBrowseResult(browseDirectoryPath), | ||
| 557 | staleTime: BROWSE_STALE_TIME_MS, | ||
| 558 | enabled: | ||
| 559 | isBrowsing && | ||
| 560 | browseDirectoryPath.length > 0 && | 567 | browseDirectoryPath.length > 0 && |
| 561 | browseEnvironmentId !== null && | 568 | browseEnvironmentId !== null && |
| 562 | !relativePathNeedsActiveProject, | 569 | !relativePathNeedsActiveProject |
| 563 | }); | 570 | ? filesystemEnvironment.browse({ |
| 571 | environmentId: browseEnvironmentId, | ||
| 572 | input: { | ||
| 573 | partialPath: browseDirectoryPath, | ||
| 574 | ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), | ||
| 575 | }, | ||
| 576 | }) | ||
| 577 | : null, | ||
| 578 | ); | ||
| 579 | const browseResult = browseQuery.data; | ||
| 580 | const isBrowsePending = browseQuery.isPending; | ||
| 564 | const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; | 581 | const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; |
| 565 | const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( | 582 | const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( |
| 566 | () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), | 583 | () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), |
| 567 | [browseEntries, browseFilterQuery, highlightedItemValue], | 584 | [browseEntries, browseFilterQuery, highlightedItemValue], |
| 568 | ); | 585 | ); |
| 569 | 586 | ||
| 570 | const prefetchBrowsePath = useCallback( | ||
| 571 | (partialPath: string) => { | ||
| 572 | void queryClient.prefetchQuery({ | ||
| 573 | queryKey: [ | ||
| 574 | "filesystemBrowse", | ||
| 575 | browseEnvironmentId, | ||
| 576 | partialPath, | ||
| 577 | currentProjectCwdForBrowse, | ||
| 578 | ], | ||
| 579 | queryFn: () => fetchBrowseResult(partialPath), | ||
| 580 | staleTime: BROWSE_STALE_TIME_MS, | ||
| 581 | }); | ||
| 582 | }, | ||
| 583 | [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], | ||
| 584 | ); | ||
| 585 | |||
| 586 | // Prefetch only the parent (for back-navigation). Prefetching the | ||
| 587 | // highlighted child on every arrow-key press triggers a macOS TCC prompt | ||
| 588 | // whenever the highlighted entry is a permission-gated home dir (Music, | ||
| 589 | // Documents, Downloads, Desktop, etc.), so we wait for explicit navigation. | ||
| 590 | useEffect(() => { | ||
| 591 | if (!isBrowsing || filteredBrowseEntries.length === 0) return; | ||
| 592 | |||
| 593 | if (canNavigateUp(query)) { | ||
| 594 | prefetchBrowsePath(getBrowseParentPath(query)!); | ||
| 595 | } | ||
| 596 | }, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]); | ||
| 597 | |||
| 598 | const openProjectFromSearch = useMemo( | 587 | const openProjectFromSearch = useMemo( |
| 599 | () => async (project: (typeof projects)[number]) => { | 588 | () => async (project: (typeof projects)[number]) => { |
| 600 | const latestThread = getLatestThreadForProject( | 589 | const latestThread = getLatestThreadForProject( |
| ... | @@ -633,7 +622,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -633,7 +622,7 @@ function OpenCommandPaletteDialog() { |
| 633 | icon: (project) => ( | 622 | icon: (project) => ( |
| 634 | <ProjectFavicon | 623 | <ProjectFavicon |
| 635 | environmentId={project.environmentId} | 624 | environmentId={project.environmentId} |
| 636 | cwd={project.cwd} | 625 | cwd={project.workspaceRoot} |
| 637 | className={ITEM_ICON_CLASS} | 626 | className={ITEM_ICON_CLASS} |
| 638 | /> | 627 | /> |
| 639 | ), | 628 | ), |
| ... | @@ -651,7 +640,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -651,7 +640,7 @@ function OpenCommandPaletteDialog() { |
| 651 | icon: (project) => ( | 640 | icon: (project) => ( |
| 652 | <ProjectFavicon | 641 | <ProjectFavicon |
| 653 | environmentId={project.environmentId} | 642 | environmentId={project.environmentId} |
| 654 | cwd={project.cwd} | 643 | cwd={project.workspaceRoot} |
| 655 | className={ITEM_ICON_CLASS} | 644 | className={ITEM_ICON_CLASS} |
| 656 | /> | 645 | /> |
| 657 | ), | 646 | ), |
| ... | @@ -659,7 +648,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -659,7 +648,7 @@ function OpenCommandPaletteDialog() { |
| 659 | await startNewThreadInProjectFromContext( | 648 | await startNewThreadInProjectFromContext( |
| 660 | { | 649 | { |
| 661 | activeDraftThread, | 650 | activeDraftThread, |
| 662 | activeThread, | 651 | activeThread: activeThread ?? undefined, |
| 663 | defaultProjectRef, | 652 | defaultProjectRef, |
| 664 | defaultThreadEnvMode: settings.defaultThreadEnvMode, | 653 | defaultThreadEnvMode: settings.defaultThreadEnvMode, |
| 665 | handleNewThread, | 654 | handleNewThread, |
| ... | @@ -867,40 +856,17 @@ function OpenCommandPaletteDialog() { | ... | @@ -867,40 +856,17 @@ function OpenCommandPaletteDialog() { |
| 867 | (environmentId: EnvironmentId): void => { | 856 | (environmentId: EnvironmentId): void => { |
| 868 | setAddProjectEnvironmentId(environmentId); | 857 | setAddProjectEnvironmentId(environmentId); |
| 869 | setAddProjectCloneFlow(null); | 858 | setAddProjectCloneFlow(null); |
| 870 | const target = { environmentId }; | ||
| 871 | const initialDiscovery = getSourceControlDiscoverySnapshot(target).data; | ||
| 872 | pushPaletteView({ | 859 | pushPaletteView({ |
| 873 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | 860 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, |
| 874 | groups: buildAddProjectSourceGroups( | 861 | groups: buildAddProjectSourceGroups( |
| 875 | environmentId, | 862 | environmentId, |
| 876 | buildAddProjectRemoteSourceReadiness(initialDiscovery), | 863 | buildAddProjectRemoteSourceReadiness( |
| 864 | browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null, | ||
| 865 | ), | ||
| 877 | ), | 866 | ), |
| 878 | }); | 867 | }); |
| 879 | |||
| 880 | if (initialDiscovery) { | ||
| 881 | return; | ||
| 882 | } | ||
| 883 | |||
| 884 | void refreshSourceControlDiscovery(target).then((discovery) => { | ||
| 885 | setViewStack((previousViews) => { | ||
| 886 | const currentTopView = previousViews.at(-1); | ||
| 887 | if (currentTopView?.groups[0]?.value !== `sources:${environmentId}`) { | ||
| 888 | return previousViews; | ||
| 889 | } | ||
| 890 | return [ | ||
| 891 | ...previousViews.slice(0, -1), | ||
| 892 | { | ||
| 893 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | ||
| 894 | groups: buildAddProjectSourceGroups( | ||
| 895 | environmentId, | ||
| 896 | buildAddProjectRemoteSourceReadiness(discovery), | ||
| 897 | ), | ||
| 898 | }, | ||
| 899 | ]; | ||
| 900 | }); | ||
| 901 | }); | ||
| 902 | }, | 868 | }, |
| 903 | [buildAddProjectSourceGroups], | 869 | [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data], |
| 904 | ); | 870 | ); |
| 905 | 871 | ||
| 906 | const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( | 872 | const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( |
| ... | @@ -988,7 +954,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -988,7 +954,7 @@ function OpenCommandPaletteDialog() { |
| 988 | run: async () => { | 954 | run: async () => { |
| 989 | await startNewThreadFromContext({ | 955 | await startNewThreadFromContext({ |
| 990 | activeDraftThread, | 956 | activeDraftThread, |
| 991 | activeThread, | 957 | activeThread: activeThread ?? undefined, |
| 992 | defaultProjectRef, | 958 | defaultProjectRef, |
| 993 | defaultThreadEnvMode: settings.defaultThreadEnvMode, | 959 | defaultThreadEnvMode: settings.defaultThreadEnvMode, |
| 994 | handleNewThread, | 960 | handleNewThread, |
| ... | @@ -1049,7 +1015,17 @@ function OpenCommandPaletteDialog() { | ... | @@ -1049,7 +1015,17 @@ function OpenCommandPaletteDialog() { |
| 1049 | }); | 1015 | }); |
| 1050 | 1016 | ||
| 1051 | const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); | 1017 | const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); |
| 1052 | const activeGroups = currentView ? currentView.groups : rootGroups; | 1018 | const sourceSelectionViewValue = |
| 1019 | addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; | ||
| 1020 | const activeGroups = | ||
| 1021 | addProjectEnvironmentId !== null && | ||
| 1022 | currentView !== null && | ||
| 1023 | currentView.groups[0]?.value === sourceSelectionViewValue | ||
| 1024 | ? buildAddProjectSourceGroups( | ||
| 1025 | addProjectEnvironmentId, | ||
| 1026 | buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), | ||
| 1027 | ) | ||
| 1028 | : (currentView?.groups ?? rootGroups); | ||
| 1053 | 1029 | ||
| 1054 | const filteredGroups = filterCommandPaletteGroups({ | 1030 | const filteredGroups = filterCommandPaletteGroups({ |
| 1055 | activeGroups, | 1031 | activeGroups, |
| ... | @@ -1062,8 +1038,6 @@ function OpenCommandPaletteDialog() { | ... | @@ -1062,8 +1038,6 @@ function OpenCommandPaletteDialog() { |
| 1062 | const handleAddProject = useCallback( | 1038 | const handleAddProject = useCallback( |
| 1063 | async (rawCwd: string) => { | 1039 | async (rawCwd: string) => { |
| 1064 | if (!browseEnvironmentId) return; | 1040 | if (!browseEnvironmentId) return; |
| 1065 | const api = readEnvironmentApi(browseEnvironmentId); | ||
| 1066 | if (!api) return; | ||
| 1067 | 1041 | ||
| 1068 | if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { | 1042 | if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { |
| 1069 | toastManager.add( | 1043 | toastManager.add( |
| ... | @@ -1108,19 +1082,31 @@ function OpenCommandPaletteDialog() { | ... | @@ -1108,19 +1082,31 @@ function OpenCommandPaletteDialog() { |
| 1108 | ), | 1082 | ), |
| 1109 | }); | 1083 | }); |
| 1110 | } else { | 1084 | } else { |
| 1111 | await handleNewThread(scopeProjectRef(existing.environmentId, existing.id), { | 1085 | const navigationResult = await settlePromise(() => |
| 1112 | envMode: settings.defaultThreadEnvMode, | 1086 | handleNewThread(scopeProjectRef(existing.environmentId, existing.id), { |
| 1113 | }).catch(() => undefined); | 1087 | envMode: settings.defaultThreadEnvMode, |
| 1088 | }), | ||
| 1089 | ); | ||
| 1090 | if (navigationResult._tag === "Failure") { | ||
| 1091 | const error = squashAtomCommandFailure(navigationResult); | ||
| 1092 | toastManager.add( | ||
| 1093 | stackedThreadToast({ | ||
| 1094 | type: "error", | ||
| 1095 | title: "Failed to open project", | ||
| 1096 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1097 | }), | ||
| 1098 | ); | ||
| 1099 | return; | ||
| 1100 | } | ||
| 1114 | } | 1101 | } |
| 1115 | setOpen(false); | 1102 | setOpen(false); |
| 1116 | return; | 1103 | return; |
| 1117 | } | 1104 | } |
| 1118 | 1105 | ||
| 1119 | try { | 1106 | const projectId = newProjectId(); |
| 1120 | const projectId = newProjectId(); | 1107 | const createResult = await createProject({ |
| 1121 | await api.orchestration.dispatchCommand({ | 1108 | environmentId: browseEnvironmentId, |
| 1122 | type: "project.create", | 1109 | input: { |
| 1123 | commandId: newCommandId(), | ||
| 1124 | projectId, | 1110 | projectId, |
| 1125 | title: inferProjectTitleFromPath(cwd), | 1111 | title: inferProjectTitleFromPath(cwd), |
| 1126 | workspaceRoot: cwd, | 1112 | workspaceRoot: cwd, |
| ... | @@ -1129,13 +1115,29 @@ function OpenCommandPaletteDialog() { | ... | @@ -1129,13 +1115,29 @@ function OpenCommandPaletteDialog() { |
| 1129 | instanceId: ProviderInstanceId.make("codex"), | 1115 | instanceId: ProviderInstanceId.make("codex"), |
| 1130 | model: DEFAULT_MODEL, | 1116 | model: DEFAULT_MODEL, |
| 1131 | }, | 1117 | }, |
| 1132 | createdAt: new Date().toISOString(), | 1118 | }, |
| 1133 | }); | 1119 | }); |
| 1134 | await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { | 1120 | if (createResult._tag === "Failure") { |
| 1121 | if (!isAtomCommandInterrupted(createResult)) { | ||
| 1122 | const error = squashAtomCommandFailure(createResult); | ||
| 1123 | toastManager.add( | ||
| 1124 | stackedThreadToast({ | ||
| 1125 | type: "error", | ||
| 1126 | title: "Failed to add project", | ||
| 1127 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1128 | }), | ||
| 1129 | ); | ||
| 1130 | } | ||
| 1131 | return; | ||
| 1132 | } | ||
| 1133 | |||
| 1134 | const navigationResult = await settlePromise(() => | ||
| 1135 | handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { | ||
| 1135 | envMode: settings.defaultThreadEnvMode, | 1136 | envMode: settings.defaultThreadEnvMode, |
| 1136 | }).catch(() => undefined); | 1137 | }), |
| 1137 | setOpen(false); | 1138 | ); |
| 1138 | } catch (error) { | 1139 | if (navigationResult._tag === "Failure") { |
| 1140 | const error = squashAtomCommandFailure(navigationResult); | ||
| 1139 | toastManager.add( | 1141 | toastManager.add( |
| 1140 | stackedThreadToast({ | 1142 | stackedThreadToast({ |
| 1141 | type: "error", | 1143 | type: "error", |
| ... | @@ -1143,13 +1145,16 @@ function OpenCommandPaletteDialog() { | ... | @@ -1143,13 +1145,16 @@ function OpenCommandPaletteDialog() { |
| 1143 | description: error instanceof Error ? error.message : "An error occurred.", | 1145 | description: error instanceof Error ? error.message : "An error occurred.", |
| 1144 | }), | 1146 | }), |
| 1145 | ); | 1147 | ); |
| 1148 | return; | ||
| 1146 | } | 1149 | } |
| 1150 | setOpen(false); | ||
| 1147 | }, | 1151 | }, |
| 1148 | [ | 1152 | [ |
| 1149 | browseEnvironmentId, | 1153 | browseEnvironmentId, |
| 1150 | browseEnvironmentPlatform, | 1154 | browseEnvironmentPlatform, |
| 1151 | currentProjectCwdForBrowse, | 1155 | currentProjectCwdForBrowse, |
| 1152 | handleNewThread, | 1156 | handleNewThread, |
| 1157 | createProject, | ||
| 1153 | navigate, | 1158 | navigate, |
| 1154 | projects, | 1159 | projects, |
| 1155 | setOpen, | 1160 | setOpen, |
| ... | @@ -1168,18 +1173,6 @@ function OpenCommandPaletteDialog() { | ... | @@ -1168,18 +1173,6 @@ function OpenCommandPaletteDialog() { |
| 1168 | return; | 1173 | return; |
| 1169 | } | 1174 | } |
| 1170 | 1175 | ||
| 1171 | const api = readEnvironmentApi(addProjectCloneFlow.environmentId); | ||
| 1172 | if (!api) { | ||
| 1173 | toastManager.add( | ||
| 1174 | stackedThreadToast({ | ||
| 1175 | type: "error", | ||
| 1176 | title: "Unable to clone project", | ||
| 1177 | description: "Environment API is not available.", | ||
| 1178 | }), | ||
| 1179 | ); | ||
| 1180 | return; | ||
| 1181 | } | ||
| 1182 | |||
| 1183 | if (addProjectCloneFlow.step === "repository") { | 1176 | if (addProjectCloneFlow.step === "repository") { |
| 1184 | const rawRepository = query.trim(); | 1177 | const rawRepository = query.trim(); |
| 1185 | if (rawRepository.length === 0 || isRemoteProjectLookingUp) { | 1178 | if (rawRepository.length === 0 || isRemoteProjectLookingUp) { |
| ... | @@ -1204,34 +1197,39 @@ function OpenCommandPaletteDialog() { | ... | @@ -1204,34 +1197,39 @@ function OpenCommandPaletteDialog() { |
| 1204 | } | 1197 | } |
| 1205 | 1198 | ||
| 1206 | setIsRemoteProjectLookingUp(true); | 1199 | setIsRemoteProjectLookingUp(true); |
| 1207 | try { | 1200 | const lookupResult = await lookupRepository({ |
| 1208 | const repository = await api.sourceControl.lookupRepository({ | 1201 | environmentId: addProjectCloneFlow.environmentId, |
| 1202 | input: { | ||
| 1209 | provider, | 1203 | provider, |
| 1210 | repository: rawRepository, | 1204 | repository: rawRepository, |
| 1211 | }); | 1205 | }, |
| 1212 | const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); | 1206 | }); |
| 1213 | setAddProjectCloneFlow({ | 1207 | setIsRemoteProjectLookingUp(false); |
| 1214 | step: "confirm", | 1208 | if (lookupResult._tag === "Failure") { |
| 1215 | environmentId: addProjectCloneFlow.environmentId, | 1209 | if (!isAtomCommandInterrupted(lookupResult)) { |
| 1216 | source: addProjectCloneFlow.source, | 1210 | toastManager.add( |
| 1217 | repositoryInput: rawRepository, | 1211 | stackedThreadToast({ |
| 1218 | repository, | 1212 | type: "error", |
| 1219 | remoteUrl: repository.sshUrl, | 1213 | title: "Repository lookup failed", |
| 1220 | }); | 1214 | description: errorMessage(squashAtomCommandFailure(lookupResult)), |
| 1221 | setHighlightedItemValue(null); | 1215 | }), |
| 1222 | setQuery(destinationPath); | 1216 | ); |
| 1223 | setBrowseGeneration((generation) => generation + 1); | 1217 | } |
| 1224 | } catch (error) { | 1218 | return; |
| 1225 | toastManager.add( | ||
| 1226 | stackedThreadToast({ | ||
| 1227 | type: "error", | ||
| 1228 | title: "Repository lookup failed", | ||
| 1229 | description: errorMessage(error), | ||
| 1230 | }), | ||
| 1231 | ); | ||
| 1232 | } finally { | ||
| 1233 | setIsRemoteProjectLookingUp(false); | ||
| 1234 | } | 1219 | } |
| 1220 | const repository = lookupResult.value; | ||
| 1221 | const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); | ||
| 1222 | setAddProjectCloneFlow({ | ||
| 1223 | step: "confirm", | ||
| 1224 | environmentId: addProjectCloneFlow.environmentId, | ||
| 1225 | source: addProjectCloneFlow.source, | ||
| 1226 | repositoryInput: rawRepository, | ||
| 1227 | repository, | ||
| 1228 | remoteUrl: repository.sshUrl, | ||
| 1229 | }); | ||
| 1230 | setHighlightedItemValue(null); | ||
| 1231 | setQuery(destinationPath); | ||
| 1232 | setBrowseGeneration((generation) => generation + 1); | ||
| 1235 | return; | 1233 | return; |
| 1236 | } | 1234 | } |
| 1237 | 1235 | ||
| ... | @@ -1271,23 +1269,27 @@ function OpenCommandPaletteDialog() { | ... | @@ -1271,23 +1269,27 @@ function OpenCommandPaletteDialog() { |
| 1271 | } | 1269 | } |
| 1272 | 1270 | ||
| 1273 | setIsRemoteProjectCloning(true); | 1271 | setIsRemoteProjectCloning(true); |
| 1274 | try { | 1272 | const cloneResult = await cloneRepository({ |
| 1275 | const result = await api.sourceControl.cloneRepository({ | 1273 | environmentId: addProjectCloneFlow.environmentId, |
| 1274 | input: { | ||
| 1276 | remoteUrl: addProjectCloneFlow.remoteUrl, | 1275 | remoteUrl: addProjectCloneFlow.remoteUrl, |
| 1277 | destinationPath, | 1276 | destinationPath, |
| 1278 | }); | 1277 | }, |
| 1279 | await handleAddProject(result.cwd); | 1278 | }); |
| 1280 | } catch (error) { | 1279 | setIsRemoteProjectCloning(false); |
| 1281 | toastManager.add( | 1280 | if (cloneResult._tag === "Failure") { |
| 1282 | stackedThreadToast({ | 1281 | if (!isAtomCommandInterrupted(cloneResult)) { |
| 1283 | type: "error", | 1282 | toastManager.add( |
| 1284 | title: "Clone failed", | 1283 | stackedThreadToast({ |
| 1285 | description: errorMessage(error), | 1284 | type: "error", |
| 1286 | }), | 1285 | title: "Clone failed", |
| 1287 | ); | 1286 | description: errorMessage(squashAtomCommandFailure(cloneResult)), |
| 1288 | } finally { | 1287 | }), |
| 1289 | setIsRemoteProjectCloning(false); | 1288 | ); |
| 1289 | } | ||
| 1290 | return; | ||
| 1290 | } | 1291 | } |
| 1292 | await handleAddProject(cloneResult.value.cwd); | ||
| 1291 | } | 1293 | } |
| 1292 | 1294 | ||
| 1293 | function browseTo(name: string): void { | 1295 | function browseTo(name: string): void { |
| ... | @@ -1515,6 +1517,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -1515,6 +1517,7 @@ function OpenCommandPaletteDialog() { |
| 1515 | <CommandDialogPopup | 1517 | <CommandDialogPopup |
| 1516 | aria-label="Command palette" | 1518 | aria-label="Command palette" |
| 1517 | className="overflow-hidden p-0" | 1519 | className="overflow-hidden p-0" |
| 1520 | data-command-palette="true" | ||
| 1518 | data-testid="command-palette" | 1521 | data-testid="command-palette" |
| 1519 | finalFocus={() => { | 1522 | finalFocus={() => { |
| 1520 | composerHandleRef?.current?.focusAtEnd(); | 1523 | composerHandleRef?.current?.focusAtEnd(); |
apps/web/src/components/DiffPanel.tsx+71-51| ... | @@ -1,6 +1,11 @@ | ... | @@ -1,6 +1,11 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 1 | import { Virtualizer } from "@pierre/diffs/react"; | 2 | import { Virtualizer } from "@pierre/diffs/react"; |
| 2 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | 3 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 3 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 4 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 5 | import { | ||
| 6 | isAtomCommandInterrupted, | ||
| 7 | squashAtomCommandFailure, | ||
| 8 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 4 | import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; | 9 | import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; |
| 5 | import { | 10 | import { |
| 6 | ChevronDownIcon, | 11 | ChevronDownIcon, |
| ... | @@ -19,13 +24,11 @@ import { | ... | @@ -19,13 +24,11 @@ import { |
| 19 | useRef, | 24 | useRef, |
| 20 | useState, | 25 | useState, |
| 21 | } from "react"; | 26 | } from "react"; |
| 22 | import { openInPreferredEditor } from "../editorPreferences"; | 27 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 23 | import { type DraftId } from "../composerDraftStore"; | 28 | import { type DraftId } from "../composerDraftStore"; |
| 29 | import { openDiffFilePrimaryAction } from "../diffFileActions"; | ||
| 24 | import { useCheckpointDiff } from "~/lib/checkpointDiffState"; | 30 | import { useCheckpointDiff } from "~/lib/checkpointDiffState"; |
| 25 | import { useVcsStatus } from "~/lib/vcsStatusState"; | ||
| 26 | import { cn } from "~/lib/utils"; | 31 | import { cn } from "~/lib/utils"; |
| 27 | import { openDiffFilePrimaryAction } from "../diffFileActions"; | ||
| 28 | import { readLocalApi } from "../localApi"; | ||
| 29 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 32 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 30 | import { useTheme } from "../hooks/useTheme"; | 33 | import { useTheme } from "../hooks/useTheme"; |
| 31 | import { | 34 | import { |
| ... | @@ -36,8 +39,7 @@ import { | ... | @@ -36,8 +39,7 @@ import { |
| 36 | resolveFileDiffPath, | 39 | resolveFileDiffPath, |
| 37 | } from "../lib/diffRendering"; | 40 | } from "../lib/diffRendering"; |
| 38 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 41 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 39 | import { selectProjectByRef, useStore } from "../store"; | 42 | import { useProject, useThread } from "../state/entities"; |
| 40 | import { createThreadSelectorByRef } from "../storeSelectors"; | ||
| 41 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | 43 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 42 | import { useSettings } from "../hooks/useSettings"; | 44 | import { useSettings } from "../hooks/useSettings"; |
| 43 | import { formatShortTimestamp } from "../timestampFormat"; | 45 | import { formatShortTimestamp } from "../timestampFormat"; |
| ... | @@ -45,10 +47,20 @@ import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./Dif | ... | @@ -45,10 +47,20 @@ import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./Dif |
| 45 | import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff"; | 47 | import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff"; |
| 46 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; | 48 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 47 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 49 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 50 | import { useEnvironmentQuery } from "../state/query"; | ||
| 51 | import { serverEnvironment } from "../state/server"; | ||
| 52 | import { vcsEnvironment } from "../state/vcs"; | ||
| 48 | 53 | ||
| 49 | type DiffRenderMode = "stacked" | "split"; | 54 | type DiffRenderMode = "stacked" | "split"; |
| 50 | type DiffThemeType = "light" | "dark"; | 55 | type DiffThemeType = "light" | "dark"; |
| 51 | 56 | ||
| 57 | interface CollapsedDiffFilesState { | ||
| 58 | readonly scopeKey: string | null; | ||
| 59 | readonly fileKeys: ReadonlySet<string>; | ||
| 60 | } | ||
| 61 | |||
| 62 | const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set(); | ||
| 63 | |||
| 52 | const DIFF_PANEL_UNSAFE_CSS = ` | 64 | const DIFF_PANEL_UNSAFE_CSS = ` |
| 53 | [data-diffs-header], | 65 | [data-diffs-header], |
| 54 | [data-diff], | 66 | [data-diff], |
| ... | @@ -161,9 +173,10 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff | ... | @@ -161,9 +173,10 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 161 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); | 173 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 162 | const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); | 174 | const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); |
| 163 | const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); | 175 | const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); |
| 164 | const [collapsedDiffFileKeys, setCollapsedDiffFileKeys] = useState<ReadonlySet<string>>( | 176 | const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({ |
| 165 | () => new Set(), | 177 | scopeKey: null, |
| 166 | ); | 178 | fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, |
| 179 | })); | ||
| 167 | const patchViewportRef = useRef<HTMLDivElement>(null); | 180 | const patchViewportRef = useRef<HTMLDivElement>(null); |
| 168 | const turnStripRef = useRef<HTMLDivElement>(null); | 181 | const turnStripRef = useRef<HTMLDivElement>(null); |
| 169 | const previousDiffOpenRef = useRef(false); | 182 | const previousDiffOpenRef = useRef(false); |
| ... | @@ -176,23 +189,32 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff | ... | @@ -176,23 +189,32 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 176 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); | 189 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); |
| 177 | const diffOpen = diffSearch.diff === "1"; | 190 | const diffOpen = diffSearch.diff === "1"; |
| 178 | const activeThreadId = routeThreadRef?.threadId ?? null; | 191 | const activeThreadId = routeThreadRef?.threadId ?? null; |
| 179 | const activeThread = useStore( | 192 | const activeThread = useThread(routeThreadRef); |
| 180 | useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), | ||
| 181 | ); | ||
| 182 | const activeProjectId = activeThread?.projectId ?? null; | 193 | const activeProjectId = activeThread?.projectId ?? null; |
| 183 | const activeProject = useStore((store) => | 194 | const activeProject = useProject( |
| 184 | activeThread && activeProjectId | 195 | activeThread && activeProjectId |
| 185 | ? selectProjectByRef(store, { | 196 | ? { |
| 186 | environmentId: activeThread.environmentId, | 197 | environmentId: activeThread.environmentId, |
| 187 | projectId: activeProjectId, | 198 | projectId: activeProjectId, |
| 199 | } | ||
| 200 | : null, | ||
| 201 | ); | ||
| 202 | const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; | ||
| 203 | const serverConfig = useAtomValue( | ||
| 204 | serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), | ||
| 205 | ); | ||
| 206 | const openInPreferredEditor = useOpenInPreferredEditor( | ||
| 207 | activeThread?.environmentId ?? null, | ||
| 208 | serverConfig?.availableEditors ?? [], | ||
| 209 | ); | ||
| 210 | const gitStatusQuery = useEnvironmentQuery( | ||
| 211 | activeThread !== null && activeThread !== undefined && activeCwd != null | ||
| 212 | ? vcsEnvironment.status({ | ||
| 213 | environmentId: activeThread.environmentId, | ||
| 214 | input: { cwd: activeCwd }, | ||
| 188 | }) | 215 | }) |
| 189 | : undefined, | 216 | : null, |
| 190 | ); | 217 | ); |
| 191 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; | ||
| 192 | const gitStatusQuery = useVcsStatus({ | ||
| 193 | environmentId: activeThread?.environmentId ?? null, | ||
| 194 | cwd: activeCwd ?? null, | ||
| 195 | }); | ||
| 196 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; | 218 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 197 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 219 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 198 | useTurnDiffSummaries(activeThread); | 220 | useTurnDiffSummaries(activeThread); |
| ... | @@ -222,6 +244,13 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff | ... | @@ -222,6 +244,13 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 222 | selectedTurn && | 244 | selectedTurn && |
| 223 | (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); | 245 | (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); |
| 224 | const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation"; | 246 | const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation"; |
| 247 | const collapseScopeKey = routeThreadRef | ||
| 248 | ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` | ||
| 249 | : null; | ||
| 250 | const collapsedDiffFileKeys = | ||
| 251 | collapsedDiffFiles.scopeKey === collapseScopeKey | ||
| 252 | ? collapsedDiffFiles.fileKeys | ||
| 253 | : EMPTY_COLLAPSED_DIFF_FILE_KEYS; | ||
| 225 | const reviewSectionTitle = selectedTurn | 254 | const reviewSectionTitle = selectedTurn |
| 226 | ? `Turn ${selectedCheckpointTurnCount ?? "?"}` | 255 | ? `Turn ${selectedCheckpointTurnCount ?? "?"}` |
| 227 | : "All turns"; | 256 | : "All turns"; |
| ... | @@ -304,19 +333,6 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff | ... | @@ -304,19 +333,6 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 304 | ); | 333 | ); |
| 305 | }, [renderablePatch]); | 334 | }, [renderablePatch]); |
| 306 | 335 | ||
| 307 | useEffect(() => { | ||
| 308 | if (renderableFiles.length === 0) { | ||
| 309 | setCollapsedDiffFileKeys((current) => (current.size === 0 ? current : new Set())); | ||
| 310 | return; | ||
| 311 | } | ||
| 312 | |||
| 313 | const visibleFileKeys = new Set(renderableFiles.map(buildFileDiffRenderKey)); | ||
| 314 | setCollapsedDiffFileKeys((current) => { | ||
| 315 | const next = new Set([...current].filter((fileKey) => visibleFileKeys.has(fileKey))); | ||
| 316 | return next.size === current.size ? current : next; | ||
| 317 | }); | ||
| 318 | }, [renderableFiles]); | ||
| 319 | |||
| 320 | useEffect(() => { | 336 | useEffect(() => { |
| 321 | if (diffOpen && !previousDiffOpenRef.current) { | 337 | if (diffOpen && !previousDiffOpenRef.current) { |
| 322 | setDiffWordWrap(settings.diffWordWrap); | 338 | setDiffWordWrap(settings.diffWordWrap); |
| ... | @@ -342,27 +358,31 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff | ... | @@ -342,27 +358,31 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 342 | filePath, | 358 | filePath, |
| 343 | activeCwd, | 359 | activeCwd, |
| 344 | openInEditor: (targetPath) => { | 360 | openInEditor: (targetPath) => { |
| 345 | const api = readLocalApi(); | 361 | void (async () => { |
| 346 | if (!api) return; | 362 | const result = await openInPreferredEditor(targetPath); |
| 347 | void openInPreferredEditor(api, targetPath).catch((error) => { | 363 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 348 | console.warn("Failed to open diff file in editor.", error); | 364 | console.warn("Failed to open diff file in editor.", squashAtomCommandFailure(result)); |
| 349 | }); | 365 | } |
| 366 | })(); | ||
| 350 | }, | 367 | }, |
| 351 | }); | 368 | }); |
| 352 | }, | 369 | }, |
| 353 | [activeCwd, routeThreadRef], | 370 | [activeCwd, openInPreferredEditor, routeThreadRef], |
| 371 | ); | ||
| 372 | const toggleDiffFileCollapsed = useCallback( | ||
| 373 | (fileKey: string) => { | ||
| 374 | setCollapsedDiffFiles((current) => { | ||
| 375 | const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); | ||
| 376 | if (next.has(fileKey)) { | ||
| 377 | next.delete(fileKey); | ||
| 378 | } else { | ||
| 379 | next.add(fileKey); | ||
| 380 | } | ||
| 381 | return { scopeKey: collapseScopeKey, fileKeys: next }; | ||
| 382 | }); | ||
| 383 | }, | ||
| 384 | [collapseScopeKey], | ||
| 354 | ); | 385 | ); |
| 355 | const toggleDiffFileCollapsed = useCallback((fileKey: string) => { | ||
| 356 | setCollapsedDiffFileKeys((current) => { | ||
| 357 | const next = new Set(current); | ||
| 358 | if (next.has(fileKey)) { | ||
| 359 | next.delete(fileKey); | ||
| 360 | } else { | ||
| 361 | next.add(fileKey); | ||
| 362 | } | ||
| 363 | return next; | ||
| 364 | }); | ||
| 365 | }, []); | ||
| 366 | 386 | ||
| 367 | const selectTurn = (turnId: TurnId) => { | 387 | const selectTurn = (turnId: TurnId) => { |
| 368 | if (!activeThread) return; | 388 | if (!activeThread) return; |
apps/web/src/components/DiffPanelShell.browser.tsx deleted-22| ... | @@ -1,22 +0,0 @@ | ||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | import { render } from "vitest-browser-react"; | ||
| 5 | |||
| 6 | import { DiffPanelShell } from "./DiffPanelShell"; | ||
| 7 | |||
| 8 | describe("DiffPanelShell", () => { | ||
| 9 | it("uses the shared compact surface subheader in embedded mode", async () => { | ||
| 10 | const screen = await render( | ||
| 11 | <DiffPanelShell mode="embedded" header={<span>Diff controls</span>}> | ||
| 12 | <div>Diff content</div> | ||
| 13 | </DiffPanelShell>, | ||
| 14 | ); | ||
| 15 | const subheader = screen.container.querySelector<HTMLElement>("[data-surface-subheader]"); | ||
| 16 | |||
| 17 | expect(subheader).not.toBeNull(); | ||
| 18 | expect(subheader?.getBoundingClientRect().height).toBe(40); | ||
| 19 | expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px"); | ||
| 20 | expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px"); | ||
| 21 | }); | ||
| 22 | }); | ||
apps/web/src/components/GitActionsControl.browser.tsx deleted-457| ... | @@ -1,457 +0,0 @@ | ||
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 2 | import { ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { useState } from "react"; | ||
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | import { render } from "vitest-browser-react"; | ||
| 6 | |||
| 7 | const SHARED_THREAD_ID = ThreadId.make("thread-shared"); | ||
| 8 | const ENVIRONMENT_A = "environment-local" as never; | ||
| 9 | const ENVIRONMENT_B = "environment-remote" as never; | ||
| 10 | const GIT_CWD = "/repo/project"; | ||
| 11 | const BRANCH_NAME = "feature/toast-scope"; | ||
| 12 | |||
| 13 | function createDeferredPromise<T>() { | ||
| 14 | let resolve!: (value: T) => void; | ||
| 15 | let reject!: (reason?: unknown) => void; | ||
| 16 | |||
| 17 | const promise = new Promise<T>((nextResolve, nextReject) => { | ||
| 18 | resolve = nextResolve; | ||
| 19 | reject = nextReject; | ||
| 20 | }); | ||
| 21 | |||
| 22 | return { promise, resolve, reject }; | ||
| 23 | } | ||
| 24 | |||
| 25 | const { | ||
| 26 | activeRunStackedActionDeferredRef, | ||
| 27 | activeDraftThreadRef, | ||
| 28 | hasServerThreadRef, | ||
| 29 | invalidateSourceControlStateSpy, | ||
| 30 | refreshVcsStatusSpy, | ||
| 31 | runStackedActionSpy, | ||
| 32 | setDraftThreadContextSpy, | ||
| 33 | setThreadBranchSpy, | ||
| 34 | toastAddSpy, | ||
| 35 | toastCloseSpy, | ||
| 36 | toastPromiseSpy, | ||
| 37 | toastUpdateSpy, | ||
| 38 | } = vi.hoisted(() => ({ | ||
| 39 | activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() }, | ||
| 40 | activeDraftThreadRef: { current: null as unknown }, | ||
| 41 | hasServerThreadRef: { current: true }, | ||
| 42 | invalidateSourceControlStateSpy: vi.fn(() => Promise.resolve()), | ||
| 43 | refreshVcsStatusSpy: vi.fn(() => Promise.resolve(null)), | ||
| 44 | runStackedActionSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), | ||
| 45 | setDraftThreadContextSpy: vi.fn(), | ||
| 46 | setThreadBranchSpy: vi.fn(), | ||
| 47 | toastAddSpy: vi.fn(() => "toast-1"), | ||
| 48 | toastCloseSpy: vi.fn(), | ||
| 49 | toastPromiseSpy: vi.fn(), | ||
| 50 | toastUpdateSpy: vi.fn(), | ||
| 51 | })); | ||
| 52 | |||
| 53 | vi.mock("~/components/ui/toast", () => ({ | ||
| 54 | toastManager: { | ||
| 55 | add: toastAddSpy, | ||
| 56 | close: toastCloseSpy, | ||
| 57 | promise: toastPromiseSpy, | ||
| 58 | update: toastUpdateSpy, | ||
| 59 | }, | ||
| 60 | stackedThreadToast: vi.fn((options: unknown) => options), | ||
| 61 | })); | ||
| 62 | |||
| 63 | vi.mock("~/editorPreferences", () => ({ | ||
| 64 | openInPreferredEditor: vi.fn(), | ||
| 65 | })); | ||
| 66 | |||
| 67 | vi.mock("~/lib/sourceControlActions", () => ({ | ||
| 68 | invalidateSourceControlState: invalidateSourceControlStateSpy, | ||
| 69 | useGitStackedAction: vi.fn(() => ({ | ||
| 70 | error: null, | ||
| 71 | isPending: false, | ||
| 72 | resetError: vi.fn(), | ||
| 73 | run: runStackedActionSpy, | ||
| 74 | })), | ||
| 75 | useSourceControlActionRunning: vi.fn(() => false), | ||
| 76 | useSourceControlPublishRepositoryAction: vi.fn(() => ({ | ||
| 77 | error: null, | ||
| 78 | isPending: false, | ||
| 79 | resetError: vi.fn(), | ||
| 80 | run: vi.fn(), | ||
| 81 | })), | ||
| 82 | useVcsInitAction: vi.fn(() => ({ | ||
| 83 | error: null, | ||
| 84 | isPending: false, | ||
| 85 | resetError: vi.fn(), | ||
| 86 | run: vi.fn(), | ||
| 87 | })), | ||
| 88 | useVcsPullAction: vi.fn(() => ({ | ||
| 89 | error: null, | ||
| 90 | isPending: false, | ||
| 91 | resetError: vi.fn(), | ||
| 92 | run: vi.fn(), | ||
| 93 | })), | ||
| 94 | })); | ||
| 95 | |||
| 96 | vi.mock("~/lib/vcsStatusState", () => ({ | ||
| 97 | getVcsStatusDataForTarget: (state: { data: unknown }) => state.data, | ||
| 98 | refreshVcsStatus: refreshVcsStatusSpy, | ||
| 99 | resetVcsStatusStateForTests: () => undefined, | ||
| 100 | useVcsStatus: vi.fn(() => ({ | ||
| 101 | data: { | ||
| 102 | isRepo: true, | ||
| 103 | sourceControlProvider: { | ||
| 104 | kind: "github", | ||
| 105 | name: "GitHub", | ||
| 106 | baseUrl: "https://github.com", | ||
| 107 | }, | ||
| 108 | hasPrimaryRemote: true, | ||
| 109 | isDefaultRef: false, | ||
| 110 | refName: BRANCH_NAME, | ||
| 111 | hasWorkingTreeChanges: false, | ||
| 112 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 113 | hasUpstream: true, | ||
| 114 | aheadCount: 1, | ||
| 115 | behindCount: 0, | ||
| 116 | pr: null, | ||
| 117 | }, | ||
| 118 | error: null, | ||
| 119 | isPending: false, | ||
| 120 | })), | ||
| 121 | })); | ||
| 122 | |||
| 123 | vi.mock("~/localApi", () => ({ | ||
| 124 | ensureLocalApi: vi.fn(() => { | ||
| 125 | throw new Error("ensureLocalApi not implemented in browser test"); | ||
| 126 | }), | ||
| 127 | readLocalApi: vi.fn(() => null), | ||
| 128 | })); | ||
| 129 | |||
| 130 | vi.mock("~/composerDraftStore", async () => { | ||
| 131 | const draftStoreState = { | ||
| 132 | getDraftThreadByRef: () => activeDraftThreadRef.current, | ||
| 133 | getDraftSession: () => activeDraftThreadRef.current, | ||
| 134 | getDraftThread: () => activeDraftThreadRef.current, | ||
| 135 | getDraftSessionByLogicalProjectKey: () => null, | ||
| 136 | setDraftThreadContext: setDraftThreadContextSpy, | ||
| 137 | setLogicalProjectDraftThreadId: vi.fn(), | ||
| 138 | setProjectDraftThreadId: vi.fn(), | ||
| 139 | hasDraftThreadsInEnvironment: () => false, | ||
| 140 | clearDraftThread: vi.fn(), | ||
| 141 | }; | ||
| 142 | |||
| 143 | return { | ||
| 144 | DraftId: { | ||
| 145 | makeUnsafe: (value: string) => value, | ||
| 146 | }, | ||
| 147 | useComposerDraftStore: Object.assign( | ||
| 148 | (selector: (state: unknown) => unknown) => selector(draftStoreState), | ||
| 149 | { getState: () => draftStoreState }, | ||
| 150 | ), | ||
| 151 | markPromotedDraftThread: vi.fn(), | ||
| 152 | markPromotedDraftThreadByRef: vi.fn(), | ||
| 153 | markPromotedDraftThreads: vi.fn(), | ||
| 154 | markPromotedDraftThreadsByRef: vi.fn(), | ||
| 155 | finalizePromotedDraftThreadByRef: vi.fn(), | ||
| 156 | finalizePromotedDraftThreadsByRef: vi.fn(), | ||
| 157 | }; | ||
| 158 | }); | ||
| 159 | |||
| 160 | vi.mock("~/store", () => ({ | ||
| 161 | selectEnvironmentState: ( | ||
| 162 | state: { environmentStateById: Record<string, unknown> }, | ||
| 163 | environmentId: string | null, | ||
| 164 | ) => { | ||
| 165 | if (!environmentId) { | ||
| 166 | throw new Error("Missing environment id"); | ||
| 167 | } | ||
| 168 | const environmentState = state.environmentStateById[environmentId]; | ||
| 169 | if (!environmentState) { | ||
| 170 | throw new Error(`Unknown environment: ${environmentId}`); | ||
| 171 | } | ||
| 172 | return environmentState; | ||
| 173 | }, | ||
| 174 | selectProjectsForEnvironment: () => [], | ||
| 175 | selectProjectsAcrossEnvironments: () => [], | ||
| 176 | selectThreadsForEnvironment: () => [], | ||
| 177 | selectThreadsAcrossEnvironments: () => [], | ||
| 178 | selectThreadShellsAcrossEnvironments: () => [], | ||
| 179 | selectSidebarThreadsAcrossEnvironments: () => [], | ||
| 180 | selectSidebarThreadsForProjectRef: () => [], | ||
| 181 | selectSidebarThreadsForProjectRefs: () => [], | ||
| 182 | selectBootstrapCompleteForActiveEnvironment: () => true, | ||
| 183 | selectProjectByRef: () => null, | ||
| 184 | selectThreadByRef: () => null, | ||
| 185 | selectSidebarThreadSummaryByRef: () => null, | ||
| 186 | selectThreadIdsByProjectRef: () => [], | ||
| 187 | useStore: (selector: (state: unknown) => unknown) => | ||
| 188 | selector({ | ||
| 189 | setThreadBranch: setThreadBranchSpy, | ||
| 190 | environmentStateById: { | ||
| 191 | [ENVIRONMENT_A]: { | ||
| 192 | threadShellById: hasServerThreadRef.current | ||
| 193 | ? { | ||
| 194 | [SHARED_THREAD_ID]: { | ||
| 195 | id: SHARED_THREAD_ID, | ||
| 196 | branch: BRANCH_NAME, | ||
| 197 | worktreePath: null, | ||
| 198 | }, | ||
| 199 | } | ||
| 200 | : {}, | ||
| 201 | threadSessionById: {}, | ||
| 202 | threadTurnStateById: {}, | ||
| 203 | messageIdsByThreadId: {}, | ||
| 204 | messageByThreadId: {}, | ||
| 205 | activityIdsByThreadId: {}, | ||
| 206 | activityByThreadId: {}, | ||
| 207 | proposedPlanIdsByThreadId: {}, | ||
| 208 | proposedPlanByThreadId: {}, | ||
| 209 | turnDiffIdsByThreadId: {}, | ||
| 210 | turnDiffSummaryByThreadId: {}, | ||
| 211 | }, | ||
| 212 | [ENVIRONMENT_B]: { | ||
| 213 | threadShellById: hasServerThreadRef.current | ||
| 214 | ? { | ||
| 215 | [SHARED_THREAD_ID]: { | ||
| 216 | id: SHARED_THREAD_ID, | ||
| 217 | branch: BRANCH_NAME, | ||
| 218 | worktreePath: null, | ||
| 219 | }, | ||
| 220 | } | ||
| 221 | : {}, | ||
| 222 | threadSessionById: {}, | ||
| 223 | threadTurnStateById: {}, | ||
| 224 | messageIdsByThreadId: {}, | ||
| 225 | messageByThreadId: {}, | ||
| 226 | activityIdsByThreadId: {}, | ||
| 227 | activityByThreadId: {}, | ||
| 228 | proposedPlanIdsByThreadId: {}, | ||
| 229 | proposedPlanByThreadId: {}, | ||
| 230 | turnDiffIdsByThreadId: {}, | ||
| 231 | turnDiffSummaryByThreadId: {}, | ||
| 232 | }, | ||
| 233 | }, | ||
| 234 | }), | ||
| 235 | })); | ||
| 236 | |||
| 237 | vi.mock("~/terminal-links", () => ({ | ||
| 238 | resolvePathLinkTarget: vi.fn(), | ||
| 239 | })); | ||
| 240 | |||
| 241 | import GitActionsControl from "./GitActionsControl"; | ||
| 242 | |||
| 243 | function findButtonByText(text: string): HTMLButtonElement | null { | ||
| 244 | return (Array.from(document.querySelectorAll("button")).find((button) => | ||
| 245 | button.textContent?.includes(text), | ||
| 246 | ) ?? null) as HTMLButtonElement | null; | ||
| 247 | } | ||
| 248 | |||
| 249 | function Harness() { | ||
| 250 | const [activeThreadRef, setActiveThreadRef] = useState( | ||
| 251 | scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), | ||
| 252 | ); | ||
| 253 | |||
| 254 | return ( | ||
| 255 | <> | ||
| 256 | <button | ||
| 257 | type="button" | ||
| 258 | onClick={() => setActiveThreadRef(scopeThreadRef(ENVIRONMENT_B, SHARED_THREAD_ID))} | ||
| 259 | > | ||
| 260 | Switch environment | ||
| 261 | </button> | ||
| 262 | <GitActionsControl gitCwd={GIT_CWD} activeThreadRef={activeThreadRef} /> | ||
| 263 | </> | ||
| 264 | ); | ||
| 265 | } | ||
| 266 | |||
| 267 | describe("GitActionsControl thread-scoped progress toast", () => { | ||
| 268 | afterEach(() => { | ||
| 269 | vi.useRealTimers(); | ||
| 270 | vi.clearAllMocks(); | ||
| 271 | activeRunStackedActionDeferredRef.current = createDeferredPromise<never>(); | ||
| 272 | activeDraftThreadRef.current = null; | ||
| 273 | hasServerThreadRef.current = true; | ||
| 274 | document.body.innerHTML = ""; | ||
| 275 | }); | ||
| 276 | |||
| 277 | it("keeps an in-flight git action toast pinned to the thread ref that started it", async () => { | ||
| 278 | vi.useFakeTimers(); | ||
| 279 | |||
| 280 | const host = document.createElement("div"); | ||
| 281 | document.body.append(host); | ||
| 282 | const screen = await render(<Harness />, { container: host }); | ||
| 283 | |||
| 284 | try { | ||
| 285 | const quickActionButton = findButtonByText("Push & create PR"); | ||
| 286 | expect(quickActionButton, 'Unable to find button containing "Push & create PR"').toBeTruthy(); | ||
| 287 | if (!(quickActionButton instanceof HTMLButtonElement)) { | ||
| 288 | throw new Error('Unable to find button containing "Push & create PR"'); | ||
| 289 | } | ||
| 290 | quickActionButton.click(); | ||
| 291 | |||
| 292 | expect(toastAddSpy).toHaveBeenCalledWith( | ||
| 293 | expect.objectContaining({ | ||
| 294 | data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, | ||
| 295 | title: "Pushing...", | ||
| 296 | type: "loading", | ||
| 297 | }), | ||
| 298 | ); | ||
| 299 | |||
| 300 | await vi.advanceTimersByTimeAsync(1_000); | ||
| 301 | |||
| 302 | expect(toastUpdateSpy).toHaveBeenLastCalledWith( | ||
| 303 | "toast-1", | ||
| 304 | expect.objectContaining({ | ||
| 305 | data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, | ||
| 306 | title: "Pushing...", | ||
| 307 | type: "loading", | ||
| 308 | }), | ||
| 309 | ); | ||
| 310 | |||
| 311 | const switchEnvironmentButton = findButtonByText("Switch environment"); | ||
| 312 | expect( | ||
| 313 | switchEnvironmentButton, | ||
| 314 | 'Unable to find button containing "Switch environment"', | ||
| 315 | ).toBeTruthy(); | ||
| 316 | if (!(switchEnvironmentButton instanceof HTMLButtonElement)) { | ||
| 317 | throw new Error('Unable to find button containing "Switch environment"'); | ||
| 318 | } | ||
| 319 | switchEnvironmentButton.click(); | ||
| 320 | await vi.advanceTimersByTimeAsync(1_000); | ||
| 321 | |||
| 322 | expect(toastUpdateSpy).toHaveBeenLastCalledWith( | ||
| 323 | "toast-1", | ||
| 324 | expect.objectContaining({ | ||
| 325 | data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, | ||
| 326 | title: "Pushing...", | ||
| 327 | type: "loading", | ||
| 328 | }), | ||
| 329 | ); | ||
| 330 | } finally { | ||
| 331 | activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); | ||
| 332 | await Promise.resolve(); | ||
| 333 | vi.useRealTimers(); | ||
| 334 | await screen.unmount(); | ||
| 335 | host.remove(); | ||
| 336 | } | ||
| 337 | }); | ||
| 338 | |||
| 339 | it("debounces focus-driven git status refreshes", async () => { | ||
| 340 | vi.useFakeTimers(); | ||
| 341 | |||
| 342 | const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); | ||
| 343 | let visibilityState: DocumentVisibilityState = "hidden"; | ||
| 344 | Object.defineProperty(document, "visibilityState", { | ||
| 345 | configurable: true, | ||
| 346 | get: () => visibilityState, | ||
| 347 | }); | ||
| 348 | |||
| 349 | const host = document.createElement("div"); | ||
| 350 | document.body.append(host); | ||
| 351 | const screen = await render( | ||
| 352 | <GitActionsControl | ||
| 353 | gitCwd={GIT_CWD} | ||
| 354 | activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)} | ||
| 355 | />, | ||
| 356 | { | ||
| 357 | container: host, | ||
| 358 | }, | ||
| 359 | ); | ||
| 360 | |||
| 361 | try { | ||
| 362 | window.dispatchEvent(new Event("focus")); | ||
| 363 | visibilityState = "visible"; | ||
| 364 | document.dispatchEvent(new Event("visibilitychange")); | ||
| 365 | |||
| 366 | expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); | ||
| 367 | |||
| 368 | await vi.advanceTimersByTimeAsync(249); | ||
| 369 | expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); | ||
| 370 | |||
| 371 | await vi.advanceTimersByTimeAsync(1); | ||
| 372 | expect(refreshVcsStatusSpy).toHaveBeenCalledTimes(1); | ||
| 373 | expect(refreshVcsStatusSpy).toHaveBeenCalledWith({ | ||
| 374 | environmentId: ENVIRONMENT_A, | ||
| 375 | cwd: GIT_CWD, | ||
| 376 | }); | ||
| 377 | } finally { | ||
| 378 | if (originalVisibilityState) { | ||
| 379 | Object.defineProperty(document, "visibilityState", originalVisibilityState); | ||
| 380 | } | ||
| 381 | vi.useRealTimers(); | ||
| 382 | await screen.unmount(); | ||
| 383 | host.remove(); | ||
| 384 | } | ||
| 385 | }); | ||
| 386 | |||
| 387 | it("syncs the live branch into the active draft thread when no server thread exists", async () => { | ||
| 388 | hasServerThreadRef.current = false; | ||
| 389 | activeDraftThreadRef.current = { | ||
| 390 | threadId: SHARED_THREAD_ID, | ||
| 391 | environmentId: ENVIRONMENT_A, | ||
| 392 | branch: null, | ||
| 393 | worktreePath: null, | ||
| 394 | }; | ||
| 395 | |||
| 396 | const host = document.createElement("div"); | ||
| 397 | document.body.append(host); | ||
| 398 | const screen = await render( | ||
| 399 | <GitActionsControl | ||
| 400 | gitCwd={GIT_CWD} | ||
| 401 | activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)} | ||
| 402 | />, | ||
| 403 | { | ||
| 404 | container: host, | ||
| 405 | }, | ||
| 406 | ); | ||
| 407 | |||
| 408 | try { | ||
| 409 | await Promise.resolve(); | ||
| 410 | |||
| 411 | expect(setDraftThreadContextSpy).toHaveBeenCalledWith( | ||
| 412 | scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), | ||
| 413 | { | ||
| 414 | branch: BRANCH_NAME, | ||
| 415 | worktreePath: null, | ||
| 416 | }, | ||
| 417 | ); | ||
| 418 | expect(setThreadBranchSpy).not.toHaveBeenCalled(); | ||
| 419 | } finally { | ||
| 420 | await screen.unmount(); | ||
| 421 | host.remove(); | ||
| 422 | } | ||
| 423 | }); | ||
| 424 | |||
| 425 | it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { | ||
| 426 | hasServerThreadRef.current = false; | ||
| 427 | activeDraftThreadRef.current = { | ||
| 428 | threadId: SHARED_THREAD_ID, | ||
| 429 | environmentId: ENVIRONMENT_A, | ||
| 430 | branch: "feature/base-branch", | ||
| 431 | worktreePath: null, | ||
| 432 | envMode: "worktree", | ||
| 433 | }; | ||
| 434 | |||
| 435 | const host = document.createElement("div"); | ||
| 436 | document.body.append(host); | ||
| 437 | const screen = await render( | ||
| 438 | <GitActionsControl | ||
| 439 | gitCwd={GIT_CWD} | ||
| 440 | activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)} | ||
| 441 | />, | ||
| 442 | { | ||
| 443 | container: host, | ||
| 444 | }, | ||
| 445 | ); | ||
| 446 | |||
| 447 | try { | ||
| 448 | await Promise.resolve(); | ||
| 449 | |||
| 450 | expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); | ||
| 451 | expect(setThreadBranchSpy).not.toHaveBeenCalled(); | ||
| 452 | } finally { | ||
| 453 | await screen.unmount(); | ||
| 454 | host.remove(); | ||
| 455 | } | ||
| 456 | }); | ||
| 457 | }); | ||
apps/web/src/components/GitActionsControl.tsx+235-177| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 1 | import { type ScopedThreadRef } from "@t3tools/contracts"; | 2 | import { type ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { | ||
| 4 | isAtomCommandInterrupted, | ||
| 5 | squashAtomCommandFailure, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import type { | 7 | import type { |
| 3 | GitActionProgressEvent, | 8 | GitActionProgressEvent, |
| 4 | GitRunStackedActionResult, | 9 | GitRunStackedActionResult, |
| ... | @@ -63,7 +68,7 @@ import { ScrollArea } from "~/components/ui/scroll-area"; | ... | @@ -63,7 +68,7 @@ import { ScrollArea } from "~/components/ui/scroll-area"; |
| 63 | import { Textarea } from "~/components/ui/textarea"; | 68 | import { Textarea } from "~/components/ui/textarea"; |
| 64 | import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; | 69 | import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; |
| 65 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 70 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 66 | import { openInPreferredEditor } from "~/editorPreferences"; | 71 | import { useOpenInPreferredEditor } from "~/editorPreferences"; |
| 67 | import { | 72 | import { |
| 68 | useGitStackedAction, | 73 | useGitStackedAction, |
| 69 | useSourceControlActionRunning, | 74 | useSourceControlActionRunning, |
| ... | @@ -71,16 +76,18 @@ import { | ... | @@ -71,16 +76,18 @@ import { |
| 71 | useVcsInitAction, | 76 | useVcsInitAction, |
| 72 | useVcsPullAction, | 77 | useVcsPullAction, |
| 73 | } from "~/lib/sourceControlActions"; | 78 | } from "~/lib/sourceControlActions"; |
| 74 | import { getVcsStatusDataForTarget, refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; | 79 | import { useThread } from "~/state/entities"; |
| 75 | import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; | 80 | import { useEnvironmentQuery } from "~/state/query"; |
| 76 | import { newCommandId, randomUUID } from "~/lib/utils"; | 81 | import { serverEnvironment } from "~/state/server"; |
| 82 | import { sourceControlEnvironment } from "~/state/sourceControl"; | ||
| 83 | import { threadEnvironment } from "~/state/threads"; | ||
| 84 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 85 | import { vcsEnvironment } from "~/state/vcs"; | ||
| 86 | import { randomUUID } from "~/lib/utils"; | ||
| 77 | import { resolvePathLinkTarget } from "~/terminal-links"; | 87 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 78 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 88 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 79 | import { readEnvironmentApi } from "~/environmentApi"; | ||
| 80 | import { readLocalApi } from "~/localApi"; | 89 | import { readLocalApi } from "~/localApi"; |
| 81 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; | 90 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; |
| 82 | import { useStore } from "~/store"; | ||
| 83 | import { createThreadSelectorByRef } from "~/storeSelectors"; | ||
| 84 | 91 | ||
| 85 | interface GitActionsControlProps { | 92 | interface GitActionsControlProps { |
| 86 | gitCwd: string | null; | 93 | gitCwd: string | null; |
| ... | @@ -128,6 +135,22 @@ interface RunGitActionWithToastInput { | ... | @@ -128,6 +135,22 @@ interface RunGitActionWithToastInput { |
| 128 | } | 135 | } |
| 129 | 136 | ||
| 130 | const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; | 137 | const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; |
| 138 | |||
| 139 | type RefreshVcsStatus = (target: { | ||
| 140 | readonly environmentId: ScopedThreadRef["environmentId"]; | ||
| 141 | readonly input: { readonly cwd: string }; | ||
| 142 | }) => Promise<unknown>; | ||
| 143 | |||
| 144 | function requestVcsStatusRefresh( | ||
| 145 | refresh: RefreshVcsStatus, | ||
| 146 | environmentId: ScopedThreadRef["environmentId"] | null, | ||
| 147 | cwd: string | null, | ||
| 148 | ): void { | ||
| 149 | if (environmentId === null || cwd === null) { | ||
| 150 | return; | ||
| 151 | } | ||
| 152 | void refresh({ environmentId, input: { cwd } }); | ||
| 153 | } | ||
| 131 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; | 154 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; |
| 132 | 155 | ||
| 133 | const PUBLISH_PROVIDER_OPTIONS = [ | 156 | const PUBLISH_PROVIDER_OPTIONS = [ |
| ... | @@ -348,9 +371,17 @@ interface PublishRepositoryDialogProps { | ... | @@ -348,9 +371,17 @@ interface PublishRepositoryDialogProps { |
| 348 | 371 | ||
| 349 | function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | 372 | function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 350 | const navigate = useNavigate(); | 373 | const navigate = useNavigate(); |
| 351 | const sourceControlDiscovery = useSourceControlDiscovery(); | 374 | const sourceControlDiscovery = useEnvironmentQuery( |
| 352 | const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github"); | 375 | props.environmentId === null |
| 353 | const [publishRepository, setPublishRepository] = useState(""); | 376 | ? null |
| 377 | : sourceControlEnvironment.discovery({ | ||
| 378 | environmentId: props.environmentId, | ||
| 379 | input: {}, | ||
| 380 | }), | ||
| 381 | ); | ||
| 382 | const [selectedPublishProvider, setSelectedPublishProvider] = | ||
| 383 | useState<PublishProviderKind | null>(null); | ||
| 384 | const [publishRepositoryOverride, setPublishRepositoryOverride] = useState<string | null>(null); | ||
| 354 | const [publishVisibility, setPublishVisibility] = | 385 | const [publishVisibility, setPublishVisibility] = |
| 355 | useState<SourceControlRepositoryVisibility>("private"); | 386 | useState<SourceControlRepositoryVisibility>("private"); |
| 356 | const [publishRemoteName, setPublishRemoteName] = useState("origin"); | 387 | const [publishRemoteName, setPublishRemoteName] = useState("origin"); |
| ... | @@ -361,7 +392,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -361,7 +392,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 361 | const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>( | 392 | const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>( |
| 362 | null, | 393 | null, |
| 363 | ); | 394 | ); |
| 364 | const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); | ||
| 365 | const sourceControlScope = useMemo( | 395 | const sourceControlScope = useMemo( |
| 366 | () => ({ | 396 | () => ({ |
| 367 | environmentId: props.environmentId, | 397 | environmentId: props.environmentId, |
| ... | @@ -412,10 +442,18 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -412,10 +442,18 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 412 | }), | 442 | }), |
| 413 | [publishProviderReadiness], | 443 | [publishProviderReadiness], |
| 414 | ); | 444 | ); |
| 445 | const firstReadyPublishProvider = sortedPublishProviderOptions.find( | ||
| 446 | (option) => publishProviderReadiness[option.value].ready, | ||
| 447 | )?.value; | ||
| 448 | const publishProvider = | ||
| 449 | selectedPublishProvider !== null && publishProviderReadiness[selectedPublishProvider].ready | ||
| 450 | ? selectedPublishProvider | ||
| 451 | : (firstReadyPublishProvider ?? selectedPublishProvider ?? "github"); | ||
| 415 | const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; | 452 | const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; |
| 416 | const publishRepositoryPrefill = publishAccountByProvider[publishProvider] | 453 | const publishRepositoryPrefill = publishAccountByProvider[publishProvider] |
| 417 | ? `${publishAccountByProvider[publishProvider]}/` | 454 | ? `${publishAccountByProvider[publishProvider]}/` |
| 418 | : ""; | 455 | : ""; |
| 456 | const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; | ||
| 419 | const currentPublishProvider = publishProviderOption(publishProvider); | 457 | const currentPublishProvider = publishProviderOption(publishProvider); |
| 420 | const publishHost = currentPublishProvider.host; | 458 | const publishHost = currentPublishProvider.host; |
| 421 | const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; | 459 | const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; |
| ... | @@ -427,13 +465,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -427,13 +465,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 427 | null, | 465 | null, |
| 428 | ] as const; | 466 | ] as const; |
| 429 | 467 | ||
| 430 | useEffect(() => { | ||
| 431 | if (!props.open || hasUserEditedPublishRepository) { | ||
| 432 | return; | ||
| 433 | } | ||
| 434 | setPublishRepository(publishRepositoryPrefill); | ||
| 435 | }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); | ||
| 436 | |||
| 437 | const canSubmitPublishRepository = useMemo(() => { | 468 | const canSubmitPublishRepository = useMemo(() => { |
| 438 | if (!selectedPublishProviderReadiness.ready) return false; | 469 | if (!selectedPublishProviderReadiness.ready) return false; |
| 439 | if (publishRepositoryAction.isPending) return false; | 470 | if (publishRepositoryAction.isPending) return false; |
| ... | @@ -444,21 +475,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -444,21 +475,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 444 | return owner.length > 0 && name.length > 0; | 475 | return owner.length > 0 && name.length > 0; |
| 445 | }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]); | 476 | }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]); |
| 446 | 477 | ||
| 447 | useEffect(() => { | ||
| 448 | if (!props.open) { | ||
| 449 | return; | ||
| 450 | } | ||
| 451 | if (publishProviderReadiness[publishProvider].ready) { | ||
| 452 | return; | ||
| 453 | } | ||
| 454 | const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( | ||
| 455 | (option) => publishProviderReadiness[option.value].ready, | ||
| 456 | ); | ||
| 457 | if (firstReadyProvider) { | ||
| 458 | setPublishProvider(firstReadyProvider.value); | ||
| 459 | } | ||
| 460 | }, [props.open, publishProvider, publishProviderReadiness]); | ||
| 461 | |||
| 462 | const submitPublishRepository = useCallback(() => { | 478 | const submitPublishRepository = useCallback(() => { |
| 463 | if (!canSubmitPublishRepository) { | 479 | if (!canSubmitPublishRepository) { |
| 464 | return; | 480 | return; |
| ... | @@ -466,26 +482,28 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -466,26 +482,28 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 466 | 482 | ||
| 467 | setPublishError(null); | 483 | setPublishError(null); |
| 468 | 484 | ||
| 469 | void publishRepositoryAction | 485 | void (async () => { |
| 470 | .run({ | 486 | const result = await publishRepositoryAction.run({ |
| 471 | provider: publishProvider, | 487 | provider: publishProvider, |
| 472 | repository: publishRepository.trim(), | 488 | repository: publishRepository.trim(), |
| 473 | visibility: publishVisibility, | 489 | visibility: publishVisibility, |
| 474 | remoteName: publishRemoteName.trim() || "origin", | 490 | remoteName: publishRemoteName.trim() || "origin", |
| 475 | protocol: publishProtocol, | 491 | protocol: publishProtocol, |
| 476 | }) | ||
| 477 | .then((result) => { | ||
| 478 | flushSync(() => { | ||
| 479 | setPublishResult(result); | ||
| 480 | setPublishWizardStep(2); | ||
| 481 | }); | ||
| 482 | void refreshVcsStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( | ||
| 483 | () => undefined, | ||
| 484 | ); | ||
| 485 | }) | ||
| 486 | .catch((err: unknown) => { | ||
| 487 | setPublishError(err instanceof Error ? err.message : "An error occurred."); | ||
| 488 | }); | 492 | }); |
| 493 | |||
| 494 | if (result._tag === "Failure") { | ||
| 495 | if (!isAtomCommandInterrupted(result)) { | ||
| 496 | const error = squashAtomCommandFailure(result); | ||
| 497 | setPublishError(error instanceof Error ? error.message : "An error occurred."); | ||
| 498 | } | ||
| 499 | return; | ||
| 500 | } | ||
| 501 | |||
| 502 | flushSync(() => { | ||
| 503 | setPublishResult(result.value); | ||
| 504 | setPublishWizardStep(2); | ||
| 505 | }); | ||
| 506 | })(); | ||
| 489 | }, [ | 507 | }, [ |
| 490 | canSubmitPublishRepository, | 508 | canSubmitPublishRepository, |
| 491 | props.environmentId, | 509 | props.environmentId, |
| ... | @@ -500,8 +518,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -500,8 +518,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 500 | 518 | ||
| 501 | const resetState = useCallback(() => { | 519 | const resetState = useCallback(() => { |
| 502 | setPublishRemoteName("origin"); | 520 | setPublishRemoteName("origin"); |
| 503 | setPublishRepository(""); | 521 | setPublishRepositoryOverride(null); |
| 504 | setHasUserEditedPublishRepository(false); | ||
| 505 | setPublishWizardStep(0); | 522 | setPublishWizardStep(0); |
| 506 | setPublishAdvancedOpen(false); | 523 | setPublishAdvancedOpen(false); |
| 507 | setPublishError(null); | 524 | setPublishError(null); |
| ... | @@ -594,7 +611,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -594,7 +611,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 594 | </span> | 611 | </span> |
| 595 | <RadioGroup | 612 | <RadioGroup |
| 596 | value={publishProvider} | 613 | value={publishProvider} |
| 597 | onValueChange={(value) => setPublishProvider(value as PublishProviderKind)} | 614 | onValueChange={(value) => { |
| 615 | setSelectedPublishProvider(value as PublishProviderKind); | ||
| 616 | setPublishRepositoryOverride(null); | ||
| 617 | }} | ||
| 598 | aria-labelledby="publish-provider-cards-label" | 618 | aria-labelledby="publish-provider-cards-label" |
| 599 | className="grid grid-cols-2 gap-2.5" | 619 | className="grid grid-cols-2 gap-2.5" |
| 600 | > | 620 | > |
| ... | @@ -680,8 +700,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -680,8 +700,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 680 | name="publish-repository-path" | 700 | name="publish-repository-path" |
| 681 | value={publishRepository} | 701 | value={publishRepository} |
| 682 | onChange={(event) => { | 702 | onChange={(event) => { |
| 683 | setPublishRepository(event.target.value); | 703 | setPublishRepositoryOverride(event.target.value); |
| 684 | setHasUserEditedPublishRepository(true); | ||
| 685 | }} | 704 | }} |
| 686 | onKeyDown={(event) => { | 705 | onKeyDown={(event) => { |
| 687 | if (event.key === "Enter") { | 706 | if (event.key === "Enter") { |
| ... | @@ -951,16 +970,21 @@ export default function GitActionsControl({ | ... | @@ -951,16 +970,21 @@ export default function GitActionsControl({ |
| 951 | activeThreadRef, | 970 | activeThreadRef, |
| 952 | draftId, | 971 | draftId, |
| 953 | }: GitActionsControlProps) { | 972 | }: GitActionsControlProps) { |
| 973 | const updateThreadMetadata = useAtomCommand( | ||
| 974 | threadEnvironment.updateMetadata, | ||
| 975 | "thread branch metadata update", | ||
| 976 | ); | ||
| 954 | const activeEnvironmentId = activeThreadRef?.environmentId ?? null; | 977 | const activeEnvironmentId = activeThreadRef?.environmentId ?? null; |
| 978 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); | ||
| 979 | const openInPreferredEditor = useOpenInPreferredEditor( | ||
| 980 | activeEnvironmentId, | ||
| 981 | serverConfig?.availableEditors ?? [], | ||
| 982 | ); | ||
| 955 | const threadToastData = useMemo( | 983 | const threadToastData = useMemo( |
| 956 | () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), | 984 | () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), |
| 957 | [activeThreadRef], | 985 | [activeThreadRef], |
| 958 | ); | 986 | ); |
| 959 | const activeServerThreadSelector = useMemo( | 987 | const activeServerThread = useThread(activeThreadRef); |
| 960 | () => createThreadSelectorByRef(activeThreadRef), | ||
| 961 | [activeThreadRef], | ||
| 962 | ); | ||
| 963 | const activeServerThread = useStore(activeServerThreadSelector); | ||
| 964 | const activeDraftThread = useComposerDraftStore((store) => | 988 | const activeDraftThread = useComposerDraftStore((store) => |
| 965 | draftId | 989 | draftId |
| 966 | ? store.getDraftSession(draftId) | 990 | ? store.getDraftSession(draftId) |
| ... | @@ -969,7 +993,6 @@ export default function GitActionsControl({ | ... | @@ -969,7 +993,6 @@ export default function GitActionsControl({ |
| 969 | : null, | 993 | : null, |
| 970 | ); | 994 | ); |
| 971 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 995 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 972 | const setThreadBranch = useStore((store) => store.setThreadBranch); | ||
| 973 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); | 996 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); |
| 974 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | 997 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 975 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); | 998 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); |
| ... | @@ -1010,20 +1033,15 @@ export default function GitActionsControl({ | ... | @@ -1010,20 +1033,15 @@ export default function GitActionsControl({ |
| 1010 | } | 1033 | } |
| 1011 | 1034 | ||
| 1012 | const worktreePath = activeServerThread.worktreePath; | 1035 | const worktreePath = activeServerThread.worktreePath; |
| 1013 | const api = readEnvironmentApi(activeThreadRef.environmentId); | 1036 | void updateThreadMetadata({ |
| 1014 | if (api) { | 1037 | environmentId: activeThreadRef.environmentId, |
| 1015 | void api.orchestration | 1038 | input: { |
| 1016 | .dispatchCommand({ | 1039 | threadId: activeThreadRef.threadId, |
| 1017 | type: "thread.meta.update", | 1040 | branch, |
| 1018 | commandId: newCommandId(), | 1041 | worktreePath, |
| 1019 | threadId: activeThreadRef.threadId, | 1042 | }, |
| 1020 | branch, | 1043 | }); |
| 1021 | worktreePath, | ||
| 1022 | }) | ||
| 1023 | .catch(() => undefined); | ||
| 1024 | } | ||
| 1025 | 1044 | ||
| 1026 | setThreadBranch(activeThreadRef, branch, worktreePath); | ||
| 1027 | return; | 1045 | return; |
| 1028 | } | 1046 | } |
| 1029 | 1047 | ||
| ... | @@ -1042,7 +1060,7 @@ export default function GitActionsControl({ | ... | @@ -1042,7 +1060,7 @@ export default function GitActionsControl({ |
| 1042 | activeThreadRef, | 1060 | activeThreadRef, |
| 1043 | draftId, | 1061 | draftId, |
| 1044 | setDraftThreadContext, | 1062 | setDraftThreadContext, |
| 1045 | setThreadBranch, | 1063 | updateThreadMetadata, |
| 1046 | ], | 1064 | ], |
| 1047 | ); | 1065 | ); |
| 1048 | 1066 | ||
| ... | @@ -1058,13 +1076,18 @@ export default function GitActionsControl({ | ... | @@ -1058,13 +1076,18 @@ export default function GitActionsControl({ |
| 1058 | [persistThreadBranchSync], | 1076 | [persistThreadBranchSync], |
| 1059 | ); | 1077 | ); |
| 1060 | 1078 | ||
| 1061 | const vcsStatusTarget = useMemo( | 1079 | const gitStatusQuery = useEnvironmentQuery( |
| 1062 | () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), | 1080 | activeEnvironmentId !== null && gitCwd !== null |
| 1063 | [activeEnvironmentId, gitCwd], | 1081 | ? vcsEnvironment.status({ |
| 1082 | environmentId: activeEnvironmentId, | ||
| 1083 | input: { cwd: gitCwd }, | ||
| 1084 | }) | ||
| 1085 | : null, | ||
| 1064 | ); | 1086 | ); |
| 1065 | const gitStatusQuery = useVcsStatus(vcsStatusTarget); | 1087 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { |
| 1066 | const { error: gitStatusError } = gitStatusQuery; | 1088 | reportFailure: false, |
| 1067 | const gitStatus = getVcsStatusDataForTarget(gitStatusQuery, vcsStatusTarget); | 1089 | }); |
| 1090 | const { data: gitStatus, error: gitStatusError } = gitStatusQuery; | ||
| 1068 | const sourceControlPresentation = useMemo( | 1091 | const sourceControlPresentation = useMemo( |
| 1069 | () => getSourceControlPresentation(gitStatus?.sourceControlProvider), | 1092 | () => getSourceControlPresentation(gitStatus?.sourceControlProvider), |
| 1070 | [gitStatus?.sourceControlProvider], | 1093 | [gitStatus?.sourceControlProvider], |
| ... | @@ -1166,9 +1189,7 @@ export default function GitActionsControl({ | ... | @@ -1166,9 +1189,7 @@ export default function GitActionsControl({ |
| 1166 | } | 1189 | } |
| 1167 | refreshTimeout = window.setTimeout(() => { | 1190 | refreshTimeout = window.setTimeout(() => { |
| 1168 | refreshTimeout = null; | 1191 | refreshTimeout = null; |
| 1169 | void refreshVcsStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( | 1192 | requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); |
| 1170 | () => undefined, | ||
| 1171 | ); | ||
| 1172 | }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); | 1193 | }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); |
| 1173 | }; | 1194 | }; |
| 1174 | const handleVisibilityChange = () => { | 1195 | const handleVisibilityChange = () => { |
| ... | @@ -1187,7 +1208,7 @@ export default function GitActionsControl({ | ... | @@ -1187,7 +1208,7 @@ export default function GitActionsControl({ |
| 1187 | window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); | 1208 | window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); |
| 1188 | document.removeEventListener("visibilitychange", handleVisibilityChange); | 1209 | document.removeEventListener("visibilitychange", handleVisibilityChange); |
| 1189 | }; | 1210 | }; |
| 1190 | }, [activeEnvironmentId, gitCwd]); | 1211 | }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); |
| 1191 | 1212 | ||
| 1192 | const openExistingPr = useCallback(async () => { | 1213 | const openExistingPr = useCallback(async () => { |
| 1193 | const api = readLocalApi(); | 1214 | const api = readLocalApi(); |
| ... | @@ -1356,7 +1377,7 @@ export default function GitActionsControl({ | ... | @@ -1356,7 +1377,7 @@ export default function GitActionsControl({ |
| 1356 | // elapsed description visible until the final success state renders. | 1377 | // elapsed description visible until the final success state renders. |
| 1357 | return; | 1378 | return; |
| 1358 | case "action_failed": | 1379 | case "action_failed": |
| 1359 | // Let the rejected mutation publish the error toast to avoid a | 1380 | // Let the settled mutation publish the error toast to avoid a |
| 1360 | // transient intermediate state before the final failure message. | 1381 | // transient intermediate state before the final failure message. |
| 1361 | return; | 1382 | return; |
| 1362 | } | 1383 | } |
| ... | @@ -1364,7 +1385,7 @@ export default function GitActionsControl({ | ... | @@ -1364,7 +1385,7 @@ export default function GitActionsControl({ |
| 1364 | updateActiveProgressToast(); | 1385 | updateActiveProgressToast(); |
| 1365 | }; | 1386 | }; |
| 1366 | 1387 | ||
| 1367 | const promise = runImmediateGitAction.run({ | 1388 | const result = await runImmediateGitAction.run({ |
| 1368 | actionId, | 1389 | actionId, |
| 1369 | action, | 1390 | action, |
| 1370 | ...(commitMessage ? { commitMessage } : {}), | 1391 | ...(commitMessage ? { commitMessage } : {}), |
| ... | @@ -1373,78 +1394,84 @@ export default function GitActionsControl({ | ... | @@ -1373,78 +1394,84 @@ export default function GitActionsControl({ |
| 1373 | onProgress: applyProgressEvent, | 1394 | onProgress: applyProgressEvent, |
| 1374 | }); | 1395 | }); |
| 1375 | 1396 | ||
| 1376 | try { | 1397 | activeGitActionProgressRef.current = null; |
| 1377 | const result = await promise; | 1398 | if (result._tag === "Failure") { |
| 1378 | activeGitActionProgressRef.current = null; | 1399 | if (isAtomCommandInterrupted(result)) { |
| 1379 | syncThreadBranchAfterGitAction(result); | ||
| 1380 | const closeResultToast = () => { | ||
| 1381 | toastManager.close(resolvedProgressToastId); | 1400 | toastManager.close(resolvedProgressToastId); |
| 1382 | }; | 1401 | return; |
| 1383 | |||
| 1384 | const toastCta = result.toast.cta; | ||
| 1385 | let toastActionProps: { | ||
| 1386 | children: string; | ||
| 1387 | onClick: () => void; | ||
| 1388 | } | null = null; | ||
| 1389 | if (toastCta.kind === "run_action") { | ||
| 1390 | toastActionProps = { | ||
| 1391 | children: toastCta.label, | ||
| 1392 | onClick: () => { | ||
| 1393 | closeResultToast(); | ||
| 1394 | void runGitActionWithToast({ | ||
| 1395 | action: toastCta.action.kind, | ||
| 1396 | }); | ||
| 1397 | }, | ||
| 1398 | }; | ||
| 1399 | } else if (toastCta.kind === "open_pr") { | ||
| 1400 | toastActionProps = { | ||
| 1401 | children: toastCta.label, | ||
| 1402 | onClick: () => { | ||
| 1403 | const api = readLocalApi(); | ||
| 1404 | if (!api) return; | ||
| 1405 | closeResultToast(); | ||
| 1406 | void api.shell.openExternal(toastCta.url); | ||
| 1407 | }, | ||
| 1408 | }; | ||
| 1409 | } | 1402 | } |
| 1410 | 1403 | ||
| 1411 | const successToastData = { | 1404 | const error = squashAtomCommandFailure(result); |
| 1412 | ...scopedToastData, | ||
| 1413 | dismissAfterVisibleMs: 10_000, | ||
| 1414 | }; | ||
| 1415 | |||
| 1416 | if (toastActionProps) { | ||
| 1417 | toastManager.update( | ||
| 1418 | resolvedProgressToastId, | ||
| 1419 | stackedThreadToast({ | ||
| 1420 | type: "success", | ||
| 1421 | title: result.toast.title, | ||
| 1422 | description: result.toast.description, | ||
| 1423 | timeout: 0, | ||
| 1424 | actionProps: toastActionProps, | ||
| 1425 | data: successToastData, | ||
| 1426 | }), | ||
| 1427 | ); | ||
| 1428 | } else { | ||
| 1429 | toastManager.update(resolvedProgressToastId, { | ||
| 1430 | type: "success", | ||
| 1431 | title: result.toast.title, | ||
| 1432 | description: result.toast.description, | ||
| 1433 | timeout: 0, | ||
| 1434 | data: successToastData, | ||
| 1435 | }); | ||
| 1436 | } | ||
| 1437 | } catch (err) { | ||
| 1438 | activeGitActionProgressRef.current = null; | ||
| 1439 | toastManager.update( | 1405 | toastManager.update( |
| 1440 | resolvedProgressToastId, | 1406 | resolvedProgressToastId, |
| 1441 | stackedThreadToast({ | 1407 | stackedThreadToast({ |
| 1442 | type: "error", | 1408 | type: "error", |
| 1443 | title: "Action failed", | 1409 | title: "Action failed", |
| 1444 | description: err instanceof Error ? err.message : "An error occurred.", | 1410 | description: error instanceof Error ? error.message : "An error occurred.", |
| 1445 | ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), | 1411 | ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), |
| 1446 | }), | 1412 | }), |
| 1447 | ); | 1413 | ); |
| 1414 | return; | ||
| 1415 | } | ||
| 1416 | |||
| 1417 | const actionResult = result.value; | ||
| 1418 | syncThreadBranchAfterGitAction(actionResult); | ||
| 1419 | const closeResultToast = () => { | ||
| 1420 | toastManager.close(resolvedProgressToastId); | ||
| 1421 | }; | ||
| 1422 | |||
| 1423 | const toastCta = actionResult.toast.cta; | ||
| 1424 | let toastActionProps: { | ||
| 1425 | children: string; | ||
| 1426 | onClick: () => void; | ||
| 1427 | } | null = null; | ||
| 1428 | if (toastCta.kind === "run_action") { | ||
| 1429 | toastActionProps = { | ||
| 1430 | children: toastCta.label, | ||
| 1431 | onClick: () => { | ||
| 1432 | closeResultToast(); | ||
| 1433 | void runGitActionWithToast({ | ||
| 1434 | action: toastCta.action.kind, | ||
| 1435 | }); | ||
| 1436 | }, | ||
| 1437 | }; | ||
| 1438 | } else if (toastCta.kind === "open_pr") { | ||
| 1439 | toastActionProps = { | ||
| 1440 | children: toastCta.label, | ||
| 1441 | onClick: () => { | ||
| 1442 | const api = readLocalApi(); | ||
| 1443 | if (!api) return; | ||
| 1444 | closeResultToast(); | ||
| 1445 | void api.shell.openExternal(toastCta.url); | ||
| 1446 | }, | ||
| 1447 | }; | ||
| 1448 | } | ||
| 1449 | |||
| 1450 | const successToastData = { | ||
| 1451 | ...scopedToastData, | ||
| 1452 | dismissAfterVisibleMs: 10_000, | ||
| 1453 | }; | ||
| 1454 | |||
| 1455 | if (toastActionProps) { | ||
| 1456 | toastManager.update( | ||
| 1457 | resolvedProgressToastId, | ||
| 1458 | stackedThreadToast({ | ||
| 1459 | type: "success", | ||
| 1460 | title: actionResult.toast.title, | ||
| 1461 | description: actionResult.toast.description, | ||
| 1462 | timeout: 0, | ||
| 1463 | actionProps: toastActionProps, | ||
| 1464 | data: successToastData, | ||
| 1465 | }), | ||
| 1466 | ); | ||
| 1467 | } else { | ||
| 1468 | toastManager.update(resolvedProgressToastId, { | ||
| 1469 | type: "success", | ||
| 1470 | title: actionResult.toast.title, | ||
| 1471 | description: actionResult.toast.description, | ||
| 1472 | timeout: 0, | ||
| 1473 | data: successToastData, | ||
| 1474 | }); | ||
| 1448 | } | 1475 | } |
| 1449 | }, | 1476 | }, |
| 1450 | ); | 1477 | ); |
| ... | @@ -1504,27 +1531,43 @@ export default function GitActionsControl({ | ... | @@ -1504,27 +1531,43 @@ export default function GitActionsControl({ |
| 1504 | return; | 1531 | return; |
| 1505 | } | 1532 | } |
| 1506 | if (quickAction.kind === "run_pull") { | 1533 | if (quickAction.kind === "run_pull") { |
| 1507 | const promise = pullAction.run(); | 1534 | const toastId = toastManager.add({ |
| 1508 | void toastManager.promise<Awaited<ReturnType<typeof pullAction.run>>, ThreadToastData>( | 1535 | type: "loading", |
| 1509 | promise, | 1536 | title: "Pulling...", |
| 1510 | { | 1537 | timeout: 0, |
| 1511 | loading: { title: "Pulling...", data: threadToastData }, | 1538 | data: threadToastData, |
| 1512 | success: (result) => ({ | 1539 | }); |
| 1513 | title: result.status === "pulled" ? "Pulled" : "Already up to date", | 1540 | void (async () => { |
| 1514 | description: | 1541 | const result = await pullAction.run(); |
| 1515 | result.status === "pulled" | 1542 | if (result._tag === "Failure") { |
| 1516 | ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` | 1543 | if (isAtomCommandInterrupted(result)) { |
| 1517 | : `${result.refName} is already synchronized.`, | 1544 | toastManager.close(toastId); |
| 1518 | data: threadToastData, | 1545 | return; |
| 1519 | }), | 1546 | } |
| 1520 | error: (err) => ({ | 1547 | const error = squashAtomCommandFailure(result); |
| 1521 | title: "Pull failed", | 1548 | toastManager.update( |
| 1522 | description: err instanceof Error ? err.message : "An error occurred.", | 1549 | toastId, |
| 1523 | data: threadToastData, | 1550 | stackedThreadToast({ |
| 1524 | }), | 1551 | type: "error", |
| 1525 | }, | 1552 | title: "Pull failed", |
| 1526 | ); | 1553 | description: error instanceof Error ? error.message : "An error occurred.", |
| 1527 | void promise.catch(() => undefined); | 1554 | ...(threadToastData !== undefined ? { data: threadToastData } : {}), |
| 1555 | }), | ||
| 1556 | ); | ||
| 1557 | return; | ||
| 1558 | } | ||
| 1559 | |||
| 1560 | const pullResult = result.value; | ||
| 1561 | toastManager.update(toastId, { | ||
| 1562 | type: "success", | ||
| 1563 | title: pullResult.status === "pulled" ? "Pulled" : "Already up to date", | ||
| 1564 | description: | ||
| 1565 | pullResult.status === "pulled" | ||
| 1566 | ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` | ||
| 1567 | : `${pullResult.refName} is already synchronized.`, | ||
| 1568 | data: threadToastData, | ||
| 1569 | }); | ||
| 1570 | })(); | ||
| 1528 | return; | 1571 | return; |
| 1529 | } | 1572 | } |
| 1530 | if (quickAction.kind === "show_hint") { | 1573 | if (quickAction.kind === "show_hint") { |
| ... | @@ -1576,8 +1619,7 @@ export default function GitActionsControl({ | ... | @@ -1576,8 +1619,7 @@ export default function GitActionsControl({ |
| 1576 | 1619 | ||
| 1577 | const openChangedFileInEditor = useCallback( | 1620 | const openChangedFileInEditor = useCallback( |
| 1578 | (filePath: string) => { | 1621 | (filePath: string) => { |
| 1579 | const api = readLocalApi(); | 1622 | if (!gitCwd) { |
| 1580 | if (!api || !gitCwd) { | ||
| 1581 | toastManager.add({ | 1623 | toastManager.add({ |
| 1582 | type: "error", | 1624 | type: "error", |
| 1583 | title: "Editor opening is unavailable.", | 1625 | title: "Editor opening is unavailable.", |
| ... | @@ -1586,7 +1628,12 @@ export default function GitActionsControl({ | ... | @@ -1586,7 +1628,12 @@ export default function GitActionsControl({ |
| 1586 | return; | 1628 | return; |
| 1587 | } | 1629 | } |
| 1588 | const target = resolvePathLinkTarget(filePath, gitCwd); | 1630 | const target = resolvePathLinkTarget(filePath, gitCwd); |
| 1589 | void openInPreferredEditor(api, target).catch((error) => { | 1631 | void (async () => { |
| 1632 | const result = await openInPreferredEditor(target); | ||
| 1633 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | ||
| 1634 | return; | ||
| 1635 | } | ||
| 1636 | const error = squashAtomCommandFailure(result); | ||
| 1590 | toastManager.add( | 1637 | toastManager.add( |
| 1591 | stackedThreadToast({ | 1638 | stackedThreadToast({ |
| 1592 | type: "error", | 1639 | type: "error", |
| ... | @@ -1595,9 +1642,9 @@ export default function GitActionsControl({ | ... | @@ -1595,9 +1642,9 @@ export default function GitActionsControl({ |
| 1595 | ...(threadToastData !== undefined ? { data: threadToastData } : {}), | 1642 | ...(threadToastData !== undefined ? { data: threadToastData } : {}), |
| 1596 | }), | 1643 | }), |
| 1597 | ); | 1644 | ); |
| 1598 | }); | 1645 | })(); |
| 1599 | }, | 1646 | }, |
| 1600 | [gitCwd, threadToastData], | 1647 | [gitCwd, openInPreferredEditor, threadToastData], |
| 1601 | ); | 1648 | ); |
| 1602 | 1649 | ||
| 1603 | const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; | 1650 | const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; |
| ... | @@ -1612,7 +1659,21 @@ export default function GitActionsControl({ | ... | @@ -1612,7 +1659,21 @@ export default function GitActionsControl({ |
| 1612 | size="xs" | 1659 | size="xs" |
| 1613 | disabled={initAction.isPending} | 1660 | disabled={initAction.isPending} |
| 1614 | onClick={() => { | 1661 | onClick={() => { |
| 1615 | void initAction.run(); | 1662 | void (async () => { |
| 1663 | const result = await initAction.run(); | ||
| 1664 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | ||
| 1665 | return; | ||
| 1666 | } | ||
| 1667 | const error = squashAtomCommandFailure(result); | ||
| 1668 | toastManager.add( | ||
| 1669 | stackedThreadToast({ | ||
| 1670 | type: "error", | ||
| 1671 | title: "Git initialization failed", | ||
| 1672 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1673 | ...(threadToastData !== undefined ? { data: threadToastData } : {}), | ||
| 1674 | }), | ||
| 1675 | ); | ||
| 1676 | })(); | ||
| 1616 | }} | 1677 | }} |
| 1617 | > | 1678 | > |
| 1618 | <GitBranchPlusIcon className="size-3.5" aria-hidden /> | 1679 | <GitBranchPlusIcon className="size-3.5" aria-hidden /> |
| ... | @@ -1664,10 +1725,7 @@ export default function GitActionsControl({ | ... | @@ -1664,10 +1725,7 @@ export default function GitActionsControl({ |
| 1664 | <Menu | 1725 | <Menu |
| 1665 | onOpenChange={(open) => { | 1726 | onOpenChange={(open) => { |
| 1666 | if (open) { | 1727 | if (open) { |
| 1667 | void refreshVcsStatus({ | 1728 | requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); |
| 1668 | environmentId: activeEnvironmentId, | ||
| 1669 | cwd: gitCwd, | ||
| 1670 | }).catch(() => undefined); | ||
| 1671 | } | 1729 | } |
| 1672 | }} | 1730 | }} |
| 1673 | > | 1731 | > |
| ... | @@ -1748,7 +1806,7 @@ export default function GitActionsControl({ | ... | @@ -1748,7 +1806,7 @@ export default function GitActionsControl({ |
| 1748 | </p> | 1806 | </p> |
| 1749 | )} | 1807 | )} |
| 1750 | {gitStatusError && ( | 1808 | {gitStatusError && ( |
| 1751 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p> | 1809 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError}</p> |
| 1752 | )} | 1810 | )} |
| 1753 | </MenuPopup> | 1811 | </MenuPopup> |
| 1754 | </Menu> | 1812 | </Menu> |
apps/web/src/components/KeybindingsToast.browser.tsx deleted-636| ... | @@ -1,636 +0,0 @@ | ||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | DEFAULT_SERVER_SETTINGS, | ||
| 5 | EnvironmentId, | ||
| 6 | ORCHESTRATION_WS_METHODS, | ||
| 7 | type MessageId, | ||
| 8 | type OrchestrationReadModel, | ||
| 9 | type ProjectId, | ||
| 10 | ProviderDriverKind, | ||
| 11 | ProviderInstanceId, | ||
| 12 | type ServerConfig, | ||
| 13 | type ServerLifecycleWelcomePayload, | ||
| 14 | ServerConfig as ServerConfigSchema, | ||
| 15 | ServerSettings, | ||
| 16 | type ThreadId, | ||
| 17 | WS_METHODS, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 19 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; | ||
| 20 | import { ws, http, HttpResponse } from "msw"; | ||
| 21 | import { setupWorker } from "msw/browser"; | ||
| 22 | import * as Schema from "effect/Schema"; | ||
| 23 | import { | ||
| 24 | afterAll, | ||
| 25 | afterEach, | ||
| 26 | beforeAll, | ||
| 27 | beforeEach, | ||
| 28 | describe, | ||
| 29 | expect, | ||
| 30 | it, | ||
| 31 | vi, | ||
| 32 | } from "vite-plus/test"; | ||
| 33 | import { render } from "vitest-browser-react"; | ||
| 34 | |||
| 35 | import { useComposerDraftStore } from "../composerDraftStore"; | ||
| 36 | import { __resetLocalApiForTests } from "../localApi"; | ||
| 37 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | ||
| 38 | import { getServerConfig, getServerConfigUpdatedNotification } from "../rpc/serverState"; | ||
| 39 | import { getWsConnectionStatus } from "../rpc/wsConnectionState"; | ||
| 40 | import { getRouter } from "../router"; | ||
| 41 | import { useStore } from "../store"; | ||
| 42 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; | ||
| 43 | import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; | ||
| 44 | |||
| 45 | vi.mock("../lib/vcsStatusState", () => { | ||
| 46 | const status = { | ||
| 47 | data: { | ||
| 48 | isRepo: true, | ||
| 49 | sourceControlProvider: { | ||
| 50 | kind: "github", | ||
| 51 | name: "GitHub", | ||
| 52 | baseUrl: "https://github.com", | ||
| 53 | }, | ||
| 54 | hasPrimaryRemote: true, | ||
| 55 | isDefaultRef: true, | ||
| 56 | refName: "main", | ||
| 57 | hasWorkingTreeChanges: false, | ||
| 58 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 59 | hasUpstream: true, | ||
| 60 | aheadCount: 0, | ||
| 61 | behindCount: 0, | ||
| 62 | pr: null, | ||
| 63 | }, | ||
| 64 | error: null, | ||
| 65 | cause: null, | ||
| 66 | isPending: false, | ||
| 67 | }; | ||
| 68 | |||
| 69 | return { | ||
| 70 | getVcsStatusDataForTarget: (state: typeof status) => state.data, | ||
| 71 | getVcsStatusSnapshot: () => status, | ||
| 72 | useVcsStatus: () => status, | ||
| 73 | useVcsStatuses: () => new Map(), | ||
| 74 | refreshVcsStatus: () => Promise.resolve(null), | ||
| 75 | resetVcsStatusStateForTests: () => undefined, | ||
| 76 | }; | ||
| 77 | }); | ||
| 78 | |||
| 79 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; | ||
| 80 | const PROJECT_ID = "project-1" as ProjectId; | ||
| 81 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | ||
| 82 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; | ||
| 83 | |||
| 84 | interface TestFixture { | ||
| 85 | snapshot: OrchestrationReadModel; | ||
| 86 | serverConfig: ServerConfig; | ||
| 87 | welcome: ServerLifecycleWelcomePayload; | ||
| 88 | } | ||
| 89 | |||
| 90 | let fixture: TestFixture; | ||
| 91 | const rpcHarness = new BrowserWsRpcHarness(); | ||
| 92 | const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); | ||
| 93 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | ||
| 94 | |||
| 95 | const wsLink = ws.link(/ws(s)?:\/\/.*/); | ||
| 96 | |||
| 97 | function createBaseServerConfig(): ServerConfig { | ||
| 98 | return { | ||
| 99 | environment: { | ||
| 100 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 101 | label: "Local environment", | ||
| 102 | platform: { os: "darwin" as const, arch: "arm64" as const }, | ||
| 103 | serverVersion: "0.0.0-test", | ||
| 104 | capabilities: { repositoryIdentity: true }, | ||
| 105 | }, | ||
| 106 | auth: { | ||
| 107 | policy: "loopback-browser", | ||
| 108 | bootstrapMethods: ["one-time-token"], | ||
| 109 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | ||
| 110 | sessionCookieName: "t3_session", | ||
| 111 | }, | ||
| 112 | cwd: "/repo/project", | ||
| 113 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | ||
| 114 | keybindings: [], | ||
| 115 | issues: [], | ||
| 116 | providers: [ | ||
| 117 | { | ||
| 118 | driver: ProviderDriverKind.make("codex"), | ||
| 119 | instanceId: ProviderInstanceId.make("codex"), | ||
| 120 | enabled: true, | ||
| 121 | installed: true, | ||
| 122 | version: "0.116.0", | ||
| 123 | status: "ready", | ||
| 124 | auth: { status: "authenticated" }, | ||
| 125 | checkedAt: NOW_ISO, | ||
| 126 | models: [], | ||
| 127 | slashCommands: [], | ||
| 128 | skills: [], | ||
| 129 | }, | ||
| 130 | ], | ||
| 131 | availableEditors: [], | ||
| 132 | observability: { | ||
| 133 | logsDirectoryPath: "/repo/project/.t3/logs", | ||
| 134 | localTracingEnabled: true, | ||
| 135 | otlpTracesEnabled: false, | ||
| 136 | otlpMetricsEnabled: false, | ||
| 137 | }, | ||
| 138 | settings: { | ||
| 139 | ...DEFAULT_SERVER_SETTINGS, | ||
| 140 | enableAssistantStreaming: false, | ||
| 141 | defaultThreadEnvMode: "local" as const, | ||
| 142 | textGenerationModelSelection: { | ||
| 143 | instanceId: ProviderInstanceId.make("codex"), | ||
| 144 | model: "gpt-5.4-mini", | ||
| 145 | }, | ||
| 146 | providers: { | ||
| 147 | codex: { | ||
| 148 | enabled: true, | ||
| 149 | binaryPath: "", | ||
| 150 | homePath: "", | ||
| 151 | shadowHomePath: "", | ||
| 152 | customModels: [], | ||
| 153 | }, | ||
| 154 | claudeAgent: { | ||
| 155 | enabled: true, | ||
| 156 | binaryPath: "", | ||
| 157 | homePath: "", | ||
| 158 | customModels: [], | ||
| 159 | launchArgs: "", | ||
| 160 | }, | ||
| 161 | cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, | ||
| 162 | grok: { enabled: true, binaryPath: "", customModels: [] }, | ||
| 163 | opencode: { | ||
| 164 | enabled: true, | ||
| 165 | binaryPath: "", | ||
| 166 | serverUrl: "", | ||
| 167 | serverPassword: "", | ||
| 168 | customModels: [], | ||
| 169 | }, | ||
| 170 | }, | ||
| 171 | }, | ||
| 172 | }; | ||
| 173 | } | ||
| 174 | |||
| 175 | function createMinimalSnapshot(): OrchestrationReadModel { | ||
| 176 | return { | ||
| 177 | snapshotSequence: 1, | ||
| 178 | projects: [ | ||
| 179 | { | ||
| 180 | id: PROJECT_ID, | ||
| 181 | title: "Project", | ||
| 182 | workspaceRoot: "/repo/project", | ||
| 183 | defaultModelSelection: { | ||
| 184 | instanceId: ProviderInstanceId.make("codex"), | ||
| 185 | model: "gpt-5", | ||
| 186 | }, | ||
| 187 | scripts: [], | ||
| 188 | createdAt: NOW_ISO, | ||
| 189 | updatedAt: NOW_ISO, | ||
| 190 | deletedAt: null, | ||
| 191 | }, | ||
| 192 | ], | ||
| 193 | threads: [ | ||
| 194 | { | ||
| 195 | id: THREAD_ID, | ||
| 196 | projectId: PROJECT_ID, | ||
| 197 | title: "Test thread", | ||
| 198 | modelSelection: { | ||
| 199 | instanceId: ProviderInstanceId.make("codex"), | ||
| 200 | model: "gpt-5", | ||
| 201 | }, | ||
| 202 | interactionMode: "default", | ||
| 203 | runtimeMode: "full-access", | ||
| 204 | branch: "main", | ||
| 205 | worktreePath: null, | ||
| 206 | latestTurn: null, | ||
| 207 | createdAt: NOW_ISO, | ||
| 208 | updatedAt: NOW_ISO, | ||
| 209 | archivedAt: null, | ||
| 210 | deletedAt: null, | ||
| 211 | messages: [ | ||
| 212 | { | ||
| 213 | id: "msg-1" as MessageId, | ||
| 214 | role: "user", | ||
| 215 | text: "hello", | ||
| 216 | turnId: null, | ||
| 217 | streaming: false, | ||
| 218 | createdAt: NOW_ISO, | ||
| 219 | updatedAt: NOW_ISO, | ||
| 220 | }, | ||
| 221 | ], | ||
| 222 | activities: [], | ||
| 223 | proposedPlans: [], | ||
| 224 | checkpoints: [], | ||
| 225 | session: { | ||
| 226 | threadId: THREAD_ID, | ||
| 227 | status: "ready", | ||
| 228 | providerName: "codex", | ||
| 229 | runtimeMode: "full-access", | ||
| 230 | activeTurnId: null, | ||
| 231 | lastError: null, | ||
| 232 | updatedAt: NOW_ISO, | ||
| 233 | }, | ||
| 234 | }, | ||
| 235 | ], | ||
| 236 | updatedAt: NOW_ISO, | ||
| 237 | }; | ||
| 238 | } | ||
| 239 | |||
| 240 | function toShellSnapshot(snapshot: OrchestrationReadModel) { | ||
| 241 | return { | ||
| 242 | snapshotSequence: snapshot.snapshotSequence, | ||
| 243 | projects: snapshot.projects.map((project) => ({ | ||
| 244 | id: project.id, | ||
| 245 | title: project.title, | ||
| 246 | workspaceRoot: project.workspaceRoot, | ||
| 247 | repositoryIdentity: project.repositoryIdentity ?? null, | ||
| 248 | defaultModelSelection: project.defaultModelSelection, | ||
| 249 | scripts: project.scripts, | ||
| 250 | createdAt: project.createdAt, | ||
| 251 | updatedAt: project.updatedAt, | ||
| 252 | })), | ||
| 253 | threads: snapshot.threads.map((thread) => ({ | ||
| 254 | id: thread.id, | ||
| 255 | projectId: thread.projectId, | ||
| 256 | title: thread.title, | ||
| 257 | modelSelection: thread.modelSelection, | ||
| 258 | runtimeMode: thread.runtimeMode, | ||
| 259 | interactionMode: thread.interactionMode, | ||
| 260 | branch: thread.branch, | ||
| 261 | worktreePath: thread.worktreePath, | ||
| 262 | latestTurn: thread.latestTurn, | ||
| 263 | createdAt: thread.createdAt, | ||
| 264 | updatedAt: thread.updatedAt, | ||
| 265 | archivedAt: thread.archivedAt, | ||
| 266 | session: thread.session, | ||
| 267 | latestUserMessageAt: | ||
| 268 | thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, | ||
| 269 | hasPendingApprovals: false, | ||
| 270 | hasPendingUserInput: false, | ||
| 271 | hasActionableProposedPlan: false, | ||
| 272 | })), | ||
| 273 | updatedAt: snapshot.updatedAt, | ||
| 274 | }; | ||
| 275 | } | ||
| 276 | |||
| 277 | function buildFixture(): TestFixture { | ||
| 278 | return { | ||
| 279 | snapshot: createMinimalSnapshot(), | ||
| 280 | serverConfig: createBaseServerConfig(), | ||
| 281 | welcome: { | ||
| 282 | environment: { | ||
| 283 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 284 | label: "Local environment", | ||
| 285 | platform: { os: "darwin" as const, arch: "arm64" as const }, | ||
| 286 | serverVersion: "0.0.0-test", | ||
| 287 | capabilities: { repositoryIdentity: true }, | ||
| 288 | }, | ||
| 289 | cwd: "/repo/project", | ||
| 290 | projectName: "Project", | ||
| 291 | bootstrapProjectId: PROJECT_ID, | ||
| 292 | bootstrapThreadId: THREAD_ID, | ||
| 293 | }, | ||
| 294 | }; | ||
| 295 | } | ||
| 296 | |||
| 297 | function resolveWsRpc(tag: string): unknown { | ||
| 298 | if (tag === WS_METHODS.serverGetConfig) { | ||
| 299 | return encodeServerConfig(fixture.serverConfig); | ||
| 300 | } | ||
| 301 | if (tag === WS_METHODS.vcsListRefs) { | ||
| 302 | return { | ||
| 303 | isRepo: true, | ||
| 304 | hasPrimaryRemote: true, | ||
| 305 | nextCursor: null, | ||
| 306 | totalCount: 1, | ||
| 307 | refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], | ||
| 308 | }; | ||
| 309 | } | ||
| 310 | if (tag === WS_METHODS.projectsSearchEntries) { | ||
| 311 | return { entries: [], truncated: false }; | ||
| 312 | } | ||
| 313 | return {}; | ||
| 314 | } | ||
| 315 | |||
| 316 | const worker = setupWorker( | ||
| 317 | wsLink.addEventListener("connection", ({ client }) => { | ||
| 318 | void rpcHarness.connect(client); | ||
| 319 | client.addEventListener("message", (event) => { | ||
| 320 | const rawData = event.data; | ||
| 321 | if (typeof rawData !== "string") return; | ||
| 322 | void rpcHarness.onMessage(rawData); | ||
| 323 | }); | ||
| 324 | }), | ||
| 325 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), | ||
| 326 | http.get("*/api/assets/*", () => new HttpResponse(null, { status: 204 })), | ||
| 327 | ); | ||
| 328 | |||
| 329 | function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { | ||
| 330 | rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { | ||
| 331 | version: 1, | ||
| 332 | type: "keybindingsUpdated", | ||
| 333 | payload: { keybindings: fixture.serverConfig.keybindings, issues }, | ||
| 334 | }); | ||
| 335 | } | ||
| 336 | |||
| 337 | function queryToastTitles(): string[] { | ||
| 338 | return Array.from(document.querySelectorAll('[data-slot="toast-title"]')).map( | ||
| 339 | (el) => el.textContent ?? "", | ||
| 340 | ); | ||
| 341 | } | ||
| 342 | |||
| 343 | async function waitForElement<T extends Element>( | ||
| 344 | query: () => T | null, | ||
| 345 | errorMessage: string, | ||
| 346 | ): Promise<T> { | ||
| 347 | let element: T | null = null; | ||
| 348 | await vi.waitFor( | ||
| 349 | () => { | ||
| 350 | element = query(); | ||
| 351 | expect(element, errorMessage).toBeTruthy(); | ||
| 352 | }, | ||
| 353 | { timeout: 8_000, interval: 16 }, | ||
| 354 | ); | ||
| 355 | return element!; | ||
| 356 | } | ||
| 357 | |||
| 358 | async function waitForComposerEditor(): Promise<HTMLElement> { | ||
| 359 | return waitForElement( | ||
| 360 | () => document.querySelector<HTMLElement>('[data-testid="composer-editor"]'), | ||
| 361 | "App should render composer editor", | ||
| 362 | ); | ||
| 363 | } | ||
| 364 | |||
| 365 | async function waitForToastViewport(): Promise<HTMLElement> { | ||
| 366 | return waitForElement( | ||
| 367 | () => document.querySelector<HTMLElement>('[data-slot="toast-viewport"]'), | ||
| 368 | "App should render the toast viewport before server config updates are pushed", | ||
| 369 | ); | ||
| 370 | } | ||
| 371 | |||
| 372 | async function waitForWsConnection(): Promise<void> { | ||
| 373 | await vi.waitFor( | ||
| 374 | () => { | ||
| 375 | expect(getWsConnectionStatus().phase).toBe("connected"); | ||
| 376 | }, | ||
| 377 | { timeout: 8_000, interval: 16 }, | ||
| 378 | ); | ||
| 379 | } | ||
| 380 | |||
| 381 | async function waitForToast(title: string, count = 1): Promise<void> { | ||
| 382 | await vi.waitFor( | ||
| 383 | () => { | ||
| 384 | const matches = queryToastTitles().filter((t) => t === title); | ||
| 385 | expect(matches.length, `Expected ${count} "${title}" toast(s)`).toBeGreaterThanOrEqual(count); | ||
| 386 | }, | ||
| 387 | { timeout: 4_000, interval: 16 }, | ||
| 388 | ); | ||
| 389 | } | ||
| 390 | |||
| 391 | async function waitForNoToast(title: string): Promise<void> { | ||
| 392 | await vi.waitFor( | ||
| 393 | () => { | ||
| 394 | expect(queryToastTitles().filter((t) => t === title)).toHaveLength(0); | ||
| 395 | }, | ||
| 396 | { timeout: 10_000, interval: 50 }, | ||
| 397 | ); | ||
| 398 | } | ||
| 399 | |||
| 400 | async function waitForNoToasts(): Promise<void> { | ||
| 401 | await vi.waitFor( | ||
| 402 | () => { | ||
| 403 | expect(queryToastTitles()).toHaveLength(0); | ||
| 404 | }, | ||
| 405 | { timeout: 8_000, interval: 16 }, | ||
| 406 | ); | ||
| 407 | } | ||
| 408 | |||
| 409 | async function waitForInitialWsSubscriptions(): Promise<void> { | ||
| 410 | await vi.waitFor( | ||
| 411 | () => { | ||
| 412 | expect( | ||
| 413 | rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), | ||
| 414 | ).toBe(true); | ||
| 415 | expect( | ||
| 416 | rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig), | ||
| 417 | ).toBe(true); | ||
| 418 | }, | ||
| 419 | { timeout: 8_000, interval: 16 }, | ||
| 420 | ); | ||
| 421 | } | ||
| 422 | |||
| 423 | async function waitForServerConfigSnapshot(): Promise<void> { | ||
| 424 | await vi.waitFor( | ||
| 425 | () => { | ||
| 426 | expect(getServerConfig()).not.toBeNull(); | ||
| 427 | }, | ||
| 428 | { timeout: 8_000, interval: 16 }, | ||
| 429 | ); | ||
| 430 | } | ||
| 431 | |||
| 432 | async function waitForServerConfigStreamReady(): Promise<void> { | ||
| 433 | const previousNotificationId = getServerConfigUpdatedNotification()?.id ?? 0; | ||
| 434 | for (let attempt = 0; attempt < 20; attempt += 1) { | ||
| 435 | rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { | ||
| 436 | version: 1, | ||
| 437 | type: "settingsUpdated", | ||
| 438 | payload: { settings: encodeServerSettings(fixture.serverConfig.settings) }, | ||
| 439 | }); | ||
| 440 | |||
| 441 | try { | ||
| 442 | await vi.waitFor( | ||
| 443 | () => { | ||
| 444 | const notification = getServerConfigUpdatedNotification(); | ||
| 445 | expect(notification?.id).toBeGreaterThan(previousNotificationId); | ||
| 446 | expect(notification?.source).toBe("settingsUpdated"); | ||
| 447 | }, | ||
| 448 | { timeout: 200, interval: 16 }, | ||
| 449 | ); | ||
| 450 | return; | ||
| 451 | } catch { | ||
| 452 | await new Promise((resolve) => setTimeout(resolve, 25)); | ||
| 453 | } | ||
| 454 | } | ||
| 455 | |||
| 456 | throw new Error("Timed out waiting for the server config stream to deliver updates."); | ||
| 457 | } | ||
| 458 | |||
| 459 | async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { | ||
| 460 | const host = document.createElement("div"); | ||
| 461 | host.style.position = "fixed"; | ||
| 462 | host.style.inset = "0"; | ||
| 463 | host.style.width = "100vw"; | ||
| 464 | host.style.height = "100vh"; | ||
| 465 | host.style.display = "grid"; | ||
| 466 | host.style.overflow = "hidden"; | ||
| 467 | document.body.append(host); | ||
| 468 | |||
| 469 | const router = getRouter( | ||
| 470 | createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), | ||
| 471 | ); | ||
| 472 | |||
| 473 | const screen = await render( | ||
| 474 | <AppAtomRegistryProvider> | ||
| 475 | <RouterProvider router={router} /> | ||
| 476 | </AppAtomRegistryProvider>, | ||
| 477 | { container: host }, | ||
| 478 | ); | ||
| 479 | await waitForComposerEditor(); | ||
| 480 | await waitForToastViewport(); | ||
| 481 | await waitForInitialWsSubscriptions(); | ||
| 482 | await waitForWsConnection(); | ||
| 483 | await waitForServerConfigSnapshot(); | ||
| 484 | await waitForServerConfigStreamReady(); | ||
| 485 | await waitForNoToasts(); | ||
| 486 | |||
| 487 | return { | ||
| 488 | cleanup: async () => { | ||
| 489 | await screen.unmount(); | ||
| 490 | host.remove(); | ||
| 491 | }, | ||
| 492 | }; | ||
| 493 | } | ||
| 494 | |||
| 495 | describe("Keybindings update toast", () => { | ||
| 496 | beforeAll(async () => { | ||
| 497 | fixture = buildFixture(); | ||
| 498 | await worker.start({ | ||
| 499 | onUnhandledRequest: "bypass", | ||
| 500 | quiet: true, | ||
| 501 | serviceWorker: { url: "/mockServiceWorker.js" }, | ||
| 502 | }); | ||
| 503 | }); | ||
| 504 | |||
| 505 | afterAll(async () => { | ||
| 506 | await rpcHarness.disconnect(); | ||
| 507 | await worker.stop(); | ||
| 508 | }); | ||
| 509 | |||
| 510 | beforeEach(async () => { | ||
| 511 | await rpcHarness.reset({ | ||
| 512 | resolveUnary: (request) => resolveWsRpc(request._tag), | ||
| 513 | getInitialStreamValues: (request) => { | ||
| 514 | if (request._tag === WS_METHODS.subscribeServerLifecycle) { | ||
| 515 | return [ | ||
| 516 | { | ||
| 517 | version: 1, | ||
| 518 | sequence: 1, | ||
| 519 | type: "welcome", | ||
| 520 | payload: fixture.welcome, | ||
| 521 | }, | ||
| 522 | ]; | ||
| 523 | } | ||
| 524 | if (request._tag === WS_METHODS.subscribeServerConfig) { | ||
| 525 | return [ | ||
| 526 | { | ||
| 527 | version: 1, | ||
| 528 | type: "snapshot", | ||
| 529 | config: encodeServerConfig(fixture.serverConfig), | ||
| 530 | }, | ||
| 531 | ]; | ||
| 532 | } | ||
| 533 | if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { | ||
| 534 | return [ | ||
| 535 | { | ||
| 536 | kind: "snapshot", | ||
| 537 | snapshot: toShellSnapshot(fixture.snapshot), | ||
| 538 | }, | ||
| 539 | ]; | ||
| 540 | } | ||
| 541 | if ( | ||
| 542 | request._tag === ORCHESTRATION_WS_METHODS.subscribeThread && | ||
| 543 | request.threadId === THREAD_ID | ||
| 544 | ) { | ||
| 545 | return [ | ||
| 546 | { | ||
| 547 | kind: "snapshot", | ||
| 548 | snapshot: { | ||
| 549 | snapshotSequence: fixture.snapshot.snapshotSequence, | ||
| 550 | thread: fixture.snapshot.threads[0], | ||
| 551 | }, | ||
| 552 | }, | ||
| 553 | ]; | ||
| 554 | } | ||
| 555 | return []; | ||
| 556 | }, | ||
| 557 | }); | ||
| 558 | await __resetLocalApiForTests(); | ||
| 559 | localStorage.clear(); | ||
| 560 | document.body.innerHTML = ""; | ||
| 561 | useComposerDraftStore.setState({ | ||
| 562 | draftsByThreadKey: {}, | ||
| 563 | draftThreadsByThreadKey: {}, | ||
| 564 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | ||
| 565 | }); | ||
| 566 | useStore.setState({ | ||
| 567 | activeEnvironmentId: null, | ||
| 568 | environmentStateById: {}, | ||
| 569 | }); | ||
| 570 | }); | ||
| 571 | |||
| 572 | afterEach(() => { | ||
| 573 | document.body.innerHTML = ""; | ||
| 574 | }); | ||
| 575 | |||
| 576 | it("coalesces rapid consecutive keybinding update toasts with no issues", async () => { | ||
| 577 | const mounted = await mountApp(); | ||
| 578 | |||
| 579 | try { | ||
| 580 | sendServerConfigUpdatedPush([]); | ||
| 581 | await waitForToast("Keybindings updated", 1); | ||
| 582 | |||
| 583 | // A single edit can produce several reload notifications as the direct update and | ||
| 584 | // filesystem watcher settle, so avoid stacking identical success toasts. | ||
| 585 | sendServerConfigUpdatedPush([]); | ||
| 586 | await new Promise((resolve) => setTimeout(resolve, 250)); | ||
| 587 | |||
| 588 | const titles = queryToastTitles(); | ||
| 589 | expect(titles.filter((title) => title === "Keybindings updated")).toHaveLength(1); | ||
| 590 | } finally { | ||
| 591 | await mounted.cleanup(); | ||
| 592 | } | ||
| 593 | }); | ||
| 594 | |||
| 595 | it("shows a warning toast when keybinding config has issues", async () => { | ||
| 596 | const mounted = await mountApp(); | ||
| 597 | |||
| 598 | try { | ||
| 599 | sendServerConfigUpdatedPush([ | ||
| 600 | { kind: "keybindings.malformed-config", message: "Expected JSON array" }, | ||
| 601 | ]); | ||
| 602 | await waitForToast("Invalid keybindings configuration"); | ||
| 603 | } finally { | ||
| 604 | await mounted.cleanup(); | ||
| 605 | } | ||
| 606 | }); | ||
| 607 | |||
| 608 | it("does not show a toast from the replayed cached value on subscribe", async () => { | ||
| 609 | const mounted = await mountApp(); | ||
| 610 | |||
| 611 | try { | ||
| 612 | sendServerConfigUpdatedPush([]); | ||
| 613 | await waitForToast("Keybindings updated"); | ||
| 614 | await waitForNoToast("Keybindings updated"); | ||
| 615 | |||
| 616 | // Remount the app — onServerConfigUpdated replays the cached value | ||
| 617 | // synchronously on subscribe. This should NOT produce a toast. | ||
| 618 | await mounted.cleanup(); | ||
| 619 | const remounted = await mountApp(); | ||
| 620 | |||
| 621 | // Give it a moment to process the replayed value | ||
| 622 | await new Promise((resolve) => setTimeout(resolve, 500)); | ||
| 623 | |||
| 624 | const titles = queryToastTitles(); | ||
| 625 | expect( | ||
| 626 | titles.filter((t) => t === "Keybindings updated").length, | ||
| 627 | "Replayed cached value should not produce a toast", | ||
| 628 | ).toBe(0); | ||
| 629 | |||
| 630 | await remounted.cleanup(); | ||
| 631 | } catch (error) { | ||
| 632 | await mounted.cleanup().catch(() => {}); | ||
| 633 | throw error; | ||
| 634 | } | ||
| 635 | }); | ||
| 636 | }); | ||
apps/web/src/components/KeybindingsUpdateToast.logic.test.ts created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import type { ServerConfigStreamEvent } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | createKeybindingsUpdateToastController, | ||
| 6 | KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS, | ||
| 7 | } from "./KeybindingsUpdateToast.logic"; | ||
| 8 | |||
| 9 | function keybindingsEvent( | ||
| 10 | overrides: Partial<Extract<ServerConfigStreamEvent, { type: "keybindingsUpdated" }>> = {}, | ||
| 11 | ): Extract<ServerConfigStreamEvent, { type: "keybindingsUpdated" }> { | ||
| 12 | return { | ||
| 13 | version: 1, | ||
| 14 | type: "keybindingsUpdated", | ||
| 15 | payload: { | ||
| 16 | keybindings: [], | ||
| 17 | issues: [], | ||
| 18 | }, | ||
| 19 | ...overrides, | ||
| 20 | }; | ||
| 21 | } | ||
| 22 | |||
| 23 | describe("keybindings update toast policy", () => { | ||
| 24 | it("coalesces repeated successful reload notifications during the cooldown", () => { | ||
| 25 | let now = 1_000; | ||
| 26 | const controller = createKeybindingsUpdateToastController({ | ||
| 27 | now: () => now, | ||
| 28 | }); | ||
| 29 | |||
| 30 | expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); | ||
| 31 | |||
| 32 | now += KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS - 1; | ||
| 33 | expect(controller.handle(keybindingsEvent())).toBeNull(); | ||
| 34 | |||
| 35 | now += 1; | ||
| 36 | expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("surfaces keybinding configuration issues", () => { | ||
| 40 | const controller = createKeybindingsUpdateToastController({}); | ||
| 41 | |||
| 42 | expect( | ||
| 43 | controller.handle( | ||
| 44 | keybindingsEvent({ | ||
| 45 | payload: { | ||
| 46 | keybindings: [], | ||
| 47 | issues: [ | ||
| 48 | { | ||
| 49 | kind: "keybindings.malformed-config", | ||
| 50 | message: "Expected JSON array", | ||
| 51 | }, | ||
| 52 | ], | ||
| 53 | }, | ||
| 54 | }), | ||
| 55 | ), | ||
| 56 | ).toEqual({ | ||
| 57 | _tag: "InvalidConfiguration", | ||
| 58 | message: "Expected JSON array", | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("ignores unrelated server config notifications", () => { | ||
| 63 | const controller = createKeybindingsUpdateToastController({}); | ||
| 64 | |||
| 65 | expect( | ||
| 66 | controller.handle({ | ||
| 67 | version: 1, | ||
| 68 | type: "settingsUpdated", | ||
| 69 | payload: { settings: {} as never }, | ||
| 70 | }), | ||
| 71 | ).toBeNull(); | ||
| 72 | }); | ||
| 73 | }); | ||
apps/web/src/components/KeybindingsUpdateToast.logic.ts created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import type { ServerConfigStreamEvent } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000; | ||
| 4 | |||
| 5 | export type KeybindingsUpdateToastDecision = | ||
| 6 | | { readonly _tag: "Success" } | ||
| 7 | | { readonly _tag: "InvalidConfiguration"; readonly message: string }; | ||
| 8 | |||
| 9 | export interface KeybindingsUpdateToastController { | ||
| 10 | readonly handle: (event: ServerConfigStreamEvent | null) => KeybindingsUpdateToastDecision | null; | ||
| 11 | } | ||
| 12 | |||
| 13 | export function createKeybindingsUpdateToastController(input: { | ||
| 14 | readonly now?: () => number; | ||
| 15 | }): KeybindingsUpdateToastController { | ||
| 16 | const now = input.now ?? Date.now; | ||
| 17 | let lastSuccessToastAt: number | null = null; | ||
| 18 | |||
| 19 | return { | ||
| 20 | handle: (event) => { | ||
| 21 | if (event?.type !== "keybindingsUpdated") { | ||
| 22 | return null; | ||
| 23 | } | ||
| 24 | |||
| 25 | const issue = event.payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); | ||
| 26 | if (issue) { | ||
| 27 | return { | ||
| 28 | _tag: "InvalidConfiguration", | ||
| 29 | message: issue.message, | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | const currentTime = now(); | ||
| 34 | if ( | ||
| 35 | lastSuccessToastAt !== null && | ||
| 36 | currentTime - lastSuccessToastAt < KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS | ||
| 37 | ) { | ||
| 38 | return null; | ||
| 39 | } | ||
| 40 | |||
| 41 | lastSuccessToastAt = currentTime; | ||
| 42 | return { _tag: "Success" }; | ||
| 43 | }, | ||
| 44 | }; | ||
| 45 | } | ||
apps/web/src/components/PlanSidebar.tsx+29-19| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { memo, useState, useCallback } from "react"; | 1 | import { memo, useState, useCallback } from "react"; |
| 2 | import { | ||
| 3 | isAtomCommandInterrupted, | ||
| 4 | squashAtomCommandFailure, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | 6 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | 7 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 4 | import { Badge } from "./ui/badge"; | 8 | import { Badge } from "./ui/badge"; |
| ... | @@ -24,9 +28,10 @@ import { | ... | @@ -24,9 +28,10 @@ import { |
| 24 | stripDisplayedPlanMarkdown, | 28 | stripDisplayedPlanMarkdown, |
| 25 | } from "../proposedPlan"; | 29 | } from "../proposedPlan"; |
| 26 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | 30 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; |
| 27 | import { readEnvironmentApi } from "~/environmentApi"; | 31 | import { projectEnvironment } from "~/state/projects"; |
| 28 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 32 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 29 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 33 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 30 | 35 | ||
| 31 | function stepStatusIcon(status: string): React.ReactNode { | 36 | function stepStatusIcon(status: string): React.ReactNode { |
| 32 | if (status === "completed") { | 37 | if (status === "completed") { |
| ... | @@ -75,6 +80,9 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -75,6 +80,9 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 75 | }: PlanSidebarProps) { | 80 | }: PlanSidebarProps) { |
| 76 | const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); | 81 | const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); |
| 77 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); | 82 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); |
| 83 | const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { | ||
| 84 | reportFailure: false, | ||
| 85 | }); | ||
| 78 | const { copyToClipboard, isCopied } = useCopyToClipboard(); | 86 | const { copyToClipboard, isCopied } = useCopyToClipboard(); |
| 79 | 87 | ||
| 80 | const planMarkdown = activeProposedPlan?.planMarkdown ?? null; | 88 | const planMarkdown = activeProposedPlan?.planMarkdown ?? null; |
| ... | @@ -93,24 +101,29 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -93,24 +101,29 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 93 | }, [planMarkdown]); | 101 | }, [planMarkdown]); |
| 94 | 102 | ||
| 95 | const handleSaveToWorkspace = useCallback(() => { | 103 | const handleSaveToWorkspace = useCallback(() => { |
| 96 | const api = readEnvironmentApi(environmentId); | 104 | if (!workspaceRoot || !planMarkdown) return; |
| 97 | if (!api || !workspaceRoot || !planMarkdown) return; | ||
| 98 | const filename = buildProposedPlanMarkdownFilename(planMarkdown); | 105 | const filename = buildProposedPlanMarkdownFilename(planMarkdown); |
| 99 | setIsSavingToWorkspace(true); | 106 | setIsSavingToWorkspace(true); |
| 100 | void api.projects | 107 | void (async () => { |
| 101 | .writeFile({ | 108 | const result = await writeProjectFile({ |
| 102 | cwd: workspaceRoot, | 109 | environmentId, |
| 103 | relativePath: filename, | 110 | input: { |
| 104 | contents: normalizePlanMarkdownForExport(planMarkdown), | 111 | cwd: workspaceRoot, |
| 105 | }) | 112 | relativePath: filename, |
| 106 | .then((result) => { | 113 | contents: normalizePlanMarkdownForExport(planMarkdown), |
| 114 | }, | ||
| 115 | }); | ||
| 116 | setIsSavingToWorkspace(false); | ||
| 117 | if (result._tag === "Success") { | ||
| 107 | toastManager.add({ | 118 | toastManager.add({ |
| 108 | type: "success", | 119 | type: "success", |
| 109 | title: "Plan saved", | 120 | title: "Plan saved", |
| 110 | description: result.relativePath, | 121 | description: result.value.relativePath, |
| 111 | }); | 122 | }); |
| 112 | }) | 123 | return; |
| 113 | .catch((error) => { | 124 | } |
| 125 | if (!isAtomCommandInterrupted(result)) { | ||
| 126 | const error = squashAtomCommandFailure(result); | ||
| 114 | toastManager.add( | 127 | toastManager.add( |
| 115 | stackedThreadToast({ | 128 | stackedThreadToast({ |
| 116 | type: "error", | 129 | type: "error", |
| ... | @@ -118,12 +131,9 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -118,12 +131,9 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 118 | description: error instanceof Error ? error.message : "An error occurred.", | 131 | description: error instanceof Error ? error.message : "An error occurred.", |
| 119 | }), | 132 | }), |
| 120 | ); | 133 | ); |
| 121 | }) | 134 | } |
| 122 | .then( | 135 | })(); |
| 123 | () => setIsSavingToWorkspace(false), | 136 | }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); |
| 124 | () => setIsSavingToWorkspace(false), | ||
| 125 | ); | ||
| 126 | }, [environmentId, planMarkdown, workspaceRoot]); | ||
| 127 | 137 | ||
| 128 | return ( | 138 | return ( |
| 129 | <div | 139 | <div |
apps/web/src/components/ProjectFavicon.tsx+23-19| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { FolderIcon } from "lucide-react"; | 2 | import { FolderIcon } from "lucide-react"; |
| 3 | import { useEffect, useState } from "react"; | 3 | import { useState } from "react"; |
| 4 | import { useAssetUrl } from "../assets/assetUrls"; | 4 | import { useAssetUrl } from "../assets/assetUrls"; |
| 5 | 5 | ||
| 6 | const loadedProjectFaviconSrcs = new Set<string>(); | 6 | const loadedProjectFaviconSrcs = new Set<string>(); |
| ... | @@ -8,38 +8,42 @@ const loadedProjectFaviconSrcs = new Set<string>(); | ... | @@ -8,38 +8,42 @@ const loadedProjectFaviconSrcs = new Set<string>(); |
| 8 | export function ProjectFavicon(input: { | 8 | export function ProjectFavicon(input: { |
| 9 | environmentId: EnvironmentId; | 9 | environmentId: EnvironmentId; |
| 10 | cwd: string; | 10 | cwd: string; |
| 11 | className?: string; | 11 | className?: string | undefined; |
| 12 | }) { | 12 | }) { |
| 13 | const src = useAssetUrl(input.environmentId, { | 13 | const src = useAssetUrl(input.environmentId, { |
| 14 | _tag: "project-favicon", | 14 | _tag: "project-favicon", |
| 15 | cwd: input.cwd, | 15 | cwd: input.cwd, |
| 16 | }); | 16 | }); |
| 17 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => | ||
| 18 | src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", | ||
| 19 | ); | ||
| 20 | useEffect(() => { | ||
| 21 | setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading"); | ||
| 22 | }, [src]); | ||
| 23 | 17 | ||
| 24 | if (!src) { | 18 | if (!src) { |
| 25 | return ( | 19 | return <ProjectFaviconFallback className={input.className} />; |
| 26 | <FolderIcon | ||
| 27 | className={`size-3.5 shrink-0 text-muted-foreground/50 ${input.className ?? ""}`} | ||
| 28 | /> | ||
| 29 | ); | ||
| 30 | } | 20 | } |
| 31 | 21 | ||
| 22 | return <ProjectFaviconImage key={src} src={src} className={input.className} />; | ||
| 23 | } | ||
| 24 | |||
| 25 | function ProjectFaviconFallback({ className }: { readonly className?: string | undefined }) { | ||
| 26 | return <FolderIcon className={`size-3.5 shrink-0 text-muted-foreground/50 ${className ?? ""}`} />; | ||
| 27 | } | ||
| 28 | |||
| 29 | function ProjectFaviconImage({ | ||
| 30 | src, | ||
| 31 | className, | ||
| 32 | }: { | ||
| 33 | readonly src: string; | ||
| 34 | readonly className?: string | undefined; | ||
| 35 | }) { | ||
| 36 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => | ||
| 37 | loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", | ||
| 38 | ); | ||
| 39 | |||
| 32 | return ( | 40 | return ( |
| 33 | <> | 41 | <> |
| 34 | {status !== "loaded" ? ( | 42 | {status !== "loaded" ? <ProjectFaviconFallback className={className} /> : null} |
| 35 | <FolderIcon | ||
| 36 | className={`size-3.5 shrink-0 text-muted-foreground/50 ${input.className ?? ""}`} | ||
| 37 | /> | ||
| 38 | ) : null} | ||
| 39 | <img | 43 | <img |
| 40 | src={src} | 44 | src={src} |
| 41 | alt="" | 45 | alt="" |
| 42 | className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loaded" ? "" : "hidden"} ${input.className ?? ""}`} | 46 | className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loaded" ? "" : "hidden"} ${className ?? ""}`} |
| 43 | onLoad={() => { | 47 | onLoad={() => { |
| 44 | loadedProjectFaviconSrcs.add(src); | 48 | loadedProjectFaviconSrcs.add(src); |
| 45 | setStatus("loaded"); | 49 | setStatus("loaded"); |
apps/web/src/components/ProjectScriptsControl.tsx+30-12| ... | @@ -3,6 +3,11 @@ import type { | ... | @@ -3,6 +3,11 @@ import type { |
| 3 | ProjectScriptIcon, | 3 | ProjectScriptIcon, |
| 4 | ResolvedKeybindingsConfig, | 4 | ResolvedKeybindingsConfig, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { | ||
| 7 | isAtomCommandInterrupted, | ||
| 8 | squashAtomCommandFailure, | ||
| 9 | type AtomCommandResult, | ||
| 10 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 6 | import { | 11 | import { |
| 7 | BugIcon, | 12 | BugIcon, |
| 8 | ChevronDownIcon, | 13 | ChevronDownIcon, |
| ... | @@ -91,14 +96,19 @@ export interface NewProjectScriptInput { | ... | @@ -91,14 +96,19 @@ export interface NewProjectScriptInput { |
| 91 | autoOpenPreview: boolean; | 96 | autoOpenPreview: boolean; |
| 92 | } | 97 | } |
| 93 | 98 | ||
| 99 | export type ProjectScriptActionResult = AtomCommandResult<void, unknown>; | ||
| 100 | |||
| 94 | interface ProjectScriptsControlProps { | 101 | interface ProjectScriptsControlProps { |
| 95 | scripts: ProjectScript[]; | 102 | scripts: ReadonlyArray<ProjectScript>; |
| 96 | keybindings: ResolvedKeybindingsConfig; | 103 | keybindings: ResolvedKeybindingsConfig; |
| 97 | preferredScriptId?: string | null; | 104 | preferredScriptId?: string | null; |
| 98 | onRunScript: (script: ProjectScript) => void; | 105 | onRunScript: (script: ProjectScript) => void; |
| 99 | onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; | 106 | onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>; |
| 100 | onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void; | 107 | onUpdateScript: ( |
| 101 | onDeleteScript: (scriptId: string) => Promise<void> | void; | 108 | scriptId: string, |
| 109 | input: NewProjectScriptInput, | ||
| 110 | ) => Promise<ProjectScriptActionResult>; | ||
| 111 | onDeleteScript: (scriptId: string) => Promise<ProjectScriptActionResult>; | ||
| 102 | } | 112 | } |
| 103 | 113 | ||
| 104 | export default function ProjectScriptsControl({ | 114 | export default function ProjectScriptsControl({ |
| ... | @@ -161,6 +171,7 @@ export default function ProjectScriptsControl({ | ... | @@ -161,6 +171,7 @@ export default function ProjectScriptsControl({ |
| 161 | } | 171 | } |
| 162 | 172 | ||
| 163 | setValidationError(null); | 173 | setValidationError(null); |
| 174 | let payload: NewProjectScriptInput; | ||
| 164 | try { | 175 | try { |
| 165 | const scriptIdForValidation = | 176 | const scriptIdForValidation = |
| 166 | editingScriptId ?? | 177 | editingScriptId ?? |
| ... | @@ -173,7 +184,7 @@ export default function ProjectScriptsControl({ | ... | @@ -173,7 +184,7 @@ export default function ProjectScriptsControl({ |
| 173 | command: commandForProjectScript(scriptIdForValidation), | 184 | command: commandForProjectScript(scriptIdForValidation), |
| 174 | }); | 185 | }); |
| 175 | const trimmedPreviewUrl = previewUrl.trim(); | 186 | const trimmedPreviewUrl = previewUrl.trim(); |
| 176 | const payload = { | 187 | payload = { |
| 177 | name: trimmedName, | 188 | name: trimmedName, |
| 178 | command: trimmedCommand, | 189 | command: trimmedCommand, |
| 179 | icon, | 190 | icon, |
| ... | @@ -182,16 +193,23 @@ export default function ProjectScriptsControl({ | ... | @@ -182,16 +193,23 @@ export default function ProjectScriptsControl({ |
| 182 | previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, | 193 | previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, |
| 183 | autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, | 194 | autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, |
| 184 | } satisfies NewProjectScriptInput; | 195 | } satisfies NewProjectScriptInput; |
| 185 | if (editingScriptId) { | ||
| 186 | await onUpdateScript(editingScriptId, payload); | ||
| 187 | } else { | ||
| 188 | await onAddScript(payload); | ||
| 189 | } | ||
| 190 | setDialogOpen(false); | ||
| 191 | setIconPickerOpen(false); | ||
| 192 | } catch (error) { | 196 | } catch (error) { |
| 193 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); | 197 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); |
| 198 | return; | ||
| 199 | } | ||
| 200 | |||
| 201 | const result = editingScriptId | ||
| 202 | ? await onUpdateScript(editingScriptId, payload) | ||
| 203 | : await onAddScript(payload); | ||
| 204 | if (result._tag === "Failure") { | ||
| 205 | if (!isAtomCommandInterrupted(result)) { | ||
| 206 | const error = squashAtomCommandFailure(result); | ||
| 207 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); | ||
| 208 | } | ||
| 209 | return; | ||
| 194 | } | 210 | } |
| 211 | setDialogOpen(false); | ||
| 212 | setIconPickerOpen(false); | ||
| 195 | }; | 213 | }; |
| 196 | 214 | ||
| 197 | const openAddDialog = () => { | 215 | const openAddDialog = () => { |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts+9-14| ... | @@ -1,11 +1,13 @@ | ... | @@ -1,11 +1,13 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | 2 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | 5 | ||
| 4 | import { | 6 | import { |
| 5 | canOneClickUpdateProviderCandidate, | 7 | canOneClickUpdateProviderCandidate, |
| 6 | collectProviderUpdateCandidates, | 8 | collectProviderUpdateCandidates, |
| 7 | collectUpdatedProviderSnapshots, | 9 | collectUpdatedProviderSnapshots, |
| 8 | firstRejectedProviderUpdateMessage, | 10 | firstFailedProviderUpdateMessage, |
| 9 | getProviderUpdateInitialToastView, | 11 | getProviderUpdateInitialToastView, |
| 10 | getProviderUpdateProgressToastView, | 12 | getProviderUpdateProgressToastView, |
| 11 | getProviderUpdateRejectedToastView, | 13 | getProviderUpdateRejectedToastView, |
| ... | @@ -246,12 +248,9 @@ describe("provider update launch notification logic", () => { | ... | @@ -246,12 +248,9 @@ describe("provider update launch notification logic", () => { |
| 246 | expect( | 248 | expect( |
| 247 | collectUpdatedProviderSnapshots({ | 249 | collectUpdatedProviderSnapshots({ |
| 248 | results: [ | 250 | results: [ |
| 249 | { | 251 | AsyncResult.success({ |
| 250 | status: "fulfilled", | 252 | providers: [updatedPersonal, currentDefaultSibling], |
| 251 | value: { | 253 | }), |
| 252 | providers: [updatedPersonal, currentDefaultSibling], | ||
| 253 | }, | ||
| 254 | }, | ||
| 255 | ], | 254 | ], |
| 256 | providerInstanceIds: new Set([targetInstanceId]), | 255 | providerInstanceIds: new Set([targetInstanceId]), |
| 257 | }), | 256 | }), |
| ... | @@ -435,11 +434,9 @@ describe("provider update launch notification logic", () => { | ... | @@ -435,11 +434,9 @@ describe("provider update launch notification logic", () => { |
| 435 | }); | 434 | }); |
| 436 | 435 | ||
| 437 | it("falls back to a rejected RPC message for transport-level failures", () => { | 436 | it("falls back to a rejected RPC message for transport-level failures", () => { |
| 438 | const results: PromiseSettledResult<unknown>[] = [ | 437 | const results = [AsyncResult.failure(Cause.die(new Error("WebSocket closed")))]; |
| 439 | { status: "rejected", reason: new Error("WebSocket closed") }, | ||
| 440 | ]; | ||
| 441 | 438 | ||
| 442 | expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed"); | 439 | expect(firstFailedProviderUpdateMessage(results)).toBe("WebSocket closed"); |
| 443 | expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({ | 440 | expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({ |
| 444 | phase: "failed", | 441 | phase: "failed", |
| 445 | title: "Provider updates failed", | 442 | title: "Provider updates failed", |
| ... | @@ -450,9 +447,7 @@ describe("provider update launch notification logic", () => { | ... | @@ -450,9 +447,7 @@ describe("provider update launch notification logic", () => { |
| 450 | it("collects only attempted provider snapshots from update responses", () => { | 447 | it("collects only attempted provider snapshots from update responses", () => { |
| 451 | const codex = provider({ driver: driver("codex") }); | 448 | const codex = provider({ driver: driver("codex") }); |
| 452 | const cursor = provider({ driver: driver("cursor") }); | 449 | const cursor = provider({ driver: driver("cursor") }); |
| 453 | const results: PromiseSettledResult<{ readonly providers: ReadonlyArray<ServerProvider> }>[] = [ | 450 | const results = [AsyncResult.success({ providers: [codex, cursor] })]; |
| 454 | { status: "fulfilled", value: { providers: [codex, cursor] } }, | ||
| 455 | ]; | ||
| 456 | 451 | ||
| 457 | expect( | 452 | expect( |
| 458 | collectUpdatedProviderSnapshots({ | 453 | collectUpdatedProviderSnapshots({ |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+12-7| ... | @@ -5,6 +5,10 @@ import { | ... | @@ -5,6 +5,10 @@ import { |
| 5 | type ProviderInstanceId, | 5 | type ProviderInstanceId, |
| 6 | type ServerProvider, | 6 | type ServerProvider, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { | ||
| 9 | squashAtomCommandFailure, | ||
| 10 | type AtomCommandResult, | ||
| 11 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 8 | 12 | ||
| 9 | export type ProviderUpdateCandidate = ServerProvider & { | 13 | export type ProviderUpdateCandidate = ServerProvider & { |
| 10 | readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & { | 14 | readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & { |
| ... | @@ -328,14 +332,14 @@ export function getSingleProviderUpdateProgressToastView( | ... | @@ -328,14 +332,14 @@ export function getSingleProviderUpdateProgressToastView( |
| 328 | 332 | ||
| 329 | export function collectUpdatedProviderSnapshots(input: { | 333 | export function collectUpdatedProviderSnapshots(input: { |
| 330 | readonly results: ReadonlyArray< | 334 | readonly results: ReadonlyArray< |
| 331 | PromiseSettledResult<{ readonly providers: ReadonlyArray<ServerProvider> }> | 335 | AtomCommandResult<{ readonly providers: ReadonlyArray<ServerProvider> }, unknown> |
| 332 | >; | 336 | >; |
| 333 | readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>; | 337 | readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>; |
| 334 | }): ServerProvider[] { | 338 | }): ServerProvider[] { |
| 335 | const matchedProviders: ServerProvider[] = []; | 339 | const matchedProviders: ServerProvider[] = []; |
| 336 | 340 | ||
| 337 | for (const result of input.results) { | 341 | for (const result of input.results) { |
| 338 | if (result.status !== "fulfilled") { | 342 | if (result._tag === "Failure") { |
| 339 | continue; | 343 | continue; |
| 340 | } | 344 | } |
| 341 | for (const provider of result.value.providers) { | 345 | for (const provider of result.value.providers) { |
| ... | @@ -348,14 +352,15 @@ export function collectUpdatedProviderSnapshots(input: { | ... | @@ -348,14 +352,15 @@ export function collectUpdatedProviderSnapshots(input: { |
| 348 | return dedupeProvidersByInstanceId(matchedProviders); | 352 | return dedupeProvidersByInstanceId(matchedProviders); |
| 349 | } | 353 | } |
| 350 | 354 | ||
| 351 | export function firstRejectedProviderUpdateMessage( | 355 | export function firstFailedProviderUpdateMessage( |
| 352 | results: ReadonlyArray<PromiseSettledResult<unknown>>, | 356 | results: ReadonlyArray<AtomCommandResult<unknown, unknown>>, |
| 353 | ): string | null { | 357 | ): string | null { |
| 354 | const rejected = results.find((result) => result.status === "rejected"); | 358 | const failed = results.find((result) => result._tag === "Failure"); |
| 355 | if (!rejected) { | 359 | if (!failed || failed._tag !== "Failure") { |
| 356 | return null; | 360 | return null; |
| 357 | } | 361 | } |
| 358 | return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed."; | 362 | const error = squashAtomCommandFailure(failed); |
| 363 | return error instanceof Error ? error.message : "Provider update failed."; | ||
| 359 | } | 364 | } |
| 360 | 365 | ||
| 361 | function getUpdateFinishedAt(provider: ServerProvider): string | null { | 366 | function getUpdateFinishedAt(provider: ServerProvider): string | null { |
apps/web/src/components/ProviderUpdateLaunchNotification.tsx+31-17| ... | @@ -1,17 +1,18 @@ | ... | @@ -1,17 +1,18 @@ |
| 1 | import { useNavigate } from "@tanstack/react-router"; | 1 | import { useNavigate } from "@tanstack/react-router"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { DownloadIcon } from "lucide-react"; | 3 | import { DownloadIcon } from "lucide-react"; |
| 3 | import { useCallback, useEffect, useMemo, useRef } from "react"; | 4 | import { useCallback, useEffect, useMemo, useRef } from "react"; |
| 4 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; | 5 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; |
| 5 | 6 | ||
| 6 | import { ensureLocalApi } from "../localApi"; | 7 | import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; |
| 8 | import { usePrimaryEnvironment } from "../state/environments"; | ||
| 7 | import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; | 9 | import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; |
| 8 | import { useServerProviders } from "../rpc/serverState"; | ||
| 9 | import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; | 10 | import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; |
| 10 | import { | 11 | import { |
| 11 | canOneClickUpdateProviderCandidate, | 12 | canOneClickUpdateProviderCandidate, |
| 12 | collectProviderUpdateCandidates, | 13 | collectProviderUpdateCandidates, |
| 13 | collectUpdatedProviderSnapshots, | 14 | collectUpdatedProviderSnapshots, |
| 14 | firstRejectedProviderUpdateMessage, | 15 | firstFailedProviderUpdateMessage, |
| 15 | getProviderUpdateInitialToastView, | 16 | getProviderUpdateInitialToastView, |
| 16 | getProviderUpdateProgressToastView, | 17 | getProviderUpdateProgressToastView, |
| 17 | getProviderUpdateRejectedToastView, | 18 | getProviderUpdateRejectedToastView, |
| ... | @@ -20,6 +21,7 @@ import { | ... | @@ -20,6 +21,7 @@ import { |
| 20 | type ProviderUpdateToastView, | 21 | type ProviderUpdateToastView, |
| 21 | } from "./ProviderUpdateLaunchNotification.logic"; | 22 | } from "./ProviderUpdateLaunchNotification.logic"; |
| 22 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 23 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 24 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 23 | 25 | ||
| 24 | const seenProviderUpdateNotificationKeys = new Set<string>(); | 26 | const seenProviderUpdateNotificationKeys = new Set<string>(); |
| 25 | type ProviderUpdateToastId = ReturnType<typeof toastManager.add>; | 27 | type ProviderUpdateToastId = ReturnType<typeof toastManager.add>; |
| ... | @@ -101,7 +103,11 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { | ... | @@ -101,7 +103,11 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { |
| 101 | 103 | ||
| 102 | export function ProviderUpdateLaunchNotification() { | 104 | export function ProviderUpdateLaunchNotification() { |
| 103 | const navigate = useNavigate(); | 105 | const navigate = useNavigate(); |
| 104 | const providers = useServerProviders(); | 106 | const providers = useAtomValue(primaryServerProvidersAtom); |
| 107 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 108 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { | ||
| 109 | reportFailure: false, | ||
| 110 | }); | ||
| 105 | const activeToastRef = useRef<ActiveProviderUpdateToast | null>(null); | 111 | const activeToastRef = useRef<ActiveProviderUpdateToast | null>(null); |
| 106 | const { dismissedNotificationKeys, dismissNotificationKey } = | 112 | const { dismissedNotificationKeys, dismissNotificationKey } = |
| 107 | useDismissedProviderUpdateNotificationKeys(); | 113 | useDismissedProviderUpdateNotificationKeys(); |
| ... | @@ -185,7 +191,7 @@ export function ProviderUpdateLaunchNotification() { | ... | @@ -185,7 +191,7 @@ export function ProviderUpdateLaunchNotification() { |
| 185 | }; | 191 | }; |
| 186 | 192 | ||
| 187 | const runUpdates = () => { | 193 | const runUpdates = () => { |
| 188 | if (updateStarted || oneClickProviders.length === 0) { | 194 | if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { |
| 189 | return; | 195 | return; |
| 190 | } | 196 | } |
| 191 | updateStarted = true; | 197 | updateStarted = true; |
| ... | @@ -206,24 +212,30 @@ export function ProviderUpdateLaunchNotification() { | ... | @@ -206,24 +212,30 @@ export function ProviderUpdateLaunchNotification() { |
| 206 | openSettings, | 212 | openSettings, |
| 207 | }); | 213 | }); |
| 208 | 214 | ||
| 209 | void Promise.allSettled( | 215 | void (async () => { |
| 210 | oneClickProviders.map(async (provider) => | 216 | const results = []; |
| 211 | ensureLocalApi().server.updateProvider({ | 217 | for (const provider of oneClickProviders) { |
| 212 | provider: provider.driver, | 218 | results.push( |
| 213 | instanceId: provider.instanceId, | 219 | await updateProvider({ |
| 214 | }), | 220 | environmentId: primaryEnvironment.environmentId, |
| 215 | ), | 221 | input: { |
| 216 | ).then((results) => { | 222 | provider: provider.driver, |
| 223 | instanceId: provider.instanceId, | ||
| 224 | }, | ||
| 225 | }), | ||
| 226 | ); | ||
| 227 | } | ||
| 228 | |||
| 217 | const activeUpdateToast = activeToastRef.current; | 229 | const activeUpdateToast = activeToastRef.current; |
| 218 | if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { | 230 | if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { |
| 219 | return; | 231 | return; |
| 220 | } | 232 | } |
| 221 | 233 | ||
| 222 | const rejectedMessage = firstRejectedProviderUpdateMessage(results); | 234 | const failedMessage = firstFailedProviderUpdateMessage(results); |
| 223 | if (rejectedMessage) { | 235 | if (failedMessage) { |
| 224 | updateProviderUpdateToast({ | 236 | updateProviderUpdateToast({ |
| 225 | toastId, | 237 | toastId, |
| 226 | view: getProviderUpdateRejectedToastView(providerCount, rejectedMessage), | 238 | view: getProviderUpdateRejectedToastView(providerCount, failedMessage), |
| 227 | openSettings, | 239 | openSettings, |
| 228 | }); | 240 | }); |
| 229 | activeToastRef.current = null; | 241 | activeToastRef.current = null; |
| ... | @@ -247,7 +259,7 @@ export function ProviderUpdateLaunchNotification() { | ... | @@ -247,7 +259,7 @@ export function ProviderUpdateLaunchNotification() { |
| 247 | if (isTerminalProviderUpdateToastView(view)) { | 259 | if (isTerminalProviderUpdateToastView(view)) { |
| 248 | activeToastRef.current = null; | 260 | activeToastRef.current = null; |
| 249 | } | 261 | } |
| 250 | }); | 262 | })(); |
| 251 | }; | 263 | }; |
| 252 | 264 | ||
| 253 | toastId = toastManager.add( | 265 | toastId = toastManager.add( |
| ... | @@ -288,11 +300,13 @@ export function ProviderUpdateLaunchNotification() { | ... | @@ -288,11 +300,13 @@ export function ProviderUpdateLaunchNotification() { |
| 288 | ); | 300 | ); |
| 289 | activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; | 301 | activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; |
| 290 | }, [ | 302 | }, [ |
| 303 | updateProvider, | ||
| 291 | dismissNotificationKey, | 304 | dismissNotificationKey, |
| 292 | dismissedNotificationKeys, | 305 | dismissedNotificationKeys, |
| 293 | notificationKey, | 306 | notificationKey, |
| 294 | oneClickProviders, | 307 | oneClickProviders, |
| 295 | openProviderSettings, | 308 | openProviderSettings, |
| 309 | primaryEnvironment, | ||
| 296 | updateProviders, | 310 | updateProviders, |
| 297 | ]); | 311 | ]); |
| 298 | 312 |
apps/web/src/components/PullRequestThreadDialog.tsx+28-25| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 3 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | 5 | ||
| ... | @@ -7,10 +8,11 @@ import { | ... | @@ -7,10 +8,11 @@ import { |
| 7 | usePreparePullRequestThreadAction, | 8 | usePreparePullRequestThreadAction, |
| 8 | usePullRequestResolution, | 9 | usePullRequestResolution, |
| 9 | } from "~/lib/sourceControlActions"; | 10 | } from "~/lib/sourceControlActions"; |
| 10 | import { useVcsStatus } from "~/lib/vcsStatusState"; | ||
| 11 | import { cn } from "~/lib/utils"; | 11 | import { cn } from "~/lib/utils"; |
| 12 | import { parsePullRequestReference } from "~/pullRequestReference"; | 12 | import { parsePullRequestReference } from "~/pullRequestReference"; |
| 13 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; | 13 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; |
| 14 | import { useEnvironmentQuery } from "~/state/query"; | ||
| 15 | import { vcsEnvironment } from "~/state/vcs"; | ||
| 14 | import { Button } from "./ui/button"; | 16 | import { Button } from "./ui/button"; |
| 15 | import { | 17 | import { |
| 16 | Dialog, | 18 | Dialog, |
| ... | @@ -52,7 +54,14 @@ export function PullRequestThreadDialog({ | ... | @@ -52,7 +54,14 @@ export function PullRequestThreadDialog({ |
| 52 | { wait: 450 }, | 54 | { wait: 450 }, |
| 53 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 55 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 54 | ); | 56 | ); |
| 55 | const { data: gitStatus } = useVcsStatus({ environmentId, cwd }); | 57 | const { data: gitStatus } = useEnvironmentQuery( |
| 58 | cwd === null | ||
| 59 | ? null | ||
| 60 | : vcsEnvironment.status({ | ||
| 61 | environmentId, | ||
| 62 | input: { cwd }, | ||
| 63 | }), | ||
| 64 | ); | ||
| 56 | const sourceControlPresentation = useMemo( | 65 | const sourceControlPresentation = useMemo( |
| 57 | () => getSourceControlPresentation(gitStatus?.sourceControlProvider), | 66 | () => getSourceControlPresentation(gitStatus?.sourceControlProvider), |
| 58 | [gitStatus?.sourceControlProvider], | 67 | [gitStatus?.sourceControlProvider], |
| ... | @@ -60,13 +69,6 @@ export function PullRequestThreadDialog({ | ... | @@ -60,13 +69,6 @@ export function PullRequestThreadDialog({ |
| 60 | const terminology = sourceControlPresentation.terminology; | 69 | const terminology = sourceControlPresentation.terminology; |
| 61 | const SourceControlIcon = sourceControlPresentation.Icon; | 70 | const SourceControlIcon = sourceControlPresentation.Icon; |
| 62 | 71 | ||
| 63 | useEffect(() => { | ||
| 64 | if (!open) return; | ||
| 65 | setReference(initialReference ?? ""); | ||
| 66 | setReferenceDirty(false); | ||
| 67 | setPreparingMode(null); | ||
| 68 | }, [initialReference, open]); | ||
| 69 | |||
| 70 | useEffect(() => { | 72 | useEffect(() => { |
| 71 | if (!open) return; | 73 | if (!open) return; |
| 72 | const frame = window.requestAnimationFrame(() => { | 74 | const frame = window.requestAnimationFrame(() => { |
| ... | @@ -137,20 +139,23 @@ export function PullRequestThreadDialog({ | ... | @@ -137,20 +139,23 @@ export function PullRequestThreadDialog({ |
| 137 | return; | 139 | return; |
| 138 | } | 140 | } |
| 139 | setPreparingMode(mode); | 141 | setPreparingMode(mode); |
| 140 | try { | 142 | const result = await preparePullRequestThreadAction.run({ |
| 141 | const result = await preparePullRequestThreadAction.run({ | 143 | reference: parsedReference, |
| 142 | reference: parsedReference, | 144 | mode, |
| 143 | mode, | 145 | ...(mode === "worktree" ? { threadId } : {}), |
| 144 | ...(mode === "worktree" ? { threadId } : {}), | 146 | }); |
| 145 | }); | 147 | setPreparingMode(null); |
| 146 | await onPrepared({ | 148 | if (result._tag === "Failure") { |
| 147 | branch: result.branch, | 149 | if (isAtomCommandInterrupted(result)) { |
| 148 | worktreePath: result.worktreePath, | 150 | preparePullRequestThreadAction.resetError(); |
| 149 | }); | 151 | } |
| 150 | onOpenChange(false); | 152 | return; |
| 151 | } finally { | ||
| 152 | setPreparingMode(null); | ||
| 153 | } | 153 | } |
| 154 | await onPrepared({ | ||
| 155 | branch: result.value.branch, | ||
| 156 | worktreePath: result.value.worktreePath, | ||
| 157 | }); | ||
| 158 | onOpenChange(false); | ||
| 154 | }, | 159 | }, |
| 155 | [ | 160 | [ |
| 156 | cwd, | 161 | cwd, |
| ... | @@ -173,9 +178,7 @@ export function PullRequestThreadDialog({ | ... | @@ -173,9 +178,7 @@ export function PullRequestThreadDialog({ |
| 173 | const errorMessage = | 178 | const errorMessage = |
| 174 | validationMessage ?? | 179 | validationMessage ?? |
| 175 | (resolvedPullRequest === null && pullRequestResolution.error | 180 | (resolvedPullRequest === null && pullRequestResolution.error |
| 176 | ? pullRequestResolution.error instanceof Error | 181 | ? pullRequestResolution.error |
| 177 | ? pullRequestResolution.error.message | ||
| 178 | : `Failed to resolve ${terminology.singular}.` | ||
| 179 | : preparePullRequestThreadAction.error instanceof Error | 182 | : preparePullRequestThreadAction.error instanceof Error |
| 180 | ? preparePullRequestThreadAction.error.message | 183 | ? preparePullRequestThreadAction.error.message |
| 181 | : preparePullRequestThreadAction.error | 184 | : preparePullRequestThreadAction.error |
apps/web/src/components/Sidebar.dblclick.browser.tsx deleted-255| ... | @@ -1,255 +0,0 @@ | ||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import { useCallback, useRef, useState } from "react"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | import { page, userEvent } from "vite-plus/test/browser"; | ||
| 7 | import { cleanup, render } from "vitest-browser-react"; | ||
| 8 | |||
| 9 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | ||
| 10 | import { DEFAULT_INTERACTION_MODE } from "../types"; | ||
| 11 | import type { SidebarThreadSummary } from "../types"; | ||
| 12 | import { SidebarThreadRow } from "./Sidebar"; | ||
| 13 | |||
| 14 | // Double-click-to-rename is a desktop affordance; force the non-mobile path so | ||
| 15 | // the rename input is reachable regardless of the test browser viewport. | ||
| 16 | vi.mock("~/hooks/useMediaQuery", () => ({ | ||
| 17 | useIsMobile: () => false, | ||
| 18 | useMediaQuery: () => false, | ||
| 19 | })); | ||
| 20 | |||
| 21 | const THREAD_ID = ThreadId.make("thread-1"); | ||
| 22 | const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | ||
| 23 | const PROJECT_ID = ProjectId.make("project-1"); | ||
| 24 | const INITIAL_TITLE = "Original title"; | ||
| 25 | |||
| 26 | const ROW_TESTID = `thread-row-${THREAD_ID}`; | ||
| 27 | const TITLE_TESTID = `thread-title-${THREAD_ID}`; | ||
| 28 | |||
| 29 | // Spies live at module scope so their call history survives the row's | ||
| 30 | // re-renders; reset between tests. | ||
| 31 | const spies = { | ||
| 32 | handleThreadClick: vi.fn(), | ||
| 33 | startThreadRename: vi.fn(), | ||
| 34 | navigateToThread: vi.fn(), | ||
| 35 | handleMultiSelectContextMenu: vi.fn(async () => {}), | ||
| 36 | handleThreadContextMenu: vi.fn(async () => {}), | ||
| 37 | clearSelection: vi.fn(), | ||
| 38 | commitRename: vi.fn(), | ||
| 39 | attemptArchiveThread: vi.fn(async () => {}), | ||
| 40 | openPrLink: vi.fn(), | ||
| 41 | }; | ||
| 42 | |||
| 43 | function buildThread(title: string): SidebarThreadSummary { | ||
| 44 | return { | ||
| 45 | id: THREAD_ID, | ||
| 46 | environmentId: ENVIRONMENT_ID, | ||
| 47 | projectId: PROJECT_ID, | ||
| 48 | title, | ||
| 49 | interactionMode: DEFAULT_INTERACTION_MODE, | ||
| 50 | session: null, | ||
| 51 | createdAt: "2024-01-01T00:00:00.000Z", | ||
| 52 | archivedAt: null, | ||
| 53 | updatedAt: undefined, | ||
| 54 | latestTurn: null, | ||
| 55 | branch: null, | ||
| 56 | worktreePath: null, | ||
| 57 | latestUserMessageAt: null, | ||
| 58 | hasPendingApprovals: false, | ||
| 59 | hasPendingUserInput: false, | ||
| 60 | hasActionableProposedPlan: false, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | // Mirrors the real parent (`SidebarProjectItem`): holds the rename state, wires | ||
| 65 | // `startThreadRename`, and commits by clearing the rename state and persisting | ||
| 66 | // the new title back onto the thread so the row re-renders with it. | ||
| 67 | function Harness() { | ||
| 68 | const [title, setTitle] = useState(INITIAL_TITLE); | ||
| 69 | const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null); | ||
| 70 | const [renamingTitle, setRenamingTitle] = useState(""); | ||
| 71 | const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null); | ||
| 72 | const renamingInputRef = useRef<HTMLInputElement | null>(null); | ||
| 73 | const renamingCommittedRef = useRef(false); | ||
| 74 | const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>()); | ||
| 75 | |||
| 76 | const startThreadRename = useCallback((threadKey: string, nextTitle: string) => { | ||
| 77 | spies.startThreadRename(threadKey, nextTitle); | ||
| 78 | setRenamingThreadKey(threadKey); | ||
| 79 | setRenamingTitle(nextTitle); | ||
| 80 | renamingCommittedRef.current = false; | ||
| 81 | }, []); | ||
| 82 | |||
| 83 | const commitRename = useCallback( | ||
| 84 | async (threadRef: unknown, newTitle: string, originalTitle: string) => { | ||
| 85 | spies.commitRename(threadRef, newTitle, originalTitle); | ||
| 86 | const trimmed = newTitle.trim(); | ||
| 87 | if (trimmed.length > 0) { | ||
| 88 | setTitle(trimmed); | ||
| 89 | } | ||
| 90 | setRenamingThreadKey(null); | ||
| 91 | renamingInputRef.current = null; | ||
| 92 | }, | ||
| 93 | [], | ||
| 94 | ); | ||
| 95 | |||
| 96 | const cancelRename = useCallback(() => { | ||
| 97 | setRenamingThreadKey(null); | ||
| 98 | renamingInputRef.current = null; | ||
| 99 | }, []); | ||
| 100 | |||
| 101 | return ( | ||
| 102 | <AppAtomRegistryProvider> | ||
| 103 | <ul> | ||
| 104 | <SidebarThreadRow | ||
| 105 | thread={buildThread(title)} | ||
| 106 | projectCwd={null} | ||
| 107 | orderedProjectThreadKeys={[]} | ||
| 108 | isActive={false} | ||
| 109 | jumpLabel={null} | ||
| 110 | appSettingsConfirmThreadArchive={false} | ||
| 111 | renamingThreadKey={renamingThreadKey} | ||
| 112 | renamingTitle={renamingTitle} | ||
| 113 | setRenamingTitle={setRenamingTitle} | ||
| 114 | startThreadRename={startThreadRename} | ||
| 115 | renamingInputRef={renamingInputRef} | ||
| 116 | renamingCommittedRef={renamingCommittedRef} | ||
| 117 | confirmingArchiveThreadKey={confirmingArchiveThreadKey} | ||
| 118 | setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey} | ||
| 119 | confirmArchiveButtonRefs={confirmArchiveButtonRefs} | ||
| 120 | handleThreadClick={spies.handleThreadClick} | ||
| 121 | navigateToThread={spies.navigateToThread} | ||
| 122 | handleMultiSelectContextMenu={spies.handleMultiSelectContextMenu} | ||
| 123 | handleThreadContextMenu={spies.handleThreadContextMenu} | ||
| 124 | clearSelection={spies.clearSelection} | ||
| 125 | commitRename={commitRename} | ||
| 126 | cancelRename={cancelRename} | ||
| 127 | attemptArchiveThread={spies.attemptArchiveThread} | ||
| 128 | openPrLink={spies.openPrLink} | ||
| 129 | /> | ||
| 130 | </ul> | ||
| 131 | </AppAtomRegistryProvider> | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | |||
| 135 | describe("SidebarThreadRow double-click rename", () => { | ||
| 136 | beforeEach(() => { | ||
| 137 | for (const spy of Object.values(spies)) spy.mockClear(); | ||
| 138 | }); | ||
| 139 | |||
| 140 | afterEach(() => { | ||
| 141 | cleanup(); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("double-clicking a row starts the inline rename, focused with text selected", async () => { | ||
| 145 | render(<Harness />); | ||
| 146 | |||
| 147 | await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); | ||
| 148 | |||
| 149 | await userEvent.dblClick(page.getByTestId(ROW_TESTID)); | ||
| 150 | |||
| 151 | const input = page.getByRole("textbox"); | ||
| 152 | await expect.element(input).toBeVisible(); | ||
| 153 | |||
| 154 | const element = input.element() as HTMLInputElement; | ||
| 155 | expect(element.value).toBe(INITIAL_TITLE); | ||
| 156 | // The existing rename-input ref focuses + selects the whole title. | ||
| 157 | expect(document.activeElement).toBe(element); | ||
| 158 | expect(element.selectionStart).toBe(0); | ||
| 159 | expect(element.selectionEnd).toBe(INITIAL_TITLE.length); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("Enter commits the rename and the new title persists on the row", async () => { | ||
| 163 | render(<Harness />); | ||
| 164 | |||
| 165 | await userEvent.dblClick(page.getByTestId(ROW_TESTID)); | ||
| 166 | const input = page.getByRole("textbox"); | ||
| 167 | await expect.element(input).toBeVisible(); | ||
| 168 | |||
| 169 | await userEvent.fill(input, "Renamed thread"); | ||
| 170 | await userEvent.keyboard("{Enter}"); | ||
| 171 | |||
| 172 | // commitRename was invoked with (threadRef, newTitle, originalTitle). | ||
| 173 | expect(spies.commitRename).toHaveBeenCalledTimes(1); | ||
| 174 | expect(spies.commitRename).toHaveBeenCalledWith( | ||
| 175 | expect.anything(), | ||
| 176 | "Renamed thread", | ||
| 177 | INITIAL_TITLE, | ||
| 178 | ); | ||
| 179 | |||
| 180 | // Input is gone and the row now shows the persisted title. | ||
| 181 | const title = page.getByTestId(TITLE_TESTID); | ||
| 182 | await expect.element(title).toBeVisible(); | ||
| 183 | await expect.element(title).toHaveTextContent("Renamed thread"); | ||
| 184 | }); | ||
| 185 | |||
| 186 | it("Escape cancels the rename without committing", async () => { | ||
| 187 | render(<Harness />); | ||
| 188 | |||
| 189 | await userEvent.dblClick(page.getByTestId(ROW_TESTID)); | ||
| 190 | await expect.element(page.getByRole("textbox")).toBeVisible(); | ||
| 191 | |||
| 192 | await userEvent.keyboard("{Escape}"); | ||
| 193 | |||
| 194 | expect(spies.commitRename).not.toHaveBeenCalled(); | ||
| 195 | const title = page.getByTestId(TITLE_TESTID); | ||
| 196 | await expect.element(title).toBeVisible(); | ||
| 197 | await expect.element(title).toHaveTextContent(INITIAL_TITLE); | ||
| 198 | }); | ||
| 199 | |||
| 200 | it("double-clicking inside the rename input keeps the edit (does not reset to the title)", async () => { | ||
| 201 | render(<Harness />); | ||
| 202 | |||
| 203 | await userEvent.dblClick(page.getByTestId(ROW_TESTID)); | ||
| 204 | const input = page.getByRole("textbox"); | ||
| 205 | await expect.element(input).toBeVisible(); | ||
| 206 | |||
| 207 | await userEvent.fill(input, "Edited but not committed"); | ||
| 208 | // Double-clicking inside the input (e.g. to select a word) must not bubble | ||
| 209 | // to the row and restart the rename, which would wipe the edit. | ||
| 210 | await userEvent.dblClick(input); | ||
| 211 | |||
| 212 | expect((input.element() as HTMLInputElement).value).toBe("Edited but not committed"); | ||
| 213 | expect(spies.commitRename).not.toHaveBeenCalled(); | ||
| 214 | }); | ||
| 215 | |||
| 216 | it("double-clicking the row chrome while already renaming does not restart/reset it", async () => { | ||
| 217 | render(<Harness />); | ||
| 218 | |||
| 219 | await userEvent.dblClick(page.getByTestId(ROW_TESTID)); | ||
| 220 | const input = page.getByRole("textbox"); | ||
| 221 | await expect.element(input).toBeVisible(); | ||
| 222 | await userEvent.fill(input, "Edited"); | ||
| 223 | expect(spies.startThreadRename).toHaveBeenCalledTimes(1); | ||
| 224 | |||
| 225 | // Double-click the row element itself (chrome, not the input). | ||
| 226 | const rowEl = page.getByTestId(ROW_TESTID).element(); | ||
| 227 | rowEl.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, detail: 2 })); | ||
| 228 | |||
| 229 | // Guard short-circuits: rename is not restarted and the edit is preserved. | ||
| 230 | expect(spies.startThreadRename).toHaveBeenCalledTimes(1); | ||
| 231 | expect((input.element() as HTMLInputElement).value).toBe("Edited"); | ||
| 232 | }); | ||
| 233 | |||
| 234 | it("modifier double-click is multi-select intent and does not start a rename", async () => { | ||
| 235 | render(<Harness />); | ||
| 236 | |||
| 237 | await userEvent.keyboard("{Shift>}"); | ||
| 238 | await userEvent.dblClick(page.getByTestId(ROW_TESTID)); | ||
| 239 | await userEvent.keyboard("{/Shift}"); | ||
| 240 | |||
| 241 | await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); | ||
| 242 | expect(page.getByRole("textbox").elements()).toHaveLength(0); | ||
| 243 | }); | ||
| 244 | |||
| 245 | it("single click routes through the navigation handler and does not start a rename", async () => { | ||
| 246 | render(<Harness />); | ||
| 247 | |||
| 248 | await userEvent.click(page.getByTestId(ROW_TESTID)); | ||
| 249 | |||
| 250 | expect(spies.handleThreadClick).toHaveBeenCalledTimes(1); | ||
| 251 | // No rename input: the title span is still shown. | ||
| 252 | await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); | ||
| 253 | expect(page.getByRole("textbox").elements()).toHaveLength(0); | ||
| 254 | }); | ||
| 255 | }); | ||
apps/web/src/components/Sidebar.logic.test.ts+71-35| ... | @@ -1,6 +1,4 @@ | ... | @@ -1,6 +1,4 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | 2 | import { |
| 5 | createThreadJumpHintVisibilityController, | 3 | createThreadJumpHintVisibilityController, |
| 6 | getSidebarThreadIdsToPrewarm, | 4 | getSidebarThreadIdsToPrewarm, |
| ... | @@ -66,6 +64,20 @@ describe("hasUnseenCompletion", () => { | ... | @@ -66,6 +64,20 @@ describe("hasUnseenCompletion", () => { |
| 66 | }), | 64 | }), |
| 67 | ).toBe(true); | 65 | ).toBe(true); |
| 68 | }); | 66 | }); |
| 67 | |||
| 68 | it("treats a missing client visit marker as read", () => { | ||
| 69 | expect( | ||
| 70 | hasUnseenCompletion({ | ||
| 71 | hasActionableProposedPlan: false, | ||
| 72 | hasPendingApprovals: false, | ||
| 73 | hasPendingUserInput: false, | ||
| 74 | interactionMode: "default", | ||
| 75 | latestTurn: makeLatestTurn(), | ||
| 76 | lastVisitedAt: undefined, | ||
| 77 | session: null, | ||
| 78 | }), | ||
| 79 | ).toBe(false); | ||
| 80 | }); | ||
| 69 | }); | 81 | }); |
| 70 | 82 | ||
| 71 | describe("createThreadJumpHintVisibilityController", () => { | 83 | describe("createThreadJumpHintVisibilityController", () => { |
| ... | @@ -346,17 +358,17 @@ describe("orderItemsByPreferredIds", () => { | ... | @@ -346,17 +358,17 @@ describe("orderItemsByPreferredIds", () => { |
| 346 | { | 358 | { |
| 347 | environmentId: EnvironmentId.make("environment-local"), | 359 | environmentId: EnvironmentId.make("environment-local"), |
| 348 | id: ProjectId.make("id-alpha"), | 360 | id: ProjectId.make("id-alpha"), |
| 349 | cwd: "/work/alpha", | 361 | workspaceRoot: "/work/alpha", |
| 350 | }, | 362 | }, |
| 351 | { | 363 | { |
| 352 | environmentId: EnvironmentId.make("environment-local"), | 364 | environmentId: EnvironmentId.make("environment-local"), |
| 353 | id: ProjectId.make("id-beta"), | 365 | id: ProjectId.make("id-beta"), |
| 354 | cwd: "/work/beta", | 366 | workspaceRoot: "/work/beta", |
| 355 | }, | 367 | }, |
| 356 | { | 368 | { |
| 357 | environmentId: EnvironmentId.make("environment-local"), | 369 | environmentId: EnvironmentId.make("environment-local"), |
| 358 | id: ProjectId.make("id-gamma"), | 370 | id: ProjectId.make("id-gamma"), |
| 359 | cwd: "/work/gamma", | 371 | workspaceRoot: "/work/gamma", |
| 360 | }, | 372 | }, |
| 361 | ]; | 373 | ]; |
| 362 | const ordered = orderItemsByPreferredIds({ | 374 | const ordered = orderItemsByPreferredIds({ |
| ... | @@ -365,12 +377,31 @@ describe("orderItemsByPreferredIds", () => { | ... | @@ -365,12 +377,31 @@ describe("orderItemsByPreferredIds", () => { |
| 365 | getId: getProjectOrderKey, | 377 | getId: getProjectOrderKey, |
| 366 | }); | 378 | }); |
| 367 | 379 | ||
| 368 | expect(ordered.map((project) => project.cwd)).toEqual([ | 380 | expect(ordered.map((project) => project.workspaceRoot)).toEqual([ |
| 369 | "/work/gamma", | 381 | "/work/gamma", |
| 370 | "/work/alpha", | 382 | "/work/alpha", |
| 371 | "/work/beta", | 383 | "/work/beta", |
| 372 | ]); | 384 | ]); |
| 373 | }); | 385 | }); |
| 386 | |||
| 387 | it("resolves legacy preference aliases without materializing project state", () => { | ||
| 388 | const ordered = orderItemsByPreferredIds({ | ||
| 389 | items: [ | ||
| 390 | { id: "physical-a", cwd: "/work/a" }, | ||
| 391 | { id: "physical-b", cwd: "/work/b" }, | ||
| 392 | { id: "physical-c", cwd: "/work/c" }, | ||
| 393 | ], | ||
| 394 | preferredIds: ["legacy:/work/c", "legacy:/work/a"], | ||
| 395 | getId: (project) => project.id, | ||
| 396 | getPreferenceIds: (project) => [project.id, `legacy:${project.cwd}`], | ||
| 397 | }); | ||
| 398 | |||
| 399 | expect(ordered.map((project) => project.id)).toEqual([ | ||
| 400 | "physical-c", | ||
| 401 | "physical-a", | ||
| 402 | "physical-b", | ||
| 403 | ]); | ||
| 404 | }); | ||
| 374 | }); | 405 | }); |
| 375 | 406 | ||
| 376 | describe("resolveAdjacentThreadId", () => { | 407 | describe("resolveAdjacentThreadId", () => { |
| ... | @@ -500,11 +531,14 @@ describe("resolveThreadStatusPill", () => { | ... | @@ -500,11 +531,14 @@ describe("resolveThreadStatusPill", () => { |
| 500 | latestTurn: null, | 531 | latestTurn: null, |
| 501 | lastVisitedAt: undefined, | 532 | lastVisitedAt: undefined, |
| 502 | session: { | 533 | session: { |
| 503 | provider: ProviderDriverKind.make("codex"), | 534 | threadId: ThreadId.make("thread-1"), |
| 504 | status: "running" as const, | 535 | status: "running" as const, |
| 505 | createdAt: "2026-03-09T10:00:00.000Z", | 536 | providerName: "Codex", |
| 537 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 538 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 539 | activeTurnId: "turn-1" as never, | ||
| 540 | lastError: null, | ||
| 506 | updatedAt: "2026-03-09T10:00:00.000Z", | 541 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 507 | orchestrationStatus: "running" as const, | ||
| 508 | }, | 542 | }, |
| 509 | }; | 543 | }; |
| 510 | 544 | ||
| ... | @@ -549,14 +583,14 @@ describe("resolveThreadStatusPill", () => { | ... | @@ -549,14 +583,14 @@ describe("resolveThreadStatusPill", () => { |
| 549 | session: { | 583 | session: { |
| 550 | ...baseThread.session, | 584 | ...baseThread.session, |
| 551 | status: "ready", | 585 | status: "ready", |
| 552 | orchestrationStatus: "ready", | 586 | activeTurnId: null, |
| 553 | }, | 587 | }, |
| 554 | }, | 588 | }, |
| 555 | }), | 589 | }), |
| 556 | ).toMatchObject({ label: "Plan Ready", pulse: false }); | 590 | ).toMatchObject({ label: "Plan Ready", pulse: false }); |
| 557 | }); | 591 | }); |
| 558 | 592 | ||
| 559 | it("does not show plan ready after the proposed plan was implemented elsewhere", () => { | 593 | it("does not manufacture completed state without a client visit marker", () => { |
| 560 | expect( | 594 | expect( |
| 561 | resolveThreadStatusPill({ | 595 | resolveThreadStatusPill({ |
| 562 | thread: { | 596 | thread: { |
| ... | @@ -565,11 +599,11 @@ describe("resolveThreadStatusPill", () => { | ... | @@ -565,11 +599,11 @@ describe("resolveThreadStatusPill", () => { |
| 565 | session: { | 599 | session: { |
| 566 | ...baseThread.session, | 600 | ...baseThread.session, |
| 567 | status: "ready", | 601 | status: "ready", |
| 568 | orchestrationStatus: "ready", | 602 | activeTurnId: null, |
| 569 | }, | 603 | }, |
| 570 | }, | 604 | }, |
| 571 | }), | 605 | }), |
| 572 | ).toMatchObject({ label: "Completed", pulse: false }); | 606 | ).toBeNull(); |
| 573 | }); | 607 | }); |
| 574 | 608 | ||
| 575 | it("shows completed when there is an unseen completion and no active blocker", () => { | 609 | it("shows completed when there is an unseen completion and no active blocker", () => { |
| ... | @@ -583,7 +617,7 @@ describe("resolveThreadStatusPill", () => { | ... | @@ -583,7 +617,7 @@ describe("resolveThreadStatusPill", () => { |
| 583 | session: { | 617 | session: { |
| 584 | ...baseThread.session, | 618 | ...baseThread.session, |
| 585 | status: "ready", | 619 | status: "ready", |
| 586 | orchestrationStatus: "ready", | 620 | activeTurnId: null, |
| 587 | }, | 621 | }, |
| 588 | }, | 622 | }, |
| 589 | }), | 623 | }), |
| ... | @@ -721,8 +755,9 @@ function makeProject(overrides: Partial<Project> = {}): Project { | ... | @@ -721,8 +755,9 @@ function makeProject(overrides: Partial<Project> = {}): Project { |
| 721 | return { | 755 | return { |
| 722 | id: ProjectId.make("project-1"), | 756 | id: ProjectId.make("project-1"), |
| 723 | environmentId: localEnvironmentId, | 757 | environmentId: localEnvironmentId, |
| 724 | name: "Project", | 758 | title: "Project", |
| 725 | cwd: "/tmp/project", | 759 | workspaceRoot: "/tmp/project", |
| 760 | repositoryIdentity: null, | ||
| 726 | defaultModelSelection: { | 761 | defaultModelSelection: { |
| 727 | instanceId: ProviderInstanceId.make("codex"), | 762 | instanceId: ProviderInstanceId.make("codex"), |
| 728 | model: "gpt-5.4", | 763 | model: "gpt-5.4", |
| ... | @@ -739,7 +774,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -739,7 +774,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 739 | return { | 774 | return { |
| 740 | id: ThreadId.make("thread-1"), | 775 | id: ThreadId.make("thread-1"), |
| 741 | environmentId: localEnvironmentId, | 776 | environmentId: localEnvironmentId, |
| 742 | codexThreadId: null, | ||
| 743 | projectId: ProjectId.make("project-1"), | 777 | projectId: ProjectId.make("project-1"), |
| 744 | title: "Thread", | 778 | title: "Thread", |
| 745 | modelSelection: { | 779 | modelSelection: { |
| ... | @@ -752,14 +786,14 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -752,14 +786,14 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 752 | session: null, | 786 | session: null, |
| 753 | messages: [], | 787 | messages: [], |
| 754 | proposedPlans: [], | 788 | proposedPlans: [], |
| 755 | error: null, | ||
| 756 | createdAt: "2026-03-09T10:00:00.000Z", | 789 | createdAt: "2026-03-09T10:00:00.000Z", |
| 757 | archivedAt: null, | 790 | archivedAt: null, |
| 791 | deletedAt: null, | ||
| 758 | updatedAt: "2026-03-09T10:00:00.000Z", | 792 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 759 | latestTurn: null, | 793 | latestTurn: null, |
| 760 | branch: null, | 794 | branch: null, |
| 761 | worktreePath: null, | 795 | worktreePath: null, |
| 762 | turnDiffSummaries: [], | 796 | checkpoints: [], |
| 763 | activities: [], | 797 | activities: [], |
| 764 | ...overrides, | 798 | ...overrides, |
| 765 | }; | 799 | }; |
| ... | @@ -834,8 +868,8 @@ describe("getFallbackThreadIdAfterDelete", () => { | ... | @@ -834,8 +868,8 @@ describe("getFallbackThreadIdAfterDelete", () => { |
| 834 | describe("sortProjectsForSidebar", () => { | 868 | describe("sortProjectsForSidebar", () => { |
| 835 | it("sorts projects by the most recent user message across their threads", () => { | 869 | it("sorts projects by the most recent user message across their threads", () => { |
| 836 | const projects = [ | 870 | const projects = [ |
| 837 | makeProject({ id: ProjectId.make("project-1"), name: "Older project" }), | 871 | makeProject({ id: ProjectId.make("project-1"), title: "Older project" }), |
| 838 | makeProject({ id: ProjectId.make("project-2"), name: "Newer project" }), | 872 | makeProject({ id: ProjectId.make("project-2"), title: "Newer project" }), |
| 839 | ]; | 873 | ]; |
| 840 | const threads = [ | 874 | const threads = [ |
| 841 | makeThread({ | 875 | makeThread({ |
| ... | @@ -846,9 +880,10 @@ describe("sortProjectsForSidebar", () => { | ... | @@ -846,9 +880,10 @@ describe("sortProjectsForSidebar", () => { |
| 846 | id: "message-1" as never, | 880 | id: "message-1" as never, |
| 847 | role: "user", | 881 | role: "user", |
| 848 | text: "older project user message", | 882 | text: "older project user message", |
| 883 | turnId: null, | ||
| 849 | createdAt: "2026-03-09T10:01:00.000Z", | 884 | createdAt: "2026-03-09T10:01:00.000Z", |
| 885 | updatedAt: "2026-03-09T10:01:00.000Z", | ||
| 850 | streaming: false, | 886 | streaming: false, |
| 851 | completedAt: "2026-03-09T10:01:00.000Z", | ||
| 852 | }, | 887 | }, |
| 853 | ], | 888 | ], |
| 854 | }), | 889 | }), |
| ... | @@ -861,9 +896,10 @@ describe("sortProjectsForSidebar", () => { | ... | @@ -861,9 +896,10 @@ describe("sortProjectsForSidebar", () => { |
| 861 | id: "message-2" as never, | 896 | id: "message-2" as never, |
| 862 | role: "user", | 897 | role: "user", |
| 863 | text: "newer project user message", | 898 | text: "newer project user message", |
| 899 | turnId: null, | ||
| 864 | createdAt: "2026-03-09T10:05:00.000Z", | 900 | createdAt: "2026-03-09T10:05:00.000Z", |
| 901 | updatedAt: "2026-03-09T10:05:00.000Z", | ||
| 865 | streaming: false, | 902 | streaming: false, |
| 866 | completedAt: "2026-03-09T10:05:00.000Z", | ||
| 867 | }, | 903 | }, |
| 868 | ], | 904 | ], |
| 869 | }), | 905 | }), |
| ... | @@ -882,12 +918,12 @@ describe("sortProjectsForSidebar", () => { | ... | @@ -882,12 +918,12 @@ describe("sortProjectsForSidebar", () => { |
| 882 | [ | 918 | [ |
| 883 | makeProject({ | 919 | makeProject({ |
| 884 | id: ProjectId.make("project-1"), | 920 | id: ProjectId.make("project-1"), |
| 885 | name: "Older project", | 921 | title: "Older project", |
| 886 | updatedAt: "2026-03-09T10:01:00.000Z", | 922 | updatedAt: "2026-03-09T10:01:00.000Z", |
| 887 | }), | 923 | }), |
| 888 | makeProject({ | 924 | makeProject({ |
| 889 | id: ProjectId.make("project-2"), | 925 | id: ProjectId.make("project-2"), |
| 890 | name: "Newer project", | 926 | title: "Newer project", |
| 891 | updatedAt: "2026-03-09T10:05:00.000Z", | 927 | updatedAt: "2026-03-09T10:05:00.000Z", |
| 892 | }), | 928 | }), |
| 893 | ], | 929 | ], |
| ... | @@ -906,15 +942,15 @@ describe("sortProjectsForSidebar", () => { | ... | @@ -906,15 +942,15 @@ describe("sortProjectsForSidebar", () => { |
| 906 | [ | 942 | [ |
| 907 | makeProject({ | 943 | makeProject({ |
| 908 | id: ProjectId.make("project-2"), | 944 | id: ProjectId.make("project-2"), |
| 909 | name: "Beta", | 945 | title: "Beta", |
| 910 | createdAt: undefined, | 946 | createdAt: "invalid-created-at" as never, |
| 911 | updatedAt: undefined, | 947 | updatedAt: "invalid-updated-at" as never, |
| 912 | }), | 948 | }), |
| 913 | makeProject({ | 949 | makeProject({ |
| 914 | id: ProjectId.make("project-1"), | 950 | id: ProjectId.make("project-1"), |
| 915 | name: "Alpha", | 951 | title: "Alpha", |
| 916 | createdAt: undefined, | 952 | createdAt: "invalid-created-at" as never, |
| 917 | updatedAt: undefined, | 953 | updatedAt: "invalid-updated-at" as never, |
| 918 | }), | 954 | }), |
| 919 | ], | 955 | ], |
| 920 | [], | 956 | [], |
| ... | @@ -929,8 +965,8 @@ describe("sortProjectsForSidebar", () => { | ... | @@ -929,8 +965,8 @@ describe("sortProjectsForSidebar", () => { |
| 929 | 965 | ||
| 930 | it("preserves manual project ordering", () => { | 966 | it("preserves manual project ordering", () => { |
| 931 | const projects = [ | 967 | const projects = [ |
| 932 | makeProject({ id: ProjectId.make("project-2"), name: "Second" }), | 968 | makeProject({ id: ProjectId.make("project-2"), title: "Second" }), |
| 933 | makeProject({ id: ProjectId.make("project-1"), name: "First" }), | 969 | makeProject({ id: ProjectId.make("project-1"), title: "First" }), |
| 934 | ]; | 970 | ]; |
| 935 | 971 | ||
| 936 | const sorted = sortProjectsForSidebar(projects, [], "manual"); | 972 | const sorted = sortProjectsForSidebar(projects, [], "manual"); |
| ... | @@ -946,12 +982,12 @@ describe("sortProjectsForSidebar", () => { | ... | @@ -946,12 +982,12 @@ describe("sortProjectsForSidebar", () => { |
| 946 | [ | 982 | [ |
| 947 | makeProject({ | 983 | makeProject({ |
| 948 | id: ProjectId.make("project-1"), | 984 | id: ProjectId.make("project-1"), |
| 949 | name: "Visible project", | 985 | title: "Visible project", |
| 950 | updatedAt: "2026-03-09T10:01:00.000Z", | 986 | updatedAt: "2026-03-09T10:01:00.000Z", |
| 951 | }), | 987 | }), |
| 952 | makeProject({ | 988 | makeProject({ |
| 953 | id: ProjectId.make("project-2"), | 989 | id: ProjectId.make("project-2"), |
| 954 | name: "Archived-only project", | 990 | title: "Archived-only project", |
| 955 | updatedAt: "2026-03-09T10:00:00.000Z", | 991 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 956 | }), | 992 | }), |
| 957 | ], | 993 | ], |
apps/web/src/components/Sidebar.logic.ts+27-16| ... | @@ -18,7 +18,7 @@ export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; | ... | @@ -18,7 +18,7 @@ export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; |
| 18 | export type SidebarNewThreadEnvMode = "local" | "worktree"; | 18 | export type SidebarNewThreadEnvMode = "local" | "worktree"; |
| 19 | type SidebarProject = { | 19 | type SidebarProject = { |
| 20 | id: string; | 20 | id: string; |
| 21 | name: string; | 21 | title: string; |
| 22 | createdAt?: string | undefined; | 22 | createdAt?: string | undefined; |
| 23 | updatedAt?: string | undefined; | 23 | updatedAt?: string | undefined; |
| 24 | }; | 24 | }; |
| ... | @@ -148,7 +148,7 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { | ... | @@ -148,7 +148,7 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { |
| 148 | if (!thread.latestTurn?.completedAt) return false; | 148 | if (!thread.latestTurn?.completedAt) return false; |
| 149 | const completedAt = Date.parse(thread.latestTurn.completedAt); | 149 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| 150 | if (Number.isNaN(completedAt)) return false; | 150 | if (Number.isNaN(completedAt)) return false; |
| 151 | if (!thread.lastVisitedAt) return true; | 151 | if (!thread.lastVisitedAt) return false; |
| 152 | 152 | ||
| 153 | const lastVisitedAt = Date.parse(thread.lastVisitedAt); | 153 | const lastVisitedAt = Date.parse(thread.lastVisitedAt); |
| 154 | if (Number.isNaN(lastVisitedAt)) return true; | 154 | if (Number.isNaN(lastVisitedAt)) return true; |
| ... | @@ -226,27 +226,38 @@ export function orderItemsByPreferredIds<TItem, TId>(input: { | ... | @@ -226,27 +226,38 @@ export function orderItemsByPreferredIds<TItem, TId>(input: { |
| 226 | items: readonly TItem[]; | 226 | items: readonly TItem[]; |
| 227 | preferredIds: readonly TId[]; | 227 | preferredIds: readonly TId[]; |
| 228 | getId: (item: TItem) => TId; | 228 | getId: (item: TItem) => TId; |
| 229 | getPreferenceIds?: (item: TItem) => readonly TId[]; | ||
| 229 | }): TItem[] { | 230 | }): TItem[] { |
| 230 | const { getId, items, preferredIds } = input; | 231 | const { getId, getPreferenceIds, items, preferredIds } = input; |
| 231 | if (preferredIds.length === 0) { | 232 | if (preferredIds.length === 0) { |
| 232 | return [...items]; | 233 | return [...items]; |
| 233 | } | 234 | } |
| 234 | 235 | ||
| 235 | const itemsById = new Map(items.map((item) => [getId(item), item] as const)); | 236 | const indexesByPreferenceId = new Map<TId, number[]>(); |
| 236 | const preferredIdSet = new Set(preferredIds); | 237 | for (const [index, item] of items.entries()) { |
| 237 | const emittedPreferredIds = new Set<TId>(); | 238 | const preferenceIds = getPreferenceIds?.(item) ?? [getId(item)]; |
| 238 | const ordered = preferredIds.flatMap((id) => { | 239 | for (const preferenceId of new Set(preferenceIds)) { |
| 239 | if (emittedPreferredIds.has(id)) { | 240 | const indexes = indexesByPreferenceId.get(preferenceId); |
| 240 | return []; | 241 | if (indexes) { |
| 242 | indexes.push(index); | ||
| 243 | } else { | ||
| 244 | indexesByPreferenceId.set(preferenceId, [index]); | ||
| 245 | } | ||
| 241 | } | 246 | } |
| 242 | const item = itemsById.get(id); | 247 | } |
| 243 | if (!item) { | 248 | |
| 249 | const emittedIndexes = new Set<number>(); | ||
| 250 | const ordered = preferredIds.flatMap((id) => { | ||
| 251 | const index = indexesByPreferenceId | ||
| 252 | .get(id) | ||
| 253 | ?.find((candidate) => !emittedIndexes.has(candidate)); | ||
| 254 | if (index === undefined) { | ||
| 244 | return []; | 255 | return []; |
| 245 | } | 256 | } |
| 246 | emittedPreferredIds.add(id); | 257 | emittedIndexes.add(index); |
| 247 | return [item]; | 258 | return [items[index]!]; |
| 248 | }); | 259 | }); |
| 249 | const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); | 260 | const remaining = items.filter((_, index) => !emittedIndexes.has(index)); |
| 250 | return [...ordered, ...remaining]; | 261 | return [...ordered, ...remaining]; |
| 251 | } | 262 | } |
| 252 | 263 | ||
| ... | @@ -367,7 +378,7 @@ export function resolveThreadStatusPill(input: { | ... | @@ -367,7 +378,7 @@ export function resolveThreadStatusPill(input: { |
| 367 | }; | 378 | }; |
| 368 | } | 379 | } |
| 369 | 380 | ||
| 370 | if (thread.session?.status === "connecting") { | 381 | if (thread.session?.status === "starting") { |
| 371 | return { | 382 | return { |
| 372 | label: "Connecting", | 383 | label: "Connecting", |
| 373 | colorClass: "text-sky-600 dark:text-sky-300/80", | 384 | colorClass: "text-sky-600 dark:text-sky-300/80", |
| ... | @@ -545,6 +556,6 @@ export function sortProjectsForSidebar< | ... | @@ -545,6 +556,6 @@ export function sortProjectsForSidebar< |
| 545 | const byTimestamp = | 556 | const byTimestamp = |
| 546 | rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; | 557 | rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; |
| 547 | if (byTimestamp !== 0) return byTimestamp; | 558 | if (byTimestamp !== 0) return byTimestamp; |
| 548 | return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); | 559 | return left.title.localeCompare(right.title) || left.id.localeCompare(right.id); |
| 549 | }); | 560 | }); |
| 550 | } | 561 | } |
apps/web/src/components/Sidebar.tsx+340-267| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | ThreadStatusLabel, | 19 | ThreadStatusLabel, |
| 20 | } from "./ThreadStatusIndicators"; | 20 | } from "./ThreadStatusIndicators"; |
| 21 | import { ProjectFavicon } from "./ProjectFavicon"; | 21 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 22 | import { useAtomValue } from "@effect/atom-react"; | ||
| 22 | import { autoAnimate } from "@formkit/auto-animate"; | 23 | import { autoAnimate } from "@formkit/auto-animate"; |
| 23 | import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; | 24 | import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; |
| 24 | import { useShallow } from "zustand/react/shallow"; | 25 | import { useShallow } from "zustand/react/shallow"; |
| ... | @@ -39,9 +40,9 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- | ... | @@ -39,9 +40,9 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- |
| 39 | import { CSS } from "@dnd-kit/utilities"; | 40 | import { CSS } from "@dnd-kit/utilities"; |
| 40 | import { | 41 | import { |
| 41 | type ContextMenuItem, | 42 | type ContextMenuItem, |
| 42 | type DesktopUpdateState, | ||
| 43 | ProjectId, | 43 | ProjectId, |
| 44 | type ScopedThreadRef, | 44 | type ScopedThreadRef, |
| 45 | type ResolvedKeybindingsConfig, | ||
| 45 | type SidebarProjectGroupingMode, | 46 | type SidebarProjectGroupingMode, |
| 46 | type ThreadEnvMode, | 47 | type ThreadEnvMode, |
| 47 | ThreadId, | 48 | ThreadId, |
| ... | @@ -52,7 +53,12 @@ import { | ... | @@ -52,7 +53,12 @@ import { |
| 52 | scopedThreadKey, | 53 | scopedThreadKey, |
| 53 | scopeProjectRef, | 54 | scopeProjectRef, |
| 54 | scopeThreadRef, | 55 | scopeThreadRef, |
| 55 | } from "@t3tools/client-runtime"; | 56 | } from "@t3tools/client-runtime/environment"; |
| 57 | import { | ||
| 58 | isAtomCommandInterrupted, | ||
| 59 | settlePromise, | ||
| 60 | squashAtomCommandFailure, | ||
| 61 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 56 | import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; | 62 | import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; |
| 57 | import { | 63 | import { |
| 58 | MAX_SIDEBAR_THREAD_PREVIEW_COUNT, | 64 | MAX_SIDEBAR_THREAD_PREVIEW_COUNT, |
| ... | @@ -61,23 +67,28 @@ import { | ... | @@ -61,23 +67,28 @@ import { |
| 61 | type SidebarThreadPreviewCount, | 67 | type SidebarThreadPreviewCount, |
| 62 | type SidebarThreadSortOrder, | 68 | type SidebarThreadSortOrder, |
| 63 | } from "@t3tools/contracts/settings"; | 69 | } from "@t3tools/contracts/settings"; |
| 64 | import { usePrimaryEnvironmentId } from "../environments/primary"; | ||
| 65 | import { isElectron } from "../env"; | 70 | import { isElectron } from "../env"; |
| 66 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; | 71 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 67 | import { isTerminalFocused } from "../lib/terminalFocus"; | 72 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 68 | import { isMacPlatform, newCommandId } from "../lib/utils"; | 73 | import { isMacPlatform } from "../lib/utils"; |
| 69 | import { | 74 | import { |
| 70 | selectProjectByRef, | 75 | readThreadShell, |
| 71 | selectProjectsAcrossEnvironments, | 76 | useProject, |
| 72 | selectSidebarThreadsForProjectRefs, | 77 | useProjects, |
| 73 | selectSidebarThreadsAcrossEnvironments, | 78 | useThreadShells, |
| 74 | selectThreadByRef, | 79 | useThreadShellsForProjectRefs, |
| 75 | useStore, | 80 | } from "../state/entities"; |
| 76 | } from "../store"; | ||
| 77 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 81 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 78 | import { useThreadRunningTerminalIds } from "../terminalSessionState"; | 82 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 79 | import { useThreadDiscoveredPorts } from "../portDiscoveryState"; | 83 | import { useThreadDiscoveredPorts } from "../portDiscoveryState"; |
| 80 | import { useUiStateStore } from "../uiStateStore"; | 84 | import { openDiscoveredPort } from "./preview/openDiscoveredPort"; |
| 85 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 86 | import { previewEnvironment } from "../state/preview"; | ||
| 87 | import { | ||
| 88 | legacyProjectCwdPreferenceKey, | ||
| 89 | resolveProjectExpanded, | ||
| 90 | useUiStateStore, | ||
| 91 | } from "../uiStateStore"; | ||
| 81 | import { | 92 | import { |
| 82 | resolveShortcutCommand, | 93 | resolveShortcutCommand, |
| 83 | shortcutLabelForCommand, | 94 | shortcutLabelForCommand, |
| ... | @@ -86,15 +97,19 @@ import { | ... | @@ -86,15 +97,19 @@ import { |
| 86 | threadJumpIndexFromCommand, | 97 | threadJumpIndexFromCommand, |
| 87 | threadTraversalDirectionFromCommand, | 98 | threadTraversalDirectionFromCommand, |
| 88 | } from "../keybindings"; | 99 | } from "../keybindings"; |
| 89 | import { useModelPickerOpen } from "../modelPickerOpenState"; | 100 | import { isModelPickerOpen } from "../modelPickerVisibility"; |
| 90 | import { useShortcutModifierState } from "../shortcutModifierState"; | 101 | import { useShortcutModifierState } from "../shortcutModifierState"; |
| 91 | import { useVcsStatus } from "../lib/vcsStatusState"; | ||
| 92 | import { readLocalApi } from "../localApi"; | 102 | import { readLocalApi } from "../localApi"; |
| 93 | import { useComposerDraftStore } from "../composerDraftStore"; | 103 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 94 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 104 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 95 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; | 105 | import { useDesktopUpdateState } from "../state/desktopUpdate"; |
| 96 | 106 | ||
| 97 | import { useThreadActions } from "../hooks/useThreadActions"; | 107 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 108 | import { projectEnvironment } from "../state/projects"; | ||
| 109 | import { useEnvironmentQuery } from "../state/query"; | ||
| 110 | import { threadEnvironment, useEnvironmentThread } from "../state/threads"; | ||
| 111 | import { vcsEnvironment } from "../state/vcs"; | ||
| 112 | import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | ||
| 98 | import { | 113 | import { |
| 99 | buildThreadRouteParams, | 114 | buildThreadRouteParams, |
| 100 | resolveThreadRouteRef, | 115 | resolveThreadRouteRef, |
| ... | @@ -159,7 +174,7 @@ import { | ... | @@ -159,7 +174,7 @@ import { |
| 159 | useSidebar, | 174 | useSidebar, |
| 160 | } from "./ui/sidebar"; | 175 | } from "./ui/sidebar"; |
| 161 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 176 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 162 | import { useCommandPaletteStore } from "../commandPaletteStore"; | 177 | import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; |
| 163 | import { | 178 | import { |
| 164 | getSidebarThreadIdsToPrewarm, | 179 | getSidebarThreadIdsToPrewarm, |
| 165 | resolveAdjacentThreadId, | 180 | resolveAdjacentThreadId, |
| ... | @@ -181,19 +196,14 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; | ... | @@ -181,19 +196,14 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 181 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 196 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 182 | import { useIsMobile } from "~/hooks/useMediaQuery"; | 197 | import { useIsMobile } from "~/hooks/useMediaQuery"; |
| 183 | import { CommandDialogTrigger } from "./ui/command"; | 198 | import { CommandDialogTrigger } from "./ui/command"; |
| 184 | import { readEnvironmentApi } from "../environmentApi"; | ||
| 185 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | 199 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 186 | import { useServerKeybindings } from "../rpc/serverState"; | 200 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 187 | import { | 201 | import { |
| 188 | derivePhysicalProjectKey, | 202 | derivePhysicalProjectKey, |
| 189 | deriveProjectGroupingOverrideKey, | 203 | deriveProjectGroupingOverrideKey, |
| 190 | getProjectOrderKey, | 204 | getProjectOrderKey, |
| 191 | selectProjectGroupingSettings, | 205 | selectProjectGroupingSettings, |
| 192 | } from "../logicalProject"; | 206 | } from "../logicalProject"; |
| 193 | import { | ||
| 194 | useSavedEnvironmentRegistryStore, | ||
| 195 | useSavedEnvironmentRuntimeStore, | ||
| 196 | } from "../environments/runtime"; | ||
| 197 | import type { SidebarThreadSummary } from "../types"; | 207 | import type { SidebarThreadSummary } from "../types"; |
| 198 | import { | 208 | import { |
| 199 | buildPhysicalToLogicalProjectKeyMap, | 209 | buildPhysicalToLogicalProjectKeyMap, |
| ... | @@ -202,7 +212,6 @@ import { | ... | @@ -202,7 +212,6 @@ import { |
| 202 | type SidebarProjectSnapshot, | 212 | type SidebarProjectSnapshot, |
| 203 | } from "../sidebarProjectGrouping"; | 213 | } from "../sidebarProjectGrouping"; |
| 204 | import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; | 214 | import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; |
| 205 | import { openDiscoveredPort } from "./preview/openDiscoveredPort"; | ||
| 206 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { | 215 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 207 | updated_at: "Last user message", | 216 | updated_at: "Last user message", |
| 208 | created_at: "Created at", | 217 | created_at: "Created at", |
| ... | @@ -225,6 +234,11 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = | ... | @@ -225,6 +234,11 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = |
| 225 | const SIDEBAR_ICON_ACTION_BUTTON_CLASS = | 234 | const SIDEBAR_ICON_ACTION_BUTTON_CLASS = |
| 226 | "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; | 235 | "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; |
| 227 | 236 | ||
| 237 | function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { | ||
| 238 | useEnvironmentThread(threadRef.environmentId, threadRef.threadId); | ||
| 239 | return null; | ||
| 240 | } | ||
| 241 | |||
| 228 | function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { | 242 | function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { |
| 229 | return Math.min( | 243 | return Math.min( |
| 230 | MAX_SIDEBAR_THREAD_PREVIEW_COUNT, | 244 | MAX_SIDEBAR_THREAD_PREVIEW_COUNT, |
| ... | @@ -237,10 +251,20 @@ function formatProjectMemberActionLabel( | ... | @@ -237,10 +251,20 @@ function formatProjectMemberActionLabel( |
| 237 | groupedProjectCount: number, | 251 | groupedProjectCount: number, |
| 238 | ): string { | 252 | ): string { |
| 239 | if (groupedProjectCount <= 1) { | 253 | if (groupedProjectCount <= 1) { |
| 240 | return member.name; | 254 | return member.title; |
| 241 | } | 255 | } |
| 242 | 256 | ||
| 243 | return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; | 257 | return member.environmentLabel |
| 258 | ? `${member.environmentLabel} — ${member.workspaceRoot}` | ||
| 259 | : member.workspaceRoot; | ||
| 260 | } | ||
| 261 | |||
| 262 | function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { | ||
| 263 | return [ | ||
| 264 | project.projectKey, | ||
| 265 | ...project.memberProjects.map((member) => member.physicalProjectKey), | ||
| 266 | ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), | ||
| 267 | ]; | ||
| 244 | } | 268 | } |
| 245 | 269 | ||
| 246 | function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { | 270 | function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { |
| ... | @@ -255,7 +279,7 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin | ... | @@ -255,7 +279,7 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin |
| 255 | } | 279 | } |
| 256 | 280 | ||
| 257 | function buildThreadJumpLabelMap(input: { | 281 | function buildThreadJumpLabelMap(input: { |
| 258 | keybindings: ReturnType<typeof useServerKeybindings>; | 282 | keybindings: ResolvedKeybindingsConfig; |
| 259 | platform: string; | 283 | platform: string; |
| 260 | terminalOpen: boolean; | 284 | terminalOpen: boolean; |
| 261 | threadJumpCommandByKey: ReadonlyMap< | 285 | threadJumpCommandByKey: ReadonlyMap< |
| ... | @@ -361,35 +385,60 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -361,35 +385,60 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 361 | environmentId: thread.environmentId, | 385 | environmentId: thread.environmentId, |
| 362 | threadId: thread.id, | 386 | threadId: thread.id, |
| 363 | }); | 387 | }); |
| 388 | const openPreview = useAtomCommand(previewEnvironment.open, { | ||
| 389 | reportFailure: false, | ||
| 390 | }); | ||
| 391 | const environment = useEnvironment(thread.environmentId); | ||
| 364 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 392 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 365 | const isRemoteThread = | 393 | const isRemoteThread = |
| 366 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; | 394 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; |
| 367 | const remoteEnvLabel = useSavedEnvironmentRuntimeStore( | 395 | const remoteEnvLabel = environment?.label ?? null; |
| 368 | (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, | 396 | const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; |
| 369 | ); | ||
| 370 | const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( | ||
| 371 | (s) => s.byId[thread.environmentId]?.label ?? null, | ||
| 372 | ); | ||
| 373 | const threadEnvironmentLabel = isRemoteThread | ||
| 374 | ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") | ||
| 375 | : null; | ||
| 376 | // For grouped projects, the thread may belong to a different environment | 397 | // For grouped projects, the thread may belong to a different environment |
| 377 | // than the representative project. Look up the thread's own project cwd | 398 | // than the representative project. Look up the thread's own project cwd |
| 378 | // so git status (and thus PR detection) queries the correct path. | 399 | // so git status (and thus PR detection) queries the correct path. |
| 379 | const threadProjectCwd = useStore( | 400 | const threadProject = useProject( |
| 380 | useMemo( | 401 | useMemo( |
| 381 | () => (state: import("../store").AppState) => | 402 | () => scopeProjectRef(thread.environmentId, thread.projectId), |
| 382 | selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? | ||
| 383 | null, | ||
| 384 | [thread.environmentId, thread.projectId], | 403 | [thread.environmentId, thread.projectId], |
| 385 | ), | 404 | ), |
| 386 | ); | 405 | ); |
| 406 | const threadProjectCwd = threadProject?.workspaceRoot ?? null; | ||
| 387 | const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; | 407 | const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; |
| 388 | const gitStatus = useVcsStatus({ | 408 | const gitStatus = useEnvironmentQuery( |
| 389 | environmentId: thread.environmentId, | 409 | thread.branch != null && gitCwd !== null |
| 390 | cwd: thread.branch != null ? gitCwd : null, | 410 | ? vcsEnvironment.status({ |
| 391 | }); | 411 | environmentId: thread.environmentId, |
| 412 | input: { cwd: gitCwd }, | ||
| 413 | }) | ||
| 414 | : null, | ||
| 415 | ); | ||
| 392 | const isHighlighted = isActive || isSelected; | 416 | const isHighlighted = isActive || isSelected; |
| 417 | const handleOpenDiscoveredPort = useCallback( | ||
| 418 | (event: React.MouseEvent<HTMLButtonElement>) => { | ||
| 419 | const port = discoveredPorts[0]; | ||
| 420 | if (!port) return; | ||
| 421 | event.preventDefault(); | ||
| 422 | event.stopPropagation(); | ||
| 423 | navigateToThread(threadRef); | ||
| 424 | void (async () => { | ||
| 425 | const result = await openDiscoveredPort({ threadRef, port, openPreview }); | ||
| 426 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | ||
| 427 | return; | ||
| 428 | } | ||
| 429 | const error = squashAtomCommandFailure(result); | ||
| 430 | toastManager.add( | ||
| 431 | stackedThreadToast({ | ||
| 432 | type: "error", | ||
| 433 | title: "Unable to open preview", | ||
| 434 | description: | ||
| 435 | error instanceof Error ? error.message : "The preview could not be opened.", | ||
| 436 | }), | ||
| 437 | ); | ||
| 438 | })(); | ||
| 439 | }, | ||
| 440 | [discoveredPorts, navigateToThread, openPreview, threadRef], | ||
| 441 | ); | ||
| 393 | const isThreadRunning = | 442 | const isThreadRunning = |
| 394 | thread.session?.status === "running" && thread.session.activeTurnId != null; | 443 | thread.session?.status === "running" && thread.session.activeTurnId != null; |
| 395 | const threadStatus = resolveThreadStatusPill({ | 444 | const threadStatus = resolveThreadStatusPill({ |
| ... | @@ -431,17 +480,6 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -431,17 +480,6 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 431 | }, | 480 | }, |
| 432 | [handleThreadClick, orderedProjectThreadKeys, threadRef], | 481 | [handleThreadClick, orderedProjectThreadKeys, threadRef], |
| 433 | ); | 482 | ); |
| 434 | const handleOpenDiscoveredPort = useCallback( | ||
| 435 | (event: React.MouseEvent<HTMLButtonElement>) => { | ||
| 436 | const port = discoveredPorts[0]; | ||
| 437 | if (!port) return; | ||
| 438 | event.preventDefault(); | ||
| 439 | event.stopPropagation(); | ||
| 440 | navigateToThread(threadRef); | ||
| 441 | void openDiscoveredPort({ threadRef, port }); | ||
| 442 | }, | ||
| 443 | [discoveredPorts, navigateToThread, threadRef], | ||
| 444 | ); | ||
| 445 | const handleRowDoubleClick = useCallback( | 483 | const handleRowDoubleClick = useCallback( |
| 446 | (event: React.MouseEvent) => { | 484 | (event: React.MouseEvent) => { |
| 447 | // Already renaming this row: a double-click on the row chrome (outside the | 485 | // Already renaming this row: a double-click on the row chrome (outside the |
| ... | @@ -473,20 +511,48 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -473,20 +511,48 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 473 | event.preventDefault(); | 511 | event.preventDefault(); |
| 474 | const hasSelection = useThreadSelectionStore.getState().hasSelection(); | 512 | const hasSelection = useThreadSelectionStore.getState().hasSelection(); |
| 475 | if (hasSelection && isSelected) { | 513 | if (hasSelection && isSelected) { |
| 476 | void handleMultiSelectContextMenu({ | 514 | void (async () => { |
| 477 | x: event.clientX, | 515 | const result = await settlePromise(() => |
| 478 | y: event.clientY, | 516 | handleMultiSelectContextMenu({ |
| 479 | }); | 517 | x: event.clientX, |
| 518 | y: event.clientY, | ||
| 519 | }), | ||
| 520 | ); | ||
| 521 | if (result._tag === "Failure") { | ||
| 522 | const error = squashAtomCommandFailure(result); | ||
| 523 | toastManager.add( | ||
| 524 | stackedThreadToast({ | ||
| 525 | type: "error", | ||
| 526 | title: "Thread action failed", | ||
| 527 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 528 | }), | ||
| 529 | ); | ||
| 530 | } | ||
| 531 | })(); | ||
| 480 | return; | 532 | return; |
| 481 | } | 533 | } |
| 482 | 534 | ||
| 483 | if (hasSelection) { | 535 | if (hasSelection) { |
| 484 | clearSelection(); | 536 | clearSelection(); |
| 485 | } | 537 | } |
| 486 | void handleThreadContextMenu(threadRef, { | 538 | void (async () => { |
| 487 | x: event.clientX, | 539 | const result = await settlePromise(() => |
| 488 | y: event.clientY, | 540 | handleThreadContextMenu(threadRef, { |
| 489 | }); | 541 | x: event.clientX, |
| 542 | y: event.clientY, | ||
| 543 | }), | ||
| 544 | ); | ||
| 545 | if (result._tag === "Failure") { | ||
| 546 | const error = squashAtomCommandFailure(result); | ||
| 547 | toastManager.add( | ||
| 548 | stackedThreadToast({ | ||
| 549 | type: "error", | ||
| 550 | title: "Thread action failed", | ||
| 551 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 552 | }), | ||
| 553 | ); | ||
| 554 | } | ||
| 555 | })(); | ||
| 490 | }, | 556 | }, |
| 491 | [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], | 557 | [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], |
| 492 | ); | 558 | ); |
| ... | @@ -981,7 +1047,7 @@ interface SidebarProjectItemProps { | ... | @@ -981,7 +1047,7 @@ interface SidebarProjectItemProps { |
| 981 | isThreadListExpanded: boolean; | 1047 | isThreadListExpanded: boolean; |
| 982 | activeRouteThreadKey: string | null; | 1048 | activeRouteThreadKey: string | null; |
| 983 | newThreadShortcutLabel: string | null; | 1049 | newThreadShortcutLabel: string | null; |
| 984 | handleNewThread: ReturnType<typeof useNewThreadHandler>["handleNewThread"]; | 1050 | handleNewThread: ReturnType<typeof useNewThreadHandler>; |
| 985 | archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; | 1051 | archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; |
| 986 | deleteThread: ReturnType<typeof useThreadActions>["deleteThread"]; | 1052 | deleteThread: ReturnType<typeof useThreadActions>["deleteThread"]; |
| 987 | threadJumpLabelByKey: ReadonlyMap<string, string>; | 1053 | threadJumpLabelByKey: ReadonlyMap<string, string>; |
| ... | @@ -1027,14 +1093,23 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1027,14 +1093,23 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1027 | (settings) => settings.defaultThreadEnvMode, | 1093 | (settings) => settings.defaultThreadEnvMode, |
| 1028 | ); | 1094 | ); |
| 1029 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); | 1095 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); |
| 1030 | const { updateSettings } = useUpdateSettings(); | 1096 | const deleteProject = useAtomCommand(projectEnvironment.delete, { |
| 1097 | reportFailure: false, | ||
| 1098 | }); | ||
| 1099 | const updateProject = useAtomCommand(projectEnvironment.update, { | ||
| 1100 | reportFailure: false, | ||
| 1101 | }); | ||
| 1102 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | ||
| 1103 | reportFailure: false, | ||
| 1104 | }); | ||
| 1105 | const updateSettings = useUpdateSettings(); | ||
| 1031 | const sidebarThreadPreviewCount = useSettings<SidebarThreadPreviewCount>( | 1106 | const sidebarThreadPreviewCount = useSettings<SidebarThreadPreviewCount>( |
| 1032 | (settings) => settings.sidebarThreadPreviewCount, | 1107 | (settings) => settings.sidebarThreadPreviewCount, |
| 1033 | ); | 1108 | ); |
| 1034 | const router = useRouter(); | 1109 | const router = useRouter(); |
| 1035 | const { isMobile, setOpenMobile } = useSidebar(); | 1110 | const { isMobile, setOpenMobile } = useSidebar(); |
| 1036 | const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); | 1111 | const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); |
| 1037 | const toggleProject = useUiStateStore((state) => state.toggleProject); | 1112 | const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); |
| 1038 | const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); | 1113 | const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); |
| 1039 | const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); | 1114 | const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); |
| 1040 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); | 1115 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); |
| ... | @@ -1103,15 +1178,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1103,15 +1178,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1103 | ); | 1178 | ); |
| 1104 | }); | 1179 | }); |
| 1105 | }, []); | 1180 | }, []); |
| 1106 | const sidebarThreads = useStore( | 1181 | const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); |
| 1107 | useShallow( | ||
| 1108 | useMemo( | ||
| 1109 | () => (state: import("../store").AppState) => | ||
| 1110 | selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs), | ||
| 1111 | [project.memberProjectRefs], | ||
| 1112 | ), | ||
| 1113 | ), | ||
| 1114 | ); | ||
| 1115 | const sidebarThreadByKey = useMemo( | 1182 | const sidebarThreadByKey = useMemo( |
| 1116 | () => | 1183 | () => |
| 1117 | new Map( | 1184 | new Map( |
| ... | @@ -1128,8 +1195,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1128,8 +1195,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1128 | const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); | 1195 | const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); |
| 1129 | sidebarThreadByKeyRef.current = sidebarThreadByKey; | 1196 | sidebarThreadByKeyRef.current = sidebarThreadByKey; |
| 1130 | const projectThreads = sidebarThreads; | 1197 | const projectThreads = sidebarThreads; |
| 1131 | const projectExpanded = useUiStateStore( | 1198 | const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); |
| 1132 | (state) => state.projectExpandedById[project.projectKey] ?? true, | 1199 | const projectExpanded = useUiStateStore((state) => |
| 1200 | resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), | ||
| 1133 | ); | 1201 | ); |
| 1134 | const threadLastVisitedAts = useUiStateStore( | 1202 | const threadLastVisitedAts = useUiStateStore( |
| 1135 | useShallow((state) => | 1203 | useShallow((state) => |
| ... | @@ -1215,7 +1283,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1215,7 +1283,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1215 | visibleProjectThreads, | 1283 | visibleProjectThreads, |
| 1216 | }; | 1284 | }; |
| 1217 | }, [projectThreads, threadLastVisitedAts, threadSortOrder]); | 1285 | }, [projectThreads, threadLastVisitedAts, threadSortOrder]); |
| 1218 | |||
| 1219 | const pinnedCollapsedThread = useMemo(() => { | 1286 | const pinnedCollapsedThread = useMemo(() => { |
| 1220 | const activeThreadKey = activeRouteThreadKey ?? undefined; | 1287 | const activeThreadKey = activeRouteThreadKey ?? undefined; |
| 1221 | if (!activeThreadKey || projectExpanded) { | 1288 | if (!activeThreadKey || projectExpanded) { |
| ... | @@ -1313,15 +1380,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1313,15 +1380,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1313 | if (useThreadSelectionStore.getState().hasSelection()) { | 1380 | if (useThreadSelectionStore.getState().hasSelection()) { |
| 1314 | clearSelection(); | 1381 | clearSelection(); |
| 1315 | } | 1382 | } |
| 1316 | toggleProject(project.projectKey); | 1383 | setProjectExpanded(projectPreferenceKeys, !projectExpanded); |
| 1317 | }, | 1384 | }, |
| 1318 | [ | 1385 | [ |
| 1319 | clearSelection, | 1386 | clearSelection, |
| 1320 | dragInProgressRef, | 1387 | dragInProgressRef, |
| 1321 | project.projectKey, | 1388 | projectExpanded, |
| 1389 | projectPreferenceKeys, | ||
| 1390 | setProjectExpanded, | ||
| 1322 | suppressProjectClickAfterDragRef, | 1391 | suppressProjectClickAfterDragRef, |
| 1323 | suppressProjectClickForContextMenuRef, | 1392 | suppressProjectClickForContextMenuRef, |
| 1324 | toggleProject, | ||
| 1325 | ], | 1393 | ], |
| 1326 | ); | 1394 | ); |
| 1327 | 1395 | ||
| ... | @@ -1332,9 +1400,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1332,9 +1400,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1332 | if (dragInProgressRef.current) { | 1400 | if (dragInProgressRef.current) { |
| 1333 | return; | 1401 | return; |
| 1334 | } | 1402 | } |
| 1335 | toggleProject(project.projectKey); | 1403 | setProjectExpanded(projectPreferenceKeys, !projectExpanded); |
| 1336 | }, | 1404 | }, |
| 1337 | [dragInProgressRef, project.projectKey, toggleProject], | 1405 | [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], |
| 1338 | ); | 1406 | ); |
| 1339 | 1407 | ||
| 1340 | const handleProjectButtonPointerDownCapture = useCallback( | 1408 | const handleProjectButtonPointerDownCapture = useCallback( |
| ... | @@ -1357,7 +1425,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1357,7 +1425,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1357 | 1425 | ||
| 1358 | const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { | 1426 | const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { |
| 1359 | setProjectRenameTarget(member); | 1427 | setProjectRenameTarget(member); |
| 1360 | setProjectRenameTitle(member.name); | 1428 | setProjectRenameTitle(member.title); |
| 1361 | }, []); | 1429 | }, []); |
| 1362 | 1430 | ||
| 1363 | const openProjectGroupingDialog = useCallback( | 1431 | const openProjectGroupingDialog = useCallback( |
| ... | @@ -1372,28 +1440,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1372,28 +1440,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1372 | ); | 1440 | ); |
| 1373 | 1441 | ||
| 1374 | const removeProject = useCallback( | 1442 | const removeProject = useCallback( |
| 1375 | async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}): Promise<void> => { | 1443 | async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { |
| 1376 | const memberProjectRef = scopeProjectRef(member.environmentId, member.id); | 1444 | const memberProjectRef = scopeProjectRef(member.environmentId, member.id); |
| 1445 | const result = await deleteProject({ | ||
| 1446 | environmentId: member.environmentId, | ||
| 1447 | input: { | ||
| 1448 | projectId: member.id, | ||
| 1449 | ...(options.force === true ? { force: true } : {}), | ||
| 1450 | }, | ||
| 1451 | }); | ||
| 1452 | if (result._tag === "Failure") { | ||
| 1453 | return result; | ||
| 1454 | } | ||
| 1377 | const draftStore = useComposerDraftStore.getState(); | 1455 | const draftStore = useComposerDraftStore.getState(); |
| 1378 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); | 1456 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); |
| 1379 | if (projectDraftThread) { | 1457 | if (projectDraftThread) { |
| 1380 | draftStore.clearDraftThread(projectDraftThread.draftId); | 1458 | draftStore.clearDraftThread(projectDraftThread.draftId); |
| 1381 | } | 1459 | } |
| 1382 | draftStore.clearProjectDraftThreadId(memberProjectRef); | 1460 | draftStore.clearProjectDraftThreadId(memberProjectRef); |
| 1383 | 1461 | return result; | |
| 1384 | const projectApi = readEnvironmentApi(member.environmentId); | ||
| 1385 | if (!projectApi) { | ||
| 1386 | throw new Error("Project API unavailable."); | ||
| 1387 | } | ||
| 1388 | |||
| 1389 | await projectApi.orchestration.dispatchCommand({ | ||
| 1390 | type: "project.delete", | ||
| 1391 | commandId: newCommandId(), | ||
| 1392 | projectId: member.id, | ||
| 1393 | ...(options.force === true ? { force: true } : {}), | ||
| 1394 | }); | ||
| 1395 | }, | 1462 | }, |
| 1396 | [], | 1463 | [deleteProject], |
| 1397 | ); | 1464 | ); |
| 1398 | 1465 | ||
| 1399 | const handleRemoveProject = useCallback( | 1466 | const handleRemoveProject = useCallback( |
| ... | @@ -1421,17 +1488,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1421,17 +1488,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1421 | window.setTimeout(resolve, 180); | 1488 | window.setTimeout(resolve, 180); |
| 1422 | }); | 1489 | }); |
| 1423 | 1490 | ||
| 1424 | const latestProjectThreads = selectSidebarThreadsForProjectRefs( | 1491 | const latestProjectThreads = Array.from( |
| 1425 | useStore.getState(), | 1492 | sidebarThreadByKeyRef.current.values(), |
| 1426 | [memberProjectRef], | 1493 | ).filter( |
| 1494 | (thread) => | ||
| 1495 | thread.environmentId === memberProjectRef.environmentId && | ||
| 1496 | thread.projectId === memberProjectRef.projectId, | ||
| 1427 | ); | 1497 | ); |
| 1428 | const confirmed = await api.dialogs.confirm( | 1498 | const confirmed = await api.dialogs.confirm( |
| 1429 | latestProjectThreads.length > 0 | 1499 | latestProjectThreads.length > 0 |
| 1430 | ? [ | 1500 | ? [ |
| 1431 | `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${ | 1501 | `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${ |
| 1432 | latestProjectThreads.length === 1 ? "" : "s" | 1502 | latestProjectThreads.length === 1 ? "" : "s" |
| 1433 | }?`, | 1503 | }?`, |
| 1434 | `Path: ${member.cwd}`, | 1504 | `Path: ${member.workspaceRoot}`, |
| 1435 | ...(member.environmentLabel | 1505 | ...(member.environmentLabel |
| 1436 | ? [`Environment: ${member.environmentLabel}`] | 1506 | ? [`Environment: ${member.environmentLabel}`] |
| 1437 | : []), | 1507 | : []), |
| ... | @@ -1440,8 +1510,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1440,8 +1510,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1440 | "This action cannot be undone.", | 1510 | "This action cannot be undone.", |
| 1441 | ].join("\n") | 1511 | ].join("\n") |
| 1442 | : [ | 1512 | : [ |
| 1443 | `Remove project "${member.name}"?`, | 1513 | `Remove project "${member.title}"?`, |
| 1444 | `Path: ${member.cwd}`, | 1514 | `Path: ${member.workspaceRoot}`, |
| 1445 | ...(member.environmentLabel | 1515 | ...(member.environmentLabel |
| 1446 | ? [`Environment: ${member.environmentLabel}`] | 1516 | ? [`Environment: ${member.environmentLabel}`] |
| 1447 | : []), | 1517 | : []), |
| ... | @@ -1452,7 +1522,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1452,7 +1522,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1452 | return; | 1522 | return; |
| 1453 | } | 1523 | } |
| 1454 | 1524 | ||
| 1455 | await removeProject(member, { force: true }); | 1525 | const result = await removeProject(member, { force: true }); |
| 1526 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 1527 | const error = squashAtomCommandFailure(result); | ||
| 1528 | toastManager.add( | ||
| 1529 | stackedThreadToast({ | ||
| 1530 | type: "error", | ||
| 1531 | title: `Failed to remove "${member.title}"`, | ||
| 1532 | description: | ||
| 1533 | error instanceof Error | ||
| 1534 | ? error.message | ||
| 1535 | : "Unknown error removing project.", | ||
| 1536 | }), | ||
| 1537 | ); | ||
| 1538 | } | ||
| 1456 | })().catch((error) => { | 1539 | })().catch((error) => { |
| 1457 | const message = | 1540 | const message = |
| 1458 | error instanceof Error ? error.message : "Unknown error removing project."; | 1541 | error instanceof Error ? error.message : "Unknown error removing project."; |
| ... | @@ -1464,7 +1547,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1464,7 +1547,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1464 | toastManager.add( | 1547 | toastManager.add( |
| 1465 | stackedThreadToast({ | 1548 | stackedThreadToast({ |
| 1466 | type: "error", | 1549 | type: "error", |
| 1467 | title: `Failed to remove "${member.name}"`, | 1550 | title: `Failed to remove "${member.title}"`, |
| 1468 | description: message, | 1551 | description: message, |
| 1469 | }), | 1552 | }), |
| 1470 | ); | 1553 | ); |
| ... | @@ -1477,8 +1560,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1477,8 +1560,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1477 | } | 1560 | } |
| 1478 | 1561 | ||
| 1479 | const message = [ | 1562 | const message = [ |
| 1480 | `Remove project "${member.name}"?`, | 1563 | `Remove project "${member.title}"?`, |
| 1481 | `Path: ${member.cwd}`, | 1564 | `Path: ${member.workspaceRoot}`, |
| 1482 | ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), | 1565 | ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), |
| 1483 | "This removes only this project entry.", | 1566 | "This removes only this project entry.", |
| 1484 | ].join("\n"); | 1567 | ].join("\n"); |
| ... | @@ -1487,9 +1570,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1487,9 +1570,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1487 | return; | 1570 | return; |
| 1488 | } | 1571 | } |
| 1489 | 1572 | ||
| 1490 | try { | 1573 | const result = await removeProject(member); |
| 1491 | await removeProject(member); | 1574 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 1492 | } catch (error) { | 1575 | const error = squashAtomCommandFailure(result); |
| 1493 | const message = error instanceof Error ? error.message : "Unknown error removing project."; | 1576 | const message = error instanceof Error ? error.message : "Unknown error removing project."; |
| 1494 | console.error("Failed to remove project", { | 1577 | console.error("Failed to remove project", { |
| 1495 | projectId: member.id, | 1578 | projectId: member.id, |
| ... | @@ -1499,7 +1582,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1499,7 +1582,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1499 | toastManager.add( | 1582 | toastManager.add( |
| 1500 | stackedThreadToast({ | 1583 | stackedThreadToast({ |
| 1501 | type: "error", | 1584 | type: "error", |
| 1502 | title: `Failed to remove "${member.name}"`, | 1585 | title: `Failed to remove "${member.title}"`, |
| 1503 | description: message, | 1586 | description: message, |
| 1504 | }), | 1587 | }), |
| 1505 | ); | 1588 | ); |
| ... | @@ -1535,7 +1618,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1535,7 +1618,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1535 | openProjectGroupingDialog(member); | 1618 | openProjectGroupingDialog(member); |
| 1536 | return; | 1619 | return; |
| 1537 | case "copy-path": | 1620 | case "copy-path": |
| 1538 | copyPathToClipboard(member.cwd, { path: member.cwd }); | 1621 | copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot }); |
| 1539 | return; | 1622 | return; |
| 1540 | case "delete": | 1623 | case "delete": |
| 1541 | return handleRemoveProject(member); | 1624 | return handleRemoveProject(member); |
| ... | @@ -1728,9 +1811,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1728,9 +1811,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1728 | for (const threadKey of threadKeys) { | 1811 | for (const threadKey of threadKeys) { |
| 1729 | const thread = sidebarThreadByKeyRef.current.get(threadKey); | 1812 | const thread = sidebarThreadByKeyRef.current.get(threadKey); |
| 1730 | if (!thread) continue; | 1813 | if (!thread) continue; |
| 1731 | await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { | 1814 | const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { |
| 1732 | deletedThreadKeys, | 1815 | deletedThreadKeys, |
| 1733 | }); | 1816 | }); |
| 1817 | if (result._tag === "Failure") { | ||
| 1818 | if (!isAtomCommandInterrupted(result)) { | ||
| 1819 | const error = squashAtomCommandFailure(result); | ||
| 1820 | toastManager.add( | ||
| 1821 | stackedThreadToast({ | ||
| 1822 | type: "error", | ||
| 1823 | title: "Failed to delete threads", | ||
| 1824 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1825 | }), | ||
| 1826 | ); | ||
| 1827 | } | ||
| 1828 | return; | ||
| 1829 | } | ||
| 1734 | } | 1830 | } |
| 1735 | removeFromSelection(threadKeys); | 1831 | removeFromSelection(threadKeys); |
| 1736 | }, | 1832 | }, |
| ... | @@ -1750,7 +1846,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1750,7 +1846,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1750 | const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); | 1846 | const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); |
| 1751 | const currentActiveThread = | 1847 | const currentActiveThread = |
| 1752 | currentRouteTarget?.kind === "server" | 1848 | currentRouteTarget?.kind === "server" |
| 1753 | ? (selectThreadByRef(useStore.getState(), currentRouteTarget.threadRef) ?? null) | 1849 | ? readThreadShell(currentRouteTarget.threadRef) |
| 1754 | : null; | 1850 | : null; |
| 1755 | const draftStore = useComposerDraftStore.getState(); | 1851 | const draftStore = useComposerDraftStore.getState(); |
| 1756 | const currentActiveDraftThread = | 1852 | const currentActiveDraftThread = |
| ... | @@ -1785,13 +1881,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1785,13 +1881,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1785 | if (isMobile) { | 1881 | if (isMobile) { |
| 1786 | setOpenMobile(false); | 1882 | setOpenMobile(false); |
| 1787 | } | 1883 | } |
| 1788 | void handleNewThread(scopeProjectRef(member.environmentId, member.id), { | 1884 | void (async () => { |
| 1789 | ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), | 1885 | const result = await settlePromise(() => |
| 1790 | ...(seedContext.worktreePath !== undefined | 1886 | handleNewThread(scopeProjectRef(member.environmentId, member.id), { |
| 1791 | ? { worktreePath: seedContext.worktreePath } | 1887 | ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), |
| 1792 | : {}), | 1888 | ...(seedContext.worktreePath !== undefined |
| 1793 | envMode: seedContext.envMode, | 1889 | ? { worktreePath: seedContext.worktreePath } |
| 1794 | }); | 1890 | : {}), |
| 1891 | envMode: seedContext.envMode, | ||
| 1892 | }), | ||
| 1893 | ); | ||
| 1894 | if (result._tag === "Failure") { | ||
| 1895 | const error = squashAtomCommandFailure(result); | ||
| 1896 | toastManager.add( | ||
| 1897 | stackedThreadToast({ | ||
| 1898 | type: "error", | ||
| 1899 | title: "Could not create thread", | ||
| 1900 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1901 | }), | ||
| 1902 | ); | ||
| 1903 | } | ||
| 1904 | })(); | ||
| 1795 | }, | 1905 | }, |
| 1796 | [defaultThreadEnvMode, handleNewThread, isMobile, router, setOpenMobile], | 1906 | [defaultThreadEnvMode, handleNewThread, isMobile, router, setOpenMobile], |
| 1797 | ); | 1907 | ); |
| ... | @@ -1811,16 +1921,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1811,16 +1921,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1811 | if (!api) { | 1921 | if (!api) { |
| 1812 | return; | 1922 | return; |
| 1813 | } | 1923 | } |
| 1814 | const clicked = await api.contextMenu.show( | 1924 | const clickedResult = await settlePromise(() => |
| 1815 | project.memberProjects.map((member) => ({ | 1925 | api.contextMenu.show( |
| 1816 | id: member.physicalProjectKey, | 1926 | project.memberProjects.map((member) => ({ |
| 1817 | label: formatProjectMemberActionLabel(member, project.groupedProjectCount), | 1927 | id: member.physicalProjectKey, |
| 1818 | })), | 1928 | label: formatProjectMemberActionLabel(member, project.groupedProjectCount), |
| 1819 | { | 1929 | })), |
| 1820 | x: event.clientX, | 1930 | { |
| 1821 | y: event.clientY, | 1931 | x: event.clientX, |
| 1822 | }, | 1932 | y: event.clientY, |
| 1933 | }, | ||
| 1934 | ), | ||
| 1823 | ); | 1935 | ); |
| 1936 | if (clickedResult._tag === "Failure") { | ||
| 1937 | const error = squashAtomCommandFailure(clickedResult); | ||
| 1938 | toastManager.add( | ||
| 1939 | stackedThreadToast({ | ||
| 1940 | type: "error", | ||
| 1941 | title: "Could not choose environment", | ||
| 1942 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1943 | }), | ||
| 1944 | ); | ||
| 1945 | return; | ||
| 1946 | } | ||
| 1947 | const clicked = clickedResult.value; | ||
| 1824 | if (!clicked) { | 1948 | if (!clicked) { |
| 1825 | return; | 1949 | return; |
| 1826 | } | 1950 | } |
| ... | @@ -1838,9 +1962,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1838,9 +1962,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1838 | 1962 | ||
| 1839 | const attemptArchiveThread = useCallback( | 1963 | const attemptArchiveThread = useCallback( |
| 1840 | async (threadRef: ScopedThreadRef) => { | 1964 | async (threadRef: ScopedThreadRef) => { |
| 1841 | try { | 1965 | const result = await archiveThread(threadRef); |
| 1842 | await archiveThread(threadRef); | 1966 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 1843 | } catch (error) { | 1967 | const error = squashAtomCommandFailure(result); |
| 1844 | toastManager.add( | 1968 | toastManager.add( |
| 1845 | stackedThreadToast({ | 1969 | stackedThreadToast({ |
| 1846 | type: "error", | 1970 | type: "error", |
| ... | @@ -1888,19 +2012,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1888,19 +2012,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1888 | finishRename(); | 2012 | finishRename(); |
| 1889 | return; | 2013 | return; |
| 1890 | } | 2014 | } |
| 1891 | const api = readEnvironmentApi(threadRef.environmentId); | 2015 | const result = await updateThreadMetadata({ |
| 1892 | if (!api) { | 2016 | environmentId: threadRef.environmentId, |
| 1893 | finishRename(); | 2017 | input: { |
| 1894 | return; | ||
| 1895 | } | ||
| 1896 | try { | ||
| 1897 | await api.orchestration.dispatchCommand({ | ||
| 1898 | type: "thread.meta.update", | ||
| 1899 | commandId: newCommandId(), | ||
| 1900 | threadId: threadRef.threadId, | 2018 | threadId: threadRef.threadId, |
| 1901 | title: trimmed, | 2019 | title: trimmed, |
| 1902 | }); | 2020 | }, |
| 1903 | } catch (error) { | 2021 | }); |
| 2022 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 2023 | const error = squashAtomCommandFailure(result); | ||
| 1904 | toastManager.add( | 2024 | toastManager.add( |
| 1905 | stackedThreadToast({ | 2025 | stackedThreadToast({ |
| 1906 | type: "error", | 2026 | type: "error", |
| ... | @@ -1911,7 +2031,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1911,7 +2031,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1911 | } | 2031 | } |
| 1912 | finishRename(); | 2032 | finishRename(); |
| 1913 | }, | 2033 | }, |
| 1914 | [], | 2034 | [updateThreadMetadata], |
| 1915 | ); | 2035 | ); |
| 1916 | 2036 | ||
| 1917 | const closeProjectRenameDialog = useCallback(() => { | 2037 | const closeProjectRenameDialog = useCallback(() => { |
| ... | @@ -1933,32 +2053,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1933,32 +2053,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1933 | return; | 2053 | return; |
| 1934 | } | 2054 | } |
| 1935 | 2055 | ||
| 1936 | if (trimmed === projectRenameTarget.name) { | 2056 | if (trimmed === projectRenameTarget.title) { |
| 1937 | closeProjectRenameDialog(); | 2057 | closeProjectRenameDialog(); |
| 1938 | return; | 2058 | return; |
| 1939 | } | 2059 | } |
| 1940 | 2060 | ||
| 1941 | const api = readEnvironmentApi(projectRenameTarget.environmentId); | 2061 | const result = await updateProject({ |
| 1942 | if (!api) { | 2062 | environmentId: projectRenameTarget.environmentId, |
| 1943 | toastManager.add( | 2063 | input: { |
| 1944 | stackedThreadToast({ | ||
| 1945 | type: "error", | ||
| 1946 | title: "Failed to rename project", | ||
| 1947 | description: "Project API unavailable.", | ||
| 1948 | }), | ||
| 1949 | ); | ||
| 1950 | return; | ||
| 1951 | } | ||
| 1952 | |||
| 1953 | try { | ||
| 1954 | await api.orchestration.dispatchCommand({ | ||
| 1955 | type: "project.meta.update", | ||
| 1956 | commandId: newCommandId(), | ||
| 1957 | projectId: projectRenameTarget.id, | 2064 | projectId: projectRenameTarget.id, |
| 1958 | title: trimmed, | 2065 | title: trimmed, |
| 1959 | }); | 2066 | }, |
| 2067 | }); | ||
| 2068 | if (result._tag === "Success") { | ||
| 1960 | closeProjectRenameDialog(); | 2069 | closeProjectRenameDialog(); |
| 1961 | } catch (error) { | 2070 | } else if (!isAtomCommandInterrupted(result)) { |
| 2071 | const error = squashAtomCommandFailure(result); | ||
| 1962 | toastManager.add( | 2072 | toastManager.add( |
| 1963 | stackedThreadToast({ | 2073 | stackedThreadToast({ |
| 1964 | type: "error", | 2074 | type: "error", |
| ... | @@ -1967,7 +2077,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1967,7 +2077,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1967 | }), | 2077 | }), |
| 1968 | ); | 2078 | ); |
| 1969 | } | 2079 | } |
| 1970 | }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); | 2080 | }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]); |
| 1971 | 2081 | ||
| 1972 | const closeProjectGroupingDialog = useCallback(() => { | 2082 | const closeProjectGroupingDialog = useCallback(() => { |
| 1973 | setProjectGroupingTarget(null); | 2083 | setProjectGroupingTarget(null); |
| ... | @@ -2010,7 +2120,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2010,7 +2120,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2010 | const threadProject = memberProjectByScopedKey.get( | 2120 | const threadProject = memberProjectByScopedKey.get( |
| 2011 | scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), | 2121 | scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), |
| 2012 | ); | 2122 | ); |
| 2013 | const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null; | 2123 | const threadWorkspacePath = |
| 2124 | thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; | ||
| 2014 | const clicked = await api.contextMenu.show( | 2125 | const clicked = await api.contextMenu.show( |
| 2015 | [ | 2126 | [ |
| 2016 | { id: "rename", label: "Rename thread" }, | 2127 | { id: "rename", label: "Rename thread" }, |
| ... | @@ -2061,7 +2172,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2061,7 +2172,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2061 | return; | 2172 | return; |
| 2062 | } | 2173 | } |
| 2063 | } | 2174 | } |
| 2064 | await deleteThread(threadRef); | 2175 | const result = await deleteThread(threadRef); |
| 2176 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 2177 | const error = squashAtomCommandFailure(result); | ||
| 2178 | toastManager.add( | ||
| 2179 | stackedThreadToast({ | ||
| 2180 | type: "error", | ||
| 2181 | title: "Failed to delete thread", | ||
| 2182 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 2183 | }), | ||
| 2184 | ); | ||
| 2185 | } | ||
| 2065 | }, | 2186 | }, |
| 2066 | [ | 2187 | [ |
| 2067 | appSettingsConfirmThreadDelete, | 2188 | appSettingsConfirmThreadDelete, |
| ... | @@ -2070,7 +2191,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2070,7 +2191,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2070 | deleteThread, | 2191 | deleteThread, |
| 2071 | markThreadUnread, | 2192 | markThreadUnread, |
| 2072 | memberProjectByScopedKey, | 2193 | memberProjectByScopedKey, |
| 2073 | project.cwd, | 2194 | project.workspaceRoot, |
| 2074 | startThreadRename, | 2195 | startThreadRename, |
| 2075 | ], | 2196 | ], |
| 2076 | ); | 2197 | ); |
| ... | @@ -2119,7 +2240,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2119,7 +2240,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2119 | }`} | 2240 | }`} |
| 2120 | /> | 2241 | /> |
| 2121 | )} | 2242 | )} |
| 2122 | <ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} /> | 2243 | <ProjectFavicon environmentId={project.environmentId} cwd={project.workspaceRoot} /> |
| 2123 | <span className="flex min-w-0 flex-1 items-center gap-2"> | 2244 | <span className="flex min-w-0 flex-1 items-center gap-2"> |
| 2124 | <span className="truncate text-xs font-medium text-foreground/90"> | 2245 | <span className="truncate text-xs font-medium text-foreground/90"> |
| 2125 | {project.displayName} | 2246 | {project.displayName} |
| ... | @@ -2187,7 +2308,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2187,7 +2308,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2187 | showEmptyThreadState={showEmptyThreadState} | 2308 | showEmptyThreadState={showEmptyThreadState} |
| 2188 | shouldShowThreadPanel={shouldShowThreadPanel} | 2309 | shouldShowThreadPanel={shouldShowThreadPanel} |
| 2189 | isThreadListExpanded={isThreadListExpanded} | 2310 | isThreadListExpanded={isThreadListExpanded} |
| 2190 | projectCwd={project.cwd} | 2311 | projectCwd={project.workspaceRoot} |
| 2191 | activeRouteThreadKey={activeRouteThreadKey} | 2312 | activeRouteThreadKey={activeRouteThreadKey} |
| 2192 | threadJumpLabelByKey={threadJumpLabelByKey} | 2313 | threadJumpLabelByKey={threadJumpLabelByKey} |
| 2193 | appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} | 2314 | appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} |
| ... | @@ -2227,7 +2348,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2227,7 +2348,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2227 | <DialogTitle>Rename project</DialogTitle> | 2348 | <DialogTitle>Rename project</DialogTitle> |
| 2228 | <DialogDescription> | 2349 | <DialogDescription> |
| 2229 | {projectRenameTarget | 2350 | {projectRenameTarget |
| 2230 | ? `Update the title for ${projectRenameTarget.cwd}.` | 2351 | ? `Update the title for ${projectRenameTarget.workspaceRoot}.` |
| 2231 | : "Update the project title."} | 2352 | : "Update the project title."} |
| 2232 | </DialogDescription> | 2353 | </DialogDescription> |
| 2233 | </DialogHeader> | 2354 | </DialogHeader> |
| ... | @@ -2274,7 +2395,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2274,7 +2395,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2274 | <DialogTitle>Project grouping</DialogTitle> | 2395 | <DialogTitle>Project grouping</DialogTitle> |
| 2275 | <DialogDescription> | 2396 | <DialogDescription> |
| 2276 | {projectGroupingTarget | 2397 | {projectGroupingTarget |
| 2277 | ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` | 2398 | ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` |
| 2278 | : "Choose how this project should be grouped in the sidebar."} | 2399 | : "Choose how this project should be grouped in the sidebar."} |
| 2279 | </DialogDescription> | 2400 | </DialogDescription> |
| 2280 | </DialogHeader> | 2401 | </DialogHeader> |
| ... | @@ -2632,7 +2753,7 @@ interface SidebarProjectsContentProps { | ... | @@ -2632,7 +2753,7 @@ interface SidebarProjectsContentProps { |
| 2632 | threadSortOrder: SidebarThreadSortOrder; | 2753 | threadSortOrder: SidebarThreadSortOrder; |
| 2633 | projectGroupingMode: SidebarProjectGroupingMode; | 2754 | projectGroupingMode: SidebarProjectGroupingMode; |
| 2634 | threadPreviewCount: SidebarThreadPreviewCount; | 2755 | threadPreviewCount: SidebarThreadPreviewCount; |
| 2635 | updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"]; | 2756 | updateSettings: ReturnType<typeof useUpdateSettings>; |
| 2636 | openAddProject: () => void; | 2757 | openAddProject: () => void; |
| 2637 | isManualProjectSorting: boolean; | 2758 | isManualProjectSorting: boolean; |
| 2638 | projectDnDSensors: ReturnType<typeof useSensors>; | 2759 | projectDnDSensors: ReturnType<typeof useSensors>; |
| ... | @@ -2640,7 +2761,7 @@ interface SidebarProjectsContentProps { | ... | @@ -2640,7 +2761,7 @@ interface SidebarProjectsContentProps { |
| 2640 | handleProjectDragStart: (event: DragStartEvent) => void; | 2761 | handleProjectDragStart: (event: DragStartEvent) => void; |
| 2641 | handleProjectDragEnd: (event: DragEndEvent) => void; | 2762 | handleProjectDragEnd: (event: DragEndEvent) => void; |
| 2642 | handleProjectDragCancel: (event: DragCancelEvent) => void; | 2763 | handleProjectDragCancel: (event: DragCancelEvent) => void; |
| 2643 | handleNewThread: ReturnType<typeof useNewThreadHandler>["handleNewThread"]; | 2764 | handleNewThread: ReturnType<typeof useNewThreadHandler>; |
| 2644 | archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; | 2765 | archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; |
| 2645 | deleteThread: ReturnType<typeof useThreadActions>["deleteThread"]; | 2766 | deleteThread: ReturnType<typeof useThreadActions>["deleteThread"]; |
| 2646 | sortedProjects: readonly SidebarProjectSnapshot[]; | 2767 | sortedProjects: readonly SidebarProjectSnapshot[]; |
| ... | @@ -2893,8 +3014,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( | ... | @@ -2893,8 +3014,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2893 | }); | 3014 | }); |
| 2894 | 3015 | ||
| 2895 | export default function Sidebar() { | 3016 | export default function Sidebar() { |
| 2896 | const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); | 3017 | const projects = useProjects(); |
| 2897 | const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); | 3018 | const sidebarThreads = useThreadShells(); |
| 2898 | const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); | 3019 | const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); |
| 2899 | const projectOrder = useUiStateStore((store) => store.projectOrder); | 3020 | const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 2900 | const reorderProjects = useUiStateStore((store) => store.reorderProjects); | 3021 | const reorderProjects = useUiStateStore((store) => store.reorderProjects); |
| ... | @@ -2906,8 +3027,8 @@ export default function Sidebar() { | ... | @@ -2906,8 +3027,8 @@ export default function Sidebar() { |
| 2906 | const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); | 3027 | const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); |
| 2907 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); | 3028 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); |
| 2908 | const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount); | 3029 | const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount); |
| 2909 | const { updateSettings } = useUpdateSettings(); | 3030 | const updateSettings = useUpdateSettings(); |
| 2910 | const { handleNewThread } = useNewThreadHandler(); | 3031 | const handleNewThread = useNewThreadHandler(); |
| 2911 | const { archiveThread, deleteThread } = useThreadActions(); | 3032 | const { archiveThread, deleteThread } = useThreadActions(); |
| 2912 | const { isMobile, setOpenMobile } = useSidebar(); | 3033 | const { isMobile, setOpenMobile } = useSidebar(); |
| 2913 | const routeThreadRef = useParams({ | 3034 | const routeThreadRef = useParams({ |
| ... | @@ -2915,8 +3036,13 @@ export default function Sidebar() { | ... | @@ -2915,8 +3036,13 @@ export default function Sidebar() { |
| 2915 | select: (params) => resolveThreadRouteRef(params), | 3036 | select: (params) => resolveThreadRouteRef(params), |
| 2916 | }); | 3037 | }); |
| 2917 | const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; | 3038 | const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; |
| 2918 | const keybindings = useServerKeybindings(); | 3039 | const routeTerminalOpen = useTerminalUiStateStore((state) => |
| 2919 | const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); | 3040 | routeThreadRef |
| 3041 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen | ||
| 3042 | : false, | ||
| 3043 | ); | ||
| 3044 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | ||
| 3045 | const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); | ||
| 2920 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< | 3046 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| 2921 | ReadonlySet<string> | 3047 | ReadonlySet<string> |
| 2922 | >(() => new Set()); | 3048 | >(() => new Set()); |
| ... | @@ -2924,20 +3050,29 @@ export default function Sidebar() { | ... | @@ -2924,20 +3050,29 @@ export default function Sidebar() { |
| 2924 | const dragInProgressRef = useRef(false); | 3050 | const dragInProgressRef = useRef(false); |
| 2925 | const suppressProjectClickAfterDragRef = useRef(false); | 3051 | const suppressProjectClickAfterDragRef = useRef(false); |
| 2926 | const suppressProjectClickForContextMenuRef = useRef(false); | 3052 | const suppressProjectClickForContextMenuRef = useRef(false); |
| 2927 | const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); | 3053 | const desktopUpdateState = useDesktopUpdateState(); |
| 2928 | const clearSelection = useThreadSelectionStore((s) => s.clearSelection); | 3054 | const clearSelection = useThreadSelectionStore((s) => s.clearSelection); |
| 2929 | const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); | 3055 | const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); |
| 2930 | const platform = navigator.platform; | 3056 | const platform = navigator.platform; |
| 2931 | const shortcutModifiers = useShortcutModifierState(); | 3057 | const shortcutModifiers = useShortcutModifierState(); |
| 2932 | const modelPickerOpen = useModelPickerOpen(); | 3058 | const { environments } = useEnvironments(); |
| 2933 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 3059 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 2934 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); | 3060 | const environmentLabelById = useMemo( |
| 2935 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); | 3061 | () => |
| 3062 | new Map( | ||
| 3063 | environments.map((environment) => [environment.environmentId, environment.label] as const), | ||
| 3064 | ), | ||
| 3065 | [environments], | ||
| 3066 | ); | ||
| 2936 | const orderedProjects = useMemo(() => { | 3067 | const orderedProjects = useMemo(() => { |
| 2937 | return orderItemsByPreferredIds({ | 3068 | return orderItemsByPreferredIds({ |
| 2938 | items: projects, | 3069 | items: projects, |
| 2939 | preferredIds: projectOrder, | 3070 | preferredIds: projectOrder, |
| 2940 | getId: getProjectOrderKey, | 3071 | getId: getProjectOrderKey, |
| 3072 | getPreferenceIds: (project) => [ | ||
| 3073 | getProjectOrderKey(project), | ||
| 3074 | legacyProjectCwdPreferenceKey(project.workspaceRoot), | ||
| 3075 | ], | ||
| 2941 | }); | 3076 | }); |
| 2942 | }, [projectOrder, projects]); | 3077 | }, [projectOrder, projects]); |
| 2943 | 3078 | ||
| ... | @@ -2966,19 +3101,9 @@ export default function Sidebar() { | ... | @@ -2966,19 +3101,9 @@ export default function Sidebar() { |
| 2966 | projects: orderedProjects, | 3101 | projects: orderedProjects, |
| 2967 | settings: projectGroupingSettings, | 3102 | settings: projectGroupingSettings, |
| 2968 | primaryEnvironmentId, | 3103 | primaryEnvironmentId, |
| 2969 | resolveEnvironmentLabel: (environmentId) => { | 3104 | resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, |
| 2970 | const rt = savedEnvironmentRuntimeById[environmentId]; | ||
| 2971 | const saved = savedEnvironmentRegistry[environmentId]; | ||
| 2972 | return rt?.descriptor?.label ?? saved?.label ?? null; | ||
| 2973 | }, | ||
| 2974 | }); | 3105 | }); |
| 2975 | }, [ | 3106 | }, [environmentLabelById, orderedProjects, projectGroupingSettings, primaryEnvironmentId]); |
| 2976 | orderedProjects, | ||
| 2977 | projectGroupingSettings, | ||
| 2978 | primaryEnvironmentId, | ||
| 2979 | savedEnvironmentRegistry, | ||
| 2980 | savedEnvironmentRuntimeById, | ||
| 2981 | ]); | ||
| 2982 | 3107 | ||
| 2983 | const sidebarProjectByKey = useMemo( | 3108 | const sidebarProjectByKey = useMemo( |
| 2984 | () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), | 3109 | () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), |
| ... | @@ -3031,15 +3156,10 @@ export default function Sidebar() { | ... | @@ -3031,15 +3156,10 @@ export default function Sidebar() { |
| 3031 | const getCurrentSidebarShortcutContext = useCallback( | 3156 | const getCurrentSidebarShortcutContext = useCallback( |
| 3032 | () => ({ | 3157 | () => ({ |
| 3033 | terminalFocus: isTerminalFocused(), | 3158 | terminalFocus: isTerminalFocused(), |
| 3034 | terminalOpen: routeThreadRef | 3159 | terminalOpen: routeTerminalOpen, |
| 3035 | ? selectThreadTerminalUiState( | 3160 | modelPickerOpen: isModelPickerOpen(), |
| 3036 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 3037 | routeThreadRef, | ||
| 3038 | ).terminalOpen | ||
| 3039 | : false, | ||
| 3040 | modelPickerOpen, | ||
| 3041 | }), | 3161 | }), |
| 3042 | [modelPickerOpen, routeThreadRef], | 3162 | [routeTerminalOpen], |
| 3043 | ); | 3163 | ); |
| 3044 | const newThreadShortcutLabelOptions = useMemo( | 3164 | const newThreadShortcutLabelOptions = useMemo( |
| 3045 | () => ({ | 3165 | () => ({ |
| ... | @@ -3102,9 +3222,9 @@ export default function Sidebar() { | ... | @@ -3102,9 +3222,9 @@ export default function Sidebar() { |
| 3102 | (member) => member.physicalProjectKey, | 3222 | (member) => member.physicalProjectKey, |
| 3103 | ); | 3223 | ); |
| 3104 | const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); | 3224 | const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); |
| 3105 | reorderProjects(activeMemberKeys, overMemberKeys); | 3225 | reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); |
| 3106 | }, | 3226 | }, |
| 3107 | [sidebarProjectSortOrder, reorderProjects, sidebarProjects], | 3227 | [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects], |
| 3108 | ); | 3228 | ); |
| 3109 | 3229 | ||
| 3110 | const handleProjectDragStart = useCallback( | 3230 | const handleProjectDragStart = useCallback( |
| ... | @@ -3185,7 +3305,10 @@ export default function Sidebar() { | ... | @@ -3185,7 +3305,10 @@ export default function Sidebar() { |
| 3185 | ), | 3305 | ), |
| 3186 | sidebarThreadSortOrder, | 3306 | sidebarThreadSortOrder, |
| 3187 | ); | 3307 | ); |
| 3188 | const projectExpanded = projectExpandedById[project.projectKey] ?? true; | 3308 | const projectExpanded = resolveProjectExpanded( |
| 3309 | projectExpandedById, | ||
| 3310 | projectExpansionPreferenceKeys(project), | ||
| 3311 | ); | ||
| 3189 | const activeThreadKey = routeThreadKey ?? undefined; | 3312 | const activeThreadKey = routeThreadKey ?? undefined; |
| 3190 | const pinnedCollapsedThread = | 3313 | const pinnedCollapsedThread = |
| 3191 | !projectExpanded && activeThreadKey | 3314 | !projectExpanded && activeThreadKey |
| ... | @@ -3236,19 +3359,11 @@ export default function Sidebar() { | ... | @@ -3236,19 +3359,11 @@ export default function Sidebar() { |
| 3236 | () => [...threadJumpCommandByKey.keys()], | 3359 | () => [...threadJumpCommandByKey.keys()], |
| 3237 | [threadJumpCommandByKey], | 3360 | [threadJumpCommandByKey], |
| 3238 | ); | 3361 | ); |
| 3239 | const sidebarShortcutContext = useMemo( | 3362 | const sidebarShortcutContext = { |
| 3240 | () => ({ | 3363 | terminalFocus: false, |
| 3241 | terminalFocus: false, | 3364 | terminalOpen: routeTerminalOpen, |
| 3242 | terminalOpen: routeThreadRef | 3365 | modelPickerOpen: isModelPickerOpen(), |
| 3243 | ? selectThreadTerminalUiState( | 3366 | }; |
| 3244 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 3245 | routeThreadRef, | ||
| 3246 | ).terminalOpen | ||
| 3247 | : false, | ||
| 3248 | modelPickerOpen, | ||
| 3249 | }), | ||
| 3250 | [modelPickerOpen, routeThreadRef], | ||
| 3251 | ); | ||
| 3252 | const threadJumpLabelByKey = useMemo( | 3367 | const threadJumpLabelByKey = useMemo( |
| 3253 | () => | 3368 | () => |
| 3254 | buildThreadJumpLabelMap({ | 3369 | buildThreadJumpLabelMap({ |
| ... | @@ -3284,18 +3399,6 @@ export default function Sidebar() { | ... | @@ -3284,18 +3399,6 @@ export default function Sidebar() { |
| 3284 | [prewarmedSidebarThreadKeys], | 3399 | [prewarmedSidebarThreadKeys], |
| 3285 | ); | 3400 | ); |
| 3286 | 3401 | ||
| 3287 | useEffect(() => { | ||
| 3288 | const releases = prewarmedSidebarThreadRefs.map((ref) => | ||
| 3289 | retainThreadDetailSubscription(ref.environmentId, ref.threadId), | ||
| 3290 | ); | ||
| 3291 | |||
| 3292 | return () => { | ||
| 3293 | for (const release of releases) { | ||
| 3294 | release(); | ||
| 3295 | } | ||
| 3296 | }; | ||
| 3297 | }, [prewarmedSidebarThreadRefs]); | ||
| 3298 | |||
| 3299 | useEffect(() => { | 3402 | useEffect(() => { |
| 3300 | updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); | 3403 | updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); |
| 3301 | }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); | 3404 | }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); |
| ... | @@ -3382,39 +3485,6 @@ export default function Sidebar() { | ... | @@ -3382,39 +3485,6 @@ export default function Sidebar() { |
| 3382 | }; | 3485 | }; |
| 3383 | }, [clearSelection]); | 3486 | }, [clearSelection]); |
| 3384 | 3487 | ||
| 3385 | useEffect(() => { | ||
| 3386 | if (!isElectron) return; | ||
| 3387 | const bridge = window.desktopBridge; | ||
| 3388 | if ( | ||
| 3389 | !bridge || | ||
| 3390 | typeof bridge.getUpdateState !== "function" || | ||
| 3391 | typeof bridge.onUpdateState !== "function" | ||
| 3392 | ) { | ||
| 3393 | return; | ||
| 3394 | } | ||
| 3395 | |||
| 3396 | let disposed = false; | ||
| 3397 | let receivedSubscriptionUpdate = false; | ||
| 3398 | const unsubscribe = bridge.onUpdateState((nextState) => { | ||
| 3399 | if (disposed) return; | ||
| 3400 | receivedSubscriptionUpdate = true; | ||
| 3401 | setDesktopUpdateState(nextState); | ||
| 3402 | }); | ||
| 3403 | |||
| 3404 | void bridge | ||
| 3405 | .getUpdateState() | ||
| 3406 | .then((nextState) => { | ||
| 3407 | if (disposed || receivedSubscriptionUpdate) return; | ||
| 3408 | setDesktopUpdateState(nextState); | ||
| 3409 | }) | ||
| 3410 | .catch(() => undefined); | ||
| 3411 | |||
| 3412 | return () => { | ||
| 3413 | disposed = true; | ||
| 3414 | unsubscribe(); | ||
| 3415 | }; | ||
| 3416 | }, []); | ||
| 3417 | |||
| 3418 | const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); | 3488 | const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); |
| 3419 | const desktopUpdateButtonAction = desktopUpdateState | 3489 | const desktopUpdateButtonAction = desktopUpdateState |
| 3420 | ? resolveDesktopUpdateButtonAction(desktopUpdateState) | 3490 | ? resolveDesktopUpdateButtonAction(desktopUpdateState) |
| ... | @@ -3520,6 +3590,9 @@ export default function Sidebar() { | ... | @@ -3520,6 +3590,9 @@ export default function Sidebar() { |
| 3520 | 3590 | ||
| 3521 | return ( | 3591 | return ( |
| 3522 | <> | 3592 | <> |
| 3593 | {prewarmedSidebarThreadRefs.map((threadRef) => ( | ||
| 3594 | <SidebarThreadDetailPrewarmer key={scopedThreadKey(threadRef)} threadRef={threadRef} /> | ||
| 3595 | ))} | ||
| 3523 | <SidebarChromeHeader isElectron={isElectron} /> | 3596 | <SidebarChromeHeader isElectron={isElectron} /> |
| 3524 | 3597 | ||
| 3525 | {isOnSettings ? ( | 3598 | {isOnSettings ? ( |
apps/web/src/components/SlowRpcRequestToastCoordinator.tsx created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import { useEffect, useRef } from "react"; | ||
| 2 | |||
| 3 | import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; | ||
| 4 | import { toastManager } from "./ui/toast"; | ||
| 5 | |||
| 6 | function describeSlowRequests(requests: ReadonlyArray<SlowRpcAckRequest>): string { | ||
| 7 | const count = requests.length; | ||
| 8 | const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); | ||
| 9 | |||
| 10 | return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; | ||
| 11 | } | ||
| 12 | |||
| 13 | function SlowRequestDetails({ requests }: { requests: ReadonlyArray<SlowRpcAckRequest> }) { | ||
| 14 | return ( | ||
| 15 | <ul className="space-y-2.5 text-xs text-muted-foreground"> | ||
| 16 | {requests.map((request) => ( | ||
| 17 | <li | ||
| 18 | className="min-w-0 border-border/50 border-b pb-2 last:border-b-0 last:pb-0" | ||
| 19 | key={request.requestId} | ||
| 20 | > | ||
| 21 | <div className="wrap-break-word font-medium text-foreground">{request.tag}</div> | ||
| 22 | <div className="mt-0.5 text-[10px] opacity-75"> | ||
| 23 | Started {new Date(request.startedAt).toLocaleTimeString()} | ||
| 24 | </div> | ||
| 25 | </li> | ||
| 26 | ))} | ||
| 27 | </ul> | ||
| 28 | ); | ||
| 29 | } | ||
| 30 | |||
| 31 | export function SlowRpcRequestToastCoordinator() { | ||
| 32 | const slowRequests = useSlowRpcAckRequests(); | ||
| 33 | const toastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null); | ||
| 34 | |||
| 35 | useEffect(() => { | ||
| 36 | if (slowRequests.length === 0) { | ||
| 37 | if (toastIdRef.current !== null) { | ||
| 38 | toastManager.close(toastIdRef.current); | ||
| 39 | toastIdRef.current = null; | ||
| 40 | } | ||
| 41 | return; | ||
| 42 | } | ||
| 43 | |||
| 44 | const nextToast = { | ||
| 45 | data: { | ||
| 46 | expandableContent: <SlowRequestDetails requests={slowRequests} />, | ||
| 47 | expandableDescriptionTrigger: true, | ||
| 48 | expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, | ||
| 49 | }, | ||
| 50 | description: describeSlowRequests(slowRequests), | ||
| 51 | timeout: 0, | ||
| 52 | title: "Some requests are slow", | ||
| 53 | type: "warning" as const, | ||
| 54 | }; | ||
| 55 | |||
| 56 | if (toastIdRef.current === null) { | ||
| 57 | toastIdRef.current = toastManager.add(nextToast); | ||
| 58 | } else { | ||
| 59 | toastManager.update(toastIdRef.current, nextToast); | ||
| 60 | } | ||
| 61 | }, [slowRequests]); | ||
| 62 | |||
| 63 | useEffect( | ||
| 64 | () => () => { | ||
| 65 | if (toastIdRef.current !== null) { | ||
| 66 | toastManager.close(toastIdRef.current); | ||
| 67 | } | ||
| 68 | }, | ||
| 69 | [], | ||
| 70 | ); | ||
| 71 | |||
| 72 | return null; | ||
| 73 | } | ||
apps/web/src/components/ThreadStatusIndicators.tsx+24-26| ... | @@ -1,15 +1,16 @@ | ... | @@ -1,15 +1,16 @@ |
| 1 | import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { |
| 2 | scopeProjectRef, | ||
| 3 | scopedThreadKey, | ||
| 4 | scopeThreadRef, | ||
| 5 | } from "@t3tools/client-runtime/environment"; | ||
| 2 | import type { VcsStatusResult } from "@t3tools/contracts"; | 6 | import type { VcsStatusResult } from "@t3tools/contracts"; |
| 3 | import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; | 7 | import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; |
| 4 | import { useMemo } from "react"; | 8 | import { useMemo } from "react"; |
| 5 | import { usePrimaryEnvironmentId } from "../environments/primary"; | 9 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 6 | import { | 10 | import { useProject } from "../state/entities"; |
| 7 | useSavedEnvironmentRegistryStore, | 11 | import { useEnvironmentQuery } from "../state/query"; |
| 8 | useSavedEnvironmentRuntimeStore, | 12 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 9 | } from "../environments/runtime"; | 13 | import { vcsEnvironment } from "../state/vcs"; |
| 10 | import { useVcsStatus } from "../lib/vcsStatusState"; | ||
| 11 | import { type AppState, selectProjectByRef, useStore } from "../store"; | ||
| 12 | import { useThreadRunningTerminalIds } from "../terminalSessionState"; | ||
| 13 | import { useUiStateStore } from "../uiStateStore"; | 14 | import { useUiStateStore } from "../uiStateStore"; |
| 14 | import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; | 15 | import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; |
| 15 | import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; | 16 | import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; |
| ... | @@ -154,19 +155,22 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar | ... | @@ -154,19 +155,22 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar |
| 154 | const lastVisitedAt = useUiStateStore( | 155 | const lastVisitedAt = useUiStateStore( |
| 155 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], | 156 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], |
| 156 | ); | 157 | ); |
| 157 | const threadProjectCwd = useStore( | 158 | const threadProject = useProject( |
| 158 | useMemo( | 159 | useMemo( |
| 159 | () => (state: AppState) => | 160 | () => scopeProjectRef(thread.environmentId, thread.projectId), |
| 160 | selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? | ||
| 161 | null, | ||
| 162 | [thread.environmentId, thread.projectId], | 161 | [thread.environmentId, thread.projectId], |
| 163 | ), | 162 | ), |
| 164 | ); | 163 | ); |
| 164 | const threadProjectCwd = threadProject?.workspaceRoot ?? null; | ||
| 165 | const gitCwd = thread.worktreePath ?? threadProjectCwd; | 165 | const gitCwd = thread.worktreePath ?? threadProjectCwd; |
| 166 | const gitStatus = useVcsStatus({ | 166 | const gitStatus = useEnvironmentQuery( |
| 167 | environmentId: thread.environmentId, | 167 | thread.branch != null && gitCwd !== null |
| 168 | cwd: thread.branch != null ? gitCwd : null, | 168 | ? vcsEnvironment.status({ |
| 169 | }); | 169 | environmentId: thread.environmentId, |
| 170 | input: { cwd: gitCwd }, | ||
| 171 | }) | ||
| 172 | : null, | ||
| 173 | ); | ||
| 170 | const pr = resolveThreadPr(thread.branch, gitStatus.data); | 174 | const pr = resolveThreadPr(thread.branch, gitStatus.data); |
| 171 | const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); | 175 | const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); |
| 172 | const threadStatus = resolveThreadStatusPill({ | 176 | const threadStatus = resolveThreadStatusPill({ |
| ... | @@ -212,18 +216,12 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma | ... | @@ -212,18 +216,12 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma |
| 212 | environmentId: thread.environmentId, | 216 | environmentId: thread.environmentId, |
| 213 | threadId: thread.id, | 217 | threadId: thread.id, |
| 214 | }); | 218 | }); |
| 219 | const environment = useEnvironment(thread.environmentId); | ||
| 215 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 220 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 216 | const isRemoteThread = | 221 | const isRemoteThread = |
| 217 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; | 222 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; |
| 218 | const remoteEnvLabel = useSavedEnvironmentRuntimeStore( | 223 | const remoteEnvLabel = environment?.label ?? null; |
| 219 | (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null, | 224 | const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; |
| 220 | ); | ||
| 221 | const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( | ||
| 222 | (state) => state.byId[thread.environmentId]?.label ?? null, | ||
| 223 | ); | ||
| 224 | const threadEnvironmentLabel = isRemoteThread | ||
| 225 | ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") | ||
| 226 | : null; | ||
| 227 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); | 225 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 228 | 226 | ||
| 229 | if (!terminalStatus && !isRemoteThread) { | 227 | if (!terminalStatus && !isRemoteThread) { |
apps/web/src/components/ThreadTerminalDrawer.browser.tsx deleted-425| ... | @@ -1,425 +0,0 @@ | ||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 4 | import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; | ||
| 5 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | import { render } from "vitest-browser-react"; | ||
| 7 | |||
| 8 | const { | ||
| 9 | terminalConstructorSpy, | ||
| 10 | terminalDisposeSpy, | ||
| 11 | fitAddonFitSpy, | ||
| 12 | fitAddonLoadSpy, | ||
| 13 | environmentApiById, | ||
| 14 | readEnvironmentApiMock, | ||
| 15 | readLocalApiMock, | ||
| 16 | } = vi.hoisted(() => ({ | ||
| 17 | terminalConstructorSpy: vi.fn(), | ||
| 18 | terminalDisposeSpy: vi.fn(), | ||
| 19 | fitAddonFitSpy: vi.fn(), | ||
| 20 | fitAddonLoadSpy: vi.fn(), | ||
| 21 | environmentApiById: new Map< | ||
| 22 | string, | ||
| 23 | { | ||
| 24 | terminal: { | ||
| 25 | open: ReturnType<typeof vi.fn>; | ||
| 26 | attach: ReturnType<typeof vi.fn>; | ||
| 27 | write: ReturnType<typeof vi.fn>; | ||
| 28 | resize: ReturnType<typeof vi.fn>; | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | >(), | ||
| 32 | readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), | ||
| 33 | readLocalApiMock: vi.fn< | ||
| 34 | () => | ||
| 35 | | { | ||
| 36 | contextMenu: { show: ReturnType<typeof vi.fn> }; | ||
| 37 | shell: { openExternal: ReturnType<typeof vi.fn> }; | ||
| 38 | } | ||
| 39 | | undefined | ||
| 40 | >(() => ({ | ||
| 41 | contextMenu: { show: vi.fn(async () => null) }, | ||
| 42 | shell: { openExternal: vi.fn(async () => undefined) }, | ||
| 43 | })), | ||
| 44 | })); | ||
| 45 | |||
| 46 | vi.mock("@xterm/addon-fit", () => ({ | ||
| 47 | FitAddon: class MockFitAddon { | ||
| 48 | fit = fitAddonFitSpy; | ||
| 49 | }, | ||
| 50 | })); | ||
| 51 | |||
| 52 | vi.mock("@xterm/xterm", () => ({ | ||
| 53 | Terminal: class MockTerminal { | ||
| 54 | cols = 80; | ||
| 55 | rows = 24; | ||
| 56 | options: { theme?: unknown } = {}; | ||
| 57 | buffer = { | ||
| 58 | active: { | ||
| 59 | viewportY: 0, | ||
| 60 | baseY: 0, | ||
| 61 | getLine: vi.fn(() => null), | ||
| 62 | }, | ||
| 63 | }; | ||
| 64 | |||
| 65 | constructor(options: unknown) { | ||
| 66 | terminalConstructorSpy(options); | ||
| 67 | } | ||
| 68 | |||
| 69 | loadAddon(addon: unknown) { | ||
| 70 | fitAddonLoadSpy(addon); | ||
| 71 | } | ||
| 72 | |||
| 73 | open() {} | ||
| 74 | |||
| 75 | write() {} | ||
| 76 | |||
| 77 | clear() {} | ||
| 78 | |||
| 79 | clearSelection() {} | ||
| 80 | |||
| 81 | focus() {} | ||
| 82 | |||
| 83 | refresh() {} | ||
| 84 | |||
| 85 | scrollToBottom() {} | ||
| 86 | |||
| 87 | hasSelection() { | ||
| 88 | return false; | ||
| 89 | } | ||
| 90 | |||
| 91 | getSelection() { | ||
| 92 | return ""; | ||
| 93 | } | ||
| 94 | |||
| 95 | getSelectionPosition() { | ||
| 96 | return null; | ||
| 97 | } | ||
| 98 | |||
| 99 | attachCustomKeyEventHandler() { | ||
| 100 | return true; | ||
| 101 | } | ||
| 102 | |||
| 103 | registerLinkProvider() { | ||
| 104 | return { dispose: vi.fn() }; | ||
| 105 | } | ||
| 106 | |||
| 107 | onData() { | ||
| 108 | return { dispose: vi.fn() }; | ||
| 109 | } | ||
| 110 | |||
| 111 | onSelectionChange() { | ||
| 112 | return { dispose: vi.fn() }; | ||
| 113 | } | ||
| 114 | |||
| 115 | dispose() { | ||
| 116 | terminalDisposeSpy(); | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | })); | ||
| 120 | |||
| 121 | vi.mock("~/environmentApi", () => ({ | ||
| 122 | ensureEnvironmentApi: (environmentId: string) => { | ||
| 123 | const api = readEnvironmentApiMock(environmentId); | ||
| 124 | if (!api) { | ||
| 125 | throw new Error(`Environment API not found for ${environmentId}`); | ||
| 126 | } | ||
| 127 | return api; | ||
| 128 | }, | ||
| 129 | readEnvironmentApi: readEnvironmentApiMock, | ||
| 130 | })); | ||
| 131 | |||
| 132 | vi.mock("~/localApi", () => ({ | ||
| 133 | ensureLocalApi: vi.fn(() => { | ||
| 134 | throw new Error("ensureLocalApi not implemented in browser test"); | ||
| 135 | }), | ||
| 136 | readLocalApi: readLocalApiMock, | ||
| 137 | })); | ||
| 138 | |||
| 139 | import { TerminalViewport } from "./ThreadTerminalDrawer"; | ||
| 140 | |||
| 141 | const THREAD_ID = ThreadId.make("thread-terminal-browser"); | ||
| 142 | |||
| 143 | function createEnvironmentApi() { | ||
| 144 | const snapshot = { | ||
| 145 | threadId: THREAD_ID, | ||
| 146 | terminalId: "term-1", | ||
| 147 | cwd: "/repo/project", | ||
| 148 | worktreePath: null, | ||
| 149 | status: "running" as const, | ||
| 150 | pid: 123, | ||
| 151 | history: "", | ||
| 152 | exitCode: null, | ||
| 153 | exitSignal: null, | ||
| 154 | label: "Terminal 1", | ||
| 155 | updatedAt: "2026-04-07T00:00:00.000Z", | ||
| 156 | }; | ||
| 157 | |||
| 158 | return { | ||
| 159 | terminal: { | ||
| 160 | open: vi.fn(async () => snapshot), | ||
| 161 | attach: vi.fn( | ||
| 162 | ( | ||
| 163 | _input: unknown, | ||
| 164 | listener: (event: TerminalAttachStreamEvent) => void, | ||
| 165 | _options?: unknown, | ||
| 166 | ) => { | ||
| 167 | listener({ type: "snapshot", snapshot }); | ||
| 168 | return vi.fn(); | ||
| 169 | }, | ||
| 170 | ), | ||
| 171 | write: vi.fn(async () => undefined), | ||
| 172 | resize: vi.fn(async () => undefined), | ||
| 173 | }, | ||
| 174 | }; | ||
| 175 | } | ||
| 176 | |||
| 177 | async function mountTerminalViewport(props: { | ||
| 178 | threadRef: ReturnType<typeof scopeThreadRef>; | ||
| 179 | drawerBackgroundColor?: string; | ||
| 180 | drawerTextColor?: string; | ||
| 181 | runtimeEnv?: Record<string, string>; | ||
| 182 | }) { | ||
| 183 | const drawer = document.createElement("div"); | ||
| 184 | drawer.className = "thread-terminal-drawer"; | ||
| 185 | if (props.drawerBackgroundColor) { | ||
| 186 | drawer.style.backgroundColor = props.drawerBackgroundColor; | ||
| 187 | } | ||
| 188 | if (props.drawerTextColor) { | ||
| 189 | drawer.style.color = props.drawerTextColor; | ||
| 190 | } | ||
| 191 | |||
| 192 | const host = document.createElement("div"); | ||
| 193 | host.style.width = "800px"; | ||
| 194 | host.style.height = "400px"; | ||
| 195 | drawer.append(host); | ||
| 196 | document.body.append(drawer); | ||
| 197 | |||
| 198 | const screen = await render( | ||
| 199 | <TerminalViewport | ||
| 200 | threadRef={props.threadRef} | ||
| 201 | threadId={THREAD_ID} | ||
| 202 | terminalId="term-1" | ||
| 203 | terminalLabel="Terminal" | ||
| 204 | cwd="/repo/project" | ||
| 205 | {...(props.runtimeEnv ? { runtimeEnv: props.runtimeEnv } : {})} | ||
| 206 | onSessionExited={() => undefined} | ||
| 207 | onAddTerminalContext={() => undefined} | ||
| 208 | focusRequestId={0} | ||
| 209 | autoFocus={false} | ||
| 210 | resizeEpoch={0} | ||
| 211 | drawerHeight={320} | ||
| 212 | keybindings={[]} | ||
| 213 | />, | ||
| 214 | { container: host }, | ||
| 215 | ); | ||
| 216 | |||
| 217 | return { | ||
| 218 | rerender: async (nextProps: { | ||
| 219 | threadRef: ReturnType<typeof scopeThreadRef>; | ||
| 220 | runtimeEnv?: Record<string, string>; | ||
| 221 | }) => { | ||
| 222 | await screen.rerender( | ||
| 223 | <TerminalViewport | ||
| 224 | threadRef={nextProps.threadRef} | ||
| 225 | threadId={THREAD_ID} | ||
| 226 | terminalId="term-1" | ||
| 227 | terminalLabel="Terminal" | ||
| 228 | cwd="/repo/project" | ||
| 229 | {...(nextProps.runtimeEnv ? { runtimeEnv: nextProps.runtimeEnv } : {})} | ||
| 230 | onSessionExited={() => undefined} | ||
| 231 | onAddTerminalContext={() => undefined} | ||
| 232 | focusRequestId={0} | ||
| 233 | autoFocus={false} | ||
| 234 | resizeEpoch={0} | ||
| 235 | drawerHeight={320} | ||
| 236 | keybindings={[]} | ||
| 237 | />, | ||
| 238 | ); | ||
| 239 | }, | ||
| 240 | cleanup: async () => { | ||
| 241 | await screen.unmount(); | ||
| 242 | drawer.remove(); | ||
| 243 | }, | ||
| 244 | }; | ||
| 245 | } | ||
| 246 | |||
| 247 | describe("TerminalViewport", () => { | ||
| 248 | afterEach(() => { | ||
| 249 | environmentApiById.clear(); | ||
| 250 | readEnvironmentApiMock.mockClear(); | ||
| 251 | readLocalApiMock.mockClear(); | ||
| 252 | terminalConstructorSpy.mockClear(); | ||
| 253 | terminalDisposeSpy.mockClear(); | ||
| 254 | fitAddonFitSpy.mockClear(); | ||
| 255 | fitAddonLoadSpy.mockClear(); | ||
| 256 | }); | ||
| 257 | |||
| 258 | it("does not create a terminal when APIs are unavailable", async () => { | ||
| 259 | readEnvironmentApiMock.mockReturnValueOnce(undefined); | ||
| 260 | readLocalApiMock.mockReturnValueOnce(undefined); | ||
| 261 | |||
| 262 | const mounted = await mountTerminalViewport({ | ||
| 263 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 264 | }); | ||
| 265 | |||
| 266 | try { | ||
| 267 | await vi.waitFor(() => { | ||
| 268 | expect(terminalConstructorSpy).not.toHaveBeenCalled(); | ||
| 269 | }); | ||
| 270 | } finally { | ||
| 271 | await mounted.cleanup(); | ||
| 272 | } | ||
| 273 | }); | ||
| 274 | |||
| 275 | it("renders and attaches the terminal without the desktop local API", async () => { | ||
| 276 | const environment = createEnvironmentApi(); | ||
| 277 | environmentApiById.set("environment-a", environment); | ||
| 278 | readLocalApiMock.mockReturnValueOnce(undefined); | ||
| 279 | |||
| 280 | const mounted = await mountTerminalViewport({ | ||
| 281 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 282 | }); | ||
| 283 | |||
| 284 | try { | ||
| 285 | await vi.waitFor(() => { | ||
| 286 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 287 | }); | ||
| 288 | expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); | ||
| 289 | } finally { | ||
| 290 | await mounted.cleanup(); | ||
| 291 | } | ||
| 292 | }); | ||
| 293 | |||
| 294 | it("keeps the terminal mounted when xterm fit runs before dimensions are ready", async () => { | ||
| 295 | const environment = createEnvironmentApi(); | ||
| 296 | environmentApiById.set("environment-a", environment); | ||
| 297 | fitAddonFitSpy.mockImplementationOnce(() => { | ||
| 298 | throw new TypeError("Cannot read properties of undefined (reading 'dimensions')"); | ||
| 299 | }); | ||
| 300 | |||
| 301 | const mounted = await mountTerminalViewport({ | ||
| 302 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 303 | }); | ||
| 304 | |||
| 305 | try { | ||
| 306 | await vi.waitFor(() => { | ||
| 307 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 308 | }); | ||
| 309 | expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); | ||
| 310 | expect(fitAddonFitSpy).toHaveBeenCalled(); | ||
| 311 | } finally { | ||
| 312 | await mounted.cleanup(); | ||
| 313 | } | ||
| 314 | }); | ||
| 315 | |||
| 316 | it("reattaches the terminal when the scoped thread reference changes", async () => { | ||
| 317 | const environmentA = createEnvironmentApi(); | ||
| 318 | const environmentB = createEnvironmentApi(); | ||
| 319 | environmentApiById.set("environment-a", environmentA); | ||
| 320 | environmentApiById.set("environment-b", environmentB); | ||
| 321 | |||
| 322 | const mounted = await mountTerminalViewport({ | ||
| 323 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 324 | }); | ||
| 325 | |||
| 326 | try { | ||
| 327 | await vi.waitFor(() => { | ||
| 328 | expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 329 | }); | ||
| 330 | |||
| 331 | await mounted.rerender({ | ||
| 332 | threadRef: scopeThreadRef("environment-b" as never, THREAD_ID), | ||
| 333 | }); | ||
| 334 | |||
| 335 | await vi.waitFor(() => { | ||
| 336 | expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 337 | }); | ||
| 338 | expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); | ||
| 339 | } finally { | ||
| 340 | await mounted.cleanup(); | ||
| 341 | } | ||
| 342 | }); | ||
| 343 | |||
| 344 | it("does not reattach the terminal when the scoped thread reference values stay the same", async () => { | ||
| 345 | const environment = createEnvironmentApi(); | ||
| 346 | environmentApiById.set("environment-a", environment); | ||
| 347 | |||
| 348 | const mounted = await mountTerminalViewport({ | ||
| 349 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 350 | }); | ||
| 351 | |||
| 352 | try { | ||
| 353 | await vi.waitFor(() => { | ||
| 354 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 355 | }); | ||
| 356 | |||
| 357 | await mounted.rerender({ | ||
| 358 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 359 | }); | ||
| 360 | |||
| 361 | await vi.waitFor(() => { | ||
| 362 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 363 | }); | ||
| 364 | expect(terminalDisposeSpy).not.toHaveBeenCalled(); | ||
| 365 | } finally { | ||
| 366 | await mounted.cleanup(); | ||
| 367 | } | ||
| 368 | }); | ||
| 369 | |||
| 370 | it("does not reattach when runtime env contents are unchanged but object identity changes", async () => { | ||
| 371 | const environment = createEnvironmentApi(); | ||
| 372 | environmentApiById.set("environment-a", environment); | ||
| 373 | |||
| 374 | const mounted = await mountTerminalViewport({ | ||
| 375 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 376 | runtimeEnv: { PATH: "/usr/bin", T3: "1" }, | ||
| 377 | }); | ||
| 378 | |||
| 379 | try { | ||
| 380 | await vi.waitFor(() => { | ||
| 381 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 382 | }); | ||
| 383 | |||
| 384 | await mounted.rerender({ | ||
| 385 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 386 | runtimeEnv: { T3: "1", PATH: "/usr/bin" }, | ||
| 387 | }); | ||
| 388 | |||
| 389 | await vi.waitFor(() => { | ||
| 390 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 391 | }); | ||
| 392 | expect(terminalDisposeSpy).not.toHaveBeenCalled(); | ||
| 393 | } finally { | ||
| 394 | await mounted.cleanup(); | ||
| 395 | } | ||
| 396 | }); | ||
| 397 | |||
| 398 | it("uses the drawer surface colors for the terminal theme", async () => { | ||
| 399 | const environment = createEnvironmentApi(); | ||
| 400 | environmentApiById.set("environment-a", environment); | ||
| 401 | |||
| 402 | const mounted = await mountTerminalViewport({ | ||
| 403 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 404 | drawerBackgroundColor: "rgb(24, 28, 36)", | ||
| 405 | drawerTextColor: "rgb(228, 232, 240)", | ||
| 406 | }); | ||
| 407 | |||
| 408 | try { | ||
| 409 | await vi.waitFor(() => { | ||
| 410 | expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); | ||
| 411 | }); | ||
| 412 | |||
| 413 | expect(terminalConstructorSpy).toHaveBeenCalledWith( | ||
| 414 | expect.objectContaining({ | ||
| 415 | theme: expect.objectContaining({ | ||
| 416 | background: "rgb(24, 28, 36)", | ||
| 417 | foreground: "rgb(228, 232, 240)", | ||
| 418 | }), | ||
| 419 | }), | ||
| 420 | ); | ||
| 421 | } finally { | ||
| 422 | await mounted.cleanup(); | ||
| 423 | } | ||
| 424 | }); | ||
| 425 | }); | ||
apps/web/src/components/ThreadTerminalDrawer.tsx+202-256| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 1 | import { FitAddon } from "@xterm/addon-fit"; | 2 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | import { | 3 | import { |
| 3 | Globe2, | 4 | isAtomCommandInterrupted, |
| 5 | squashAtomCommandFailure, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import { | ||
| 4 | Plus, | 8 | Plus, |
| 5 | SquareSplitHorizontal, | 9 | SquareSplitHorizontal, |
| 6 | SquareSplitVertical, | 10 | SquareSplitVertical, |
| ... | @@ -11,8 +15,6 @@ import { | ... | @@ -11,8 +15,6 @@ import { |
| 11 | import { | 15 | import { |
| 12 | type ResolvedKeybindingsConfig, | 16 | type ResolvedKeybindingsConfig, |
| 13 | type ScopedThreadRef, | 17 | type ScopedThreadRef, |
| 14 | type TerminalAttachStreamEvent, | ||
| 15 | type TerminalSessionSnapshot, | ||
| 16 | type ThreadId, | 18 | type ThreadId, |
| 17 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 18 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | 20 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| ... | @@ -20,6 +22,7 @@ import { Terminal, type ITheme } from "@xterm/xterm"; | ... | @@ -20,6 +22,7 @@ import { Terminal, type ITheme } from "@xterm/xterm"; |
| 20 | import { | 22 | import { |
| 21 | type PointerEvent as ReactPointerEvent, | 23 | type PointerEvent as ReactPointerEvent, |
| 22 | type ReactNode, | 24 | type ReactNode, |
| 25 | type SetStateAction, | ||
| 23 | useCallback, | 26 | useCallback, |
| 24 | useEffect, | 27 | useEffect, |
| 25 | useEffectEvent, | 28 | useEffectEvent, |
| ... | @@ -30,7 +33,7 @@ import { | ... | @@ -30,7 +33,7 @@ import { |
| 30 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | 33 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 31 | import { cn } from "~/lib/utils"; | 34 | import { cn } from "~/lib/utils"; |
| 32 | import { type TerminalContextSelection } from "~/lib/terminalContext"; | 35 | import { type TerminalContextSelection } from "~/lib/terminalContext"; |
| 33 | import { openInPreferredEditor } from "../editorPreferences"; | 36 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 34 | import { | 37 | import { |
| 35 | collectWrappedTerminalLinkLine, | 38 | collectWrappedTerminalLinkLine, |
| 36 | extractTerminalLinks, | 39 | extractTerminalLinks, |
| ... | @@ -55,12 +58,13 @@ import { | ... | @@ -55,12 +58,13 @@ import { |
| 55 | MAX_TERMINALS_PER_GROUP, | 58 | MAX_TERMINALS_PER_GROUP, |
| 56 | type ThreadTerminalGroup, | 59 | type ThreadTerminalGroup, |
| 57 | } from "../types"; | 60 | } from "../types"; |
| 58 | import { readEnvironmentApi } from "~/environmentApi"; | ||
| 59 | import { readLocalApi } from "~/localApi"; | 61 | import { readLocalApi } from "~/localApi"; |
| 60 | import { attachTerminalSession } from "../terminalSessionState"; | 62 | import { useAttachedTerminalSession } from "../state/terminalSessions"; |
| 63 | import { serverEnvironment } from "../state/server"; | ||
| 64 | import { previewEnvironment } from "../state/preview"; | ||
| 65 | import { terminalEnvironment } from "../state/terminal"; | ||
| 61 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; | 66 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; |
| 62 | import { useDiscoveredPorts } from "../portDiscoveryState"; | 67 | import { useAtomCommand } from "../state/use-atom-command"; |
| 63 | import { openDiscoveredPort } from "./preview/openDiscoveredPort"; | ||
| 64 | 68 | ||
| 65 | const MIN_DRAWER_HEIGHT = 180; | 69 | const MIN_DRAWER_HEIGHT = 180; |
| 66 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; | 70 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | @@ -81,10 +85,10 @@ function writeSystemMessage(terminal: Terminal, message: string): void { | ... | @@ -81,10 +85,10 @@ function writeSystemMessage(terminal: Terminal, message: string): void { |
| 81 | terminal.write(`\r\n[terminal] ${message}\r\n`); | 85 | terminal.write(`\r\n[terminal] ${message}\r\n`); |
| 82 | } | 86 | } |
| 83 | 87 | ||
| 84 | function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void { | 88 | function writeTerminalBuffer(terminal: Terminal, buffer: string): void { |
| 85 | terminal.write("\u001bc"); | 89 | terminal.write("\u001bc"); |
| 86 | if (snapshot.history.length > 0) { | 90 | if (buffer.length > 0) { |
| 87 | terminal.write(snapshot.history); | 91 | terminal.write(buffer); |
| 88 | } | 92 | } |
| 89 | } | 93 | } |
| 90 | 94 | ||
| ... | @@ -307,6 +311,21 @@ export function TerminalViewport({ | ... | @@ -307,6 +311,21 @@ export function TerminalViewport({ |
| 307 | const terminalRef = useRef<Terminal | null>(null); | 311 | const terminalRef = useRef<Terminal | null>(null); |
| 308 | const fitAddonRef = useRef<FitAddon | null>(null); | 312 | const fitAddonRef = useRef<FitAddon | null>(null); |
| 309 | const environmentId = threadRef.environmentId; | 313 | const environmentId = threadRef.environmentId; |
| 314 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); | ||
| 315 | const openInPreferredEditor = useOpenInPreferredEditor( | ||
| 316 | environmentId, | ||
| 317 | serverConfig?.availableEditors ?? [], | ||
| 318 | ); | ||
| 319 | const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target)); | ||
| 320 | const openPreview = useAtomCommand(previewEnvironment.open, { | ||
| 321 | reportFailure: false, | ||
| 322 | }); | ||
| 323 | const runTerminalWrite = useAtomCommand(terminalEnvironment.write, { | ||
| 324 | reportFailure: false, | ||
| 325 | }); | ||
| 326 | const runTerminalResize = useAtomCommand(terminalEnvironment.resize, { | ||
| 327 | reportFailure: false, | ||
| 328 | }); | ||
| 310 | const hasHandledExitRef = useRef(false); | 329 | const hasHandledExitRef = useRef(false); |
| 311 | const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); | 330 | const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); |
| 312 | const selectionGestureActiveRef = useRef(false); | 331 | const selectionGestureActiveRef = useRef(false); |
| ... | @@ -322,6 +341,38 @@ export function TerminalViewport({ | ... | @@ -322,6 +341,38 @@ export function TerminalViewport({ |
| 322 | onAddTerminalContext(selection); | 341 | onAddTerminalContext(selection); |
| 323 | }); | 342 | }); |
| 324 | const readTerminalLabel = useEffectEvent(() => terminalLabel); | 343 | const readTerminalLabel = useEffectEvent(() => terminalLabel); |
| 344 | const terminalSession = useAttachedTerminalSession({ | ||
| 345 | environmentId, | ||
| 346 | terminal: { | ||
| 347 | threadId, | ||
| 348 | terminalId, | ||
| 349 | cwd, | ||
| 350 | ...(worktreePath !== undefined ? { worktreePath } : {}), | ||
| 351 | ...(runtimeEnv ? { env: runtimeEnv } : {}), | ||
| 352 | }, | ||
| 353 | }); | ||
| 354 | const writeTerminal = useEffectEvent((data: string) => | ||
| 355 | runTerminalWrite({ | ||
| 356 | environmentId, | ||
| 357 | input: { threadId, terminalId, data }, | ||
| 358 | }), | ||
| 359 | ); | ||
| 360 | const resizeTerminal = useEffectEvent((cols: number, rows: number) => | ||
| 361 | runTerminalResize({ | ||
| 362 | environmentId, | ||
| 363 | input: { threadId, terminalId, cols, rows }, | ||
| 364 | }), | ||
| 365 | ); | ||
| 366 | const terminalBuffer = terminalSession.buffer; | ||
| 367 | const terminalError = terminalSession.error; | ||
| 368 | const terminalStatus = terminalSession.status; | ||
| 369 | const terminalVersion = terminalSession.version; | ||
| 370 | const previousSessionRef = useRef({ | ||
| 371 | buffer: terminalBuffer, | ||
| 372 | error: terminalError, | ||
| 373 | status: terminalStatus, | ||
| 374 | version: terminalVersion, | ||
| 375 | }); | ||
| 325 | 376 | ||
| 326 | useEffect(() => { | 377 | useEffect(() => { |
| 327 | keybindingsRef.current = keybindings; | 378 | keybindingsRef.current = keybindings; |
| ... | @@ -331,10 +382,7 @@ export function TerminalViewport({ | ... | @@ -331,10 +382,7 @@ export function TerminalViewport({ |
| 331 | const mount = containerRef.current; | 382 | const mount = containerRef.current; |
| 332 | if (!mount) return; | 383 | if (!mount) return; |
| 333 | 384 | ||
| 334 | let disposed = false; | ||
| 335 | const api = readEnvironmentApi(environmentId); | ||
| 336 | const localApi = readLocalApi(); | 385 | const localApi = readLocalApi(); |
| 337 | if (!api) return; | ||
| 338 | 386 | ||
| 339 | const fitAddon = new FitAddon(); | 387 | const fitAddon = new FitAddon(); |
| 340 | const terminal = new Terminal({ | 388 | const terminal = new Terminal({ |
| ... | @@ -352,6 +400,12 @@ export function TerminalViewport({ | ... | @@ -352,6 +400,12 @@ export function TerminalViewport({ |
| 352 | 400 | ||
| 353 | terminalRef.current = terminal; | 401 | terminalRef.current = terminal; |
| 354 | fitAddonRef.current = fitAddon; | 402 | fitAddonRef.current = fitAddon; |
| 403 | previousSessionRef.current = { | ||
| 404 | buffer: "", | ||
| 405 | status: "closed", | ||
| 406 | error: null, | ||
| 407 | version: 0, | ||
| 408 | }; | ||
| 355 | 409 | ||
| 356 | const clearSelectionAction = () => { | 410 | const clearSelectionAction = () => { |
| 357 | selectionActionRequestIdRef.current += 1; | 411 | selectionActionRequestIdRef.current += 1; |
| ... | @@ -435,9 +489,9 @@ export function TerminalViewport({ | ... | @@ -435,9 +489,9 @@ export function TerminalViewport({ |
| 435 | const sendTerminalInput = async (data: string, fallbackError: string) => { | 489 | const sendTerminalInput = async (data: string, fallbackError: string) => { |
| 436 | const activeTerminal = terminalRef.current; | 490 | const activeTerminal = terminalRef.current; |
| 437 | if (!activeTerminal) return; | 491 | if (!activeTerminal) return; |
| 438 | try { | 492 | const result = await writeTerminal(data); |
| 439 | await api.terminal.write({ threadId, terminalId, data }); | 493 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 440 | } catch (error) { | 494 | const error = squashAtomCommandFailure(result); |
| 441 | writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); | 495 | writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); |
| 442 | } | 496 | } |
| 443 | }; | 497 | }; |
| ... | @@ -517,12 +571,15 @@ export function TerminalViewport({ | ... | @@ -517,12 +571,15 @@ export function TerminalViewport({ |
| 517 | 571 | ||
| 518 | const latestTerminal = terminalRef.current; | 572 | const latestTerminal = terminalRef.current; |
| 519 | if (!latestTerminal) return; | 573 | if (!latestTerminal) return; |
| 520 | if (!localApi) { | ||
| 521 | writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); | ||
| 522 | return; | ||
| 523 | } | ||
| 524 | 574 | ||
| 525 | if (match.kind === "url") { | 575 | if (match.kind === "url") { |
| 576 | if (!localApi) { | ||
| 577 | writeSystemMessage( | ||
| 578 | latestTerminal, | ||
| 579 | "Opening links is unavailable in this browser.", | ||
| 580 | ); | ||
| 581 | return; | ||
| 582 | } | ||
| 526 | const fallbackToBrowser = () => { | 583 | const fallbackToBrowser = () => { |
| 527 | void localApi.shell.openExternal(match.text).catch((error: unknown) => { | 584 | void localApi.shell.openExternal(match.text).catch((error: unknown) => { |
| 528 | writeSystemMessage( | 585 | writeSystemMessage( |
| ... | @@ -531,16 +588,11 @@ export function TerminalViewport({ | ... | @@ -531,16 +588,11 @@ export function TerminalViewport({ |
| 531 | ); | 588 | ); |
| 532 | }); | 589 | }); |
| 533 | }; | 590 | }; |
| 534 | const api = readEnvironmentApi(threadRef.environmentId); | ||
| 535 | if (!api) { | ||
| 536 | fallbackToBrowser(); | ||
| 537 | return; | ||
| 538 | } | ||
| 539 | void openTerminalLinkInPreview({ | 591 | void openTerminalLinkInPreview({ |
| 540 | url: match.text, | 592 | url: match.text, |
| 541 | position: { x: event.clientX, y: event.clientY }, | 593 | position: { x: event.clientX, y: event.clientY }, |
| 542 | threadRef, | 594 | threadRef, |
| 543 | api, | 595 | openPreview, |
| 544 | localApi, | 596 | localApi, |
| 545 | fallbackToBrowser, | 597 | fallbackToBrowser, |
| 546 | }); | 598 | }); |
| ... | @@ -548,12 +600,17 @@ export function TerminalViewport({ | ... | @@ -548,12 +600,17 @@ export function TerminalViewport({ |
| 548 | } | 600 | } |
| 549 | 601 | ||
| 550 | const target = resolvePathLinkTarget(match.text, cwd); | 602 | const target = resolvePathLinkTarget(match.text, cwd); |
| 551 | void openInPreferredEditor(localApi, target).catch((error) => { | 603 | void (async () => { |
| 604 | const result = await openTerminalPath(target); | ||
| 605 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | ||
| 606 | return; | ||
| 607 | } | ||
| 608 | const error = squashAtomCommandFailure(result); | ||
| 552 | writeSystemMessage( | 609 | writeSystemMessage( |
| 553 | latestTerminal, | 610 | latestTerminal, |
| 554 | error instanceof Error ? error.message : "Unable to open path", | 611 | error instanceof Error ? error.message : "Unable to open path", |
| 555 | ); | 612 | ); |
| 556 | }); | 613 | })(); |
| 557 | }, | 614 | }, |
| 558 | })), | 615 | })), |
| 559 | ); | 616 | ); |
| ... | @@ -561,14 +618,17 @@ export function TerminalViewport({ | ... | @@ -561,14 +618,17 @@ export function TerminalViewport({ |
| 561 | }); | 618 | }); |
| 562 | 619 | ||
| 563 | const inputDisposable = terminal.onData((data) => { | 620 | const inputDisposable = terminal.onData((data) => { |
| 564 | void api.terminal | 621 | void (async () => { |
| 565 | .write({ threadId, terminalId, data }) | 622 | const result = await writeTerminal(data); |
| 566 | .catch((err) => | 623 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { |
| 567 | writeSystemMessage( | 624 | return; |
| 568 | terminal, | 625 | } |
| 569 | err instanceof Error ? err.message : "Terminal write failed", | 626 | const error = squashAtomCommandFailure(result); |
| 570 | ), | 627 | writeSystemMessage( |
| 628 | terminal, | ||
| 629 | error instanceof Error ? error.message : "Terminal write failed", | ||
| 571 | ); | 630 | ); |
| 631 | })(); | ||
| 572 | }); | 632 | }); |
| 573 | 633 | ||
| 574 | const selectionDisposable = terminal.onSelectionChange(() => { | 634 | const selectionDisposable = terminal.onSelectionChange(() => { |
| ... | @@ -614,107 +674,6 @@ export function TerminalViewport({ | ... | @@ -614,107 +674,6 @@ export function TerminalViewport({ |
| 614 | attributeFilter: ["class", "style"], | 674 | attributeFilter: ["class", "style"], |
| 615 | }); | 675 | }); |
| 616 | 676 | ||
| 617 | const applyAttachEvent = (event: TerminalAttachStreamEvent) => { | ||
| 618 | const activeTerminal = terminalRef.current; | ||
| 619 | if (!activeTerminal) { | ||
| 620 | return; | ||
| 621 | } | ||
| 622 | |||
| 623 | if (event.type === "activity") { | ||
| 624 | return; | ||
| 625 | } | ||
| 626 | |||
| 627 | if (event.type === "snapshot") { | ||
| 628 | hasHandledExitRef.current = false; | ||
| 629 | clearSelectionAction(); | ||
| 630 | writeTerminalSnapshot(activeTerminal, event.snapshot); | ||
| 631 | return; | ||
| 632 | } | ||
| 633 | |||
| 634 | if (event.type === "output") { | ||
| 635 | activeTerminal.write(event.data); | ||
| 636 | clearSelectionAction(); | ||
| 637 | return; | ||
| 638 | } | ||
| 639 | |||
| 640 | if (event.type === "restarted") { | ||
| 641 | hasHandledExitRef.current = false; | ||
| 642 | clearSelectionAction(); | ||
| 643 | writeTerminalSnapshot(activeTerminal, event.snapshot); | ||
| 644 | return; | ||
| 645 | } | ||
| 646 | |||
| 647 | if (event.type === "cleared") { | ||
| 648 | clearSelectionAction(); | ||
| 649 | activeTerminal.clear(); | ||
| 650 | activeTerminal.write("\u001bc"); | ||
| 651 | return; | ||
| 652 | } | ||
| 653 | |||
| 654 | if (event.type === "error") { | ||
| 655 | writeSystemMessage(activeTerminal, event.message); | ||
| 656 | return; | ||
| 657 | } | ||
| 658 | |||
| 659 | if (event.type === "closed") { | ||
| 660 | writeSystemMessage(activeTerminal, "Terminal closed"); | ||
| 661 | } else { | ||
| 662 | const details = [ | ||
| 663 | typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, | ||
| 664 | typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, | ||
| 665 | ] | ||
| 666 | .filter((value): value is string => value !== null) | ||
| 667 | .join(", "); | ||
| 668 | writeSystemMessage( | ||
| 669 | activeTerminal, | ||
| 670 | details.length > 0 ? `Process exited (${details})` : "Process exited", | ||
| 671 | ); | ||
| 672 | } | ||
| 673 | |||
| 674 | if (hasHandledExitRef.current) { | ||
| 675 | return; | ||
| 676 | } | ||
| 677 | hasHandledExitRef.current = true; | ||
| 678 | window.setTimeout(() => { | ||
| 679 | if (!hasHandledExitRef.current) { | ||
| 680 | return; | ||
| 681 | } | ||
| 682 | handleSessionExited(); | ||
| 683 | }, 0); | ||
| 684 | }; | ||
| 685 | let unsubscribeAttach: (() => void) | null = null; | ||
| 686 | const attachTerminal = () => { | ||
| 687 | const activeTerminal = terminalRef.current; | ||
| 688 | const activeFitAddon = fitAddonRef.current; | ||
| 689 | if (!activeTerminal || !activeFitAddon) return; | ||
| 690 | fitTerminalSafely(activeFitAddon); | ||
| 691 | unsubscribeAttach = attachTerminalSession({ | ||
| 692 | environmentId, | ||
| 693 | client: api, | ||
| 694 | terminal: { | ||
| 695 | threadId, | ||
| 696 | terminalId, | ||
| 697 | cwd, | ||
| 698 | ...(worktreePath !== undefined ? { worktreePath } : {}), | ||
| 699 | cols: activeTerminal.cols, | ||
| 700 | rows: activeTerminal.rows, | ||
| 701 | ...(runtimeEnv ? { env: runtimeEnv } : {}), | ||
| 702 | }, | ||
| 703 | onEvent: (event) => { | ||
| 704 | if (disposed) return; | ||
| 705 | applyAttachEvent(event); | ||
| 706 | }, | ||
| 707 | onSnapshot: () => { | ||
| 708 | if (disposed) return; | ||
| 709 | if (autoFocus) { | ||
| 710 | window.requestAnimationFrame(() => { | ||
| 711 | activeTerminal.focus(); | ||
| 712 | }); | ||
| 713 | } | ||
| 714 | }, | ||
| 715 | }); | ||
| 716 | }; | ||
| 717 | |||
| 718 | const fitTimer = window.setTimeout(() => { | 677 | const fitTimer = window.setTimeout(() => { |
| 719 | const activeTerminal = terminalRef.current; | 678 | const activeTerminal = terminalRef.current; |
| 720 | const activeFitAddon = fitAddonRef.current; | 679 | const activeFitAddon = fitAddonRef.current; |
| ... | @@ -725,54 +684,11 @@ export function TerminalViewport({ | ... | @@ -725,54 +684,11 @@ export function TerminalViewport({ |
| 725 | if (wasAtBottom) { | 684 | if (wasAtBottom) { |
| 726 | activeTerminal.scrollToBottom(); | 685 | activeTerminal.scrollToBottom(); |
| 727 | } | 686 | } |
| 728 | void api.terminal | 687 | void resizeTerminal(activeTerminal.cols, activeTerminal.rows); |
| 729 | .resize({ | ||
| 730 | threadId, | ||
| 731 | terminalId, | ||
| 732 | cols: activeTerminal.cols, | ||
| 733 | rows: activeTerminal.rows, | ||
| 734 | }) | ||
| 735 | .catch(() => undefined); | ||
| 736 | }, 30); | 688 | }, 30); |
| 737 | attachTerminal(); | ||
| 738 | let resizeFrame = 0; | ||
| 739 | const resizeObserver = | ||
| 740 | typeof ResizeObserver === "undefined" | ||
| 741 | ? null | ||
| 742 | : new ResizeObserver(() => { | ||
| 743 | if (resizeFrame !== 0) return; | ||
| 744 | resizeFrame = window.requestAnimationFrame(() => { | ||
| 745 | resizeFrame = 0; | ||
| 746 | const activeTerminal = terminalRef.current; | ||
| 747 | const activeFitAddon = fitAddonRef.current; | ||
| 748 | if (!activeTerminal || !activeFitAddon) return; | ||
| 749 | const wasAtBottom = | ||
| 750 | activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; | ||
| 751 | fitTerminalSafely(activeFitAddon); | ||
| 752 | if (wasAtBottom) { | ||
| 753 | activeTerminal.scrollToBottom(); | ||
| 754 | } | ||
| 755 | void api.terminal | ||
| 756 | .resize({ | ||
| 757 | threadId, | ||
| 758 | terminalId, | ||
| 759 | cols: activeTerminal.cols, | ||
| 760 | rows: activeTerminal.rows, | ||
| 761 | }) | ||
| 762 | .catch(() => undefined); | ||
| 763 | }); | ||
| 764 | }); | ||
| 765 | resizeObserver?.observe(mount); | ||
| 766 | 689 | ||
| 767 | return () => { | 690 | return () => { |
| 768 | disposed = true; | ||
| 769 | unsubscribeAttach?.(); | ||
| 770 | unsubscribeAttach = null; | ||
| 771 | window.clearTimeout(fitTimer); | 691 | window.clearTimeout(fitTimer); |
| 772 | if (resizeFrame !== 0) { | ||
| 773 | window.cancelAnimationFrame(resizeFrame); | ||
| 774 | } | ||
| 775 | resizeObserver?.disconnect(); | ||
| 776 | inputDisposable.dispose(); | 692 | inputDisposable.dispose(); |
| 777 | selectionDisposable.dispose(); | 693 | selectionDisposable.dispose(); |
| 778 | terminalLinksDisposable.dispose(); | 694 | terminalLinksDisposable.dispose(); |
| ... | @@ -791,6 +707,65 @@ export function TerminalViewport({ | ... | @@ -791,6 +707,65 @@ export function TerminalViewport({ |
| 791 | // eslint-disable-next-line react-hooks/exhaustive-deps | 707 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 792 | }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); | 708 | }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); |
| 793 | 709 | ||
| 710 | useEffect(() => { | ||
| 711 | const terminal = terminalRef.current; | ||
| 712 | const current = { | ||
| 713 | buffer: terminalBuffer, | ||
| 714 | error: terminalError, | ||
| 715 | status: terminalStatus, | ||
| 716 | version: terminalVersion, | ||
| 717 | }; | ||
| 718 | if (!terminal) { | ||
| 719 | previousSessionRef.current = current; | ||
| 720 | return; | ||
| 721 | } | ||
| 722 | |||
| 723 | const previous = previousSessionRef.current; | ||
| 724 | if (current.version === previous.version) { | ||
| 725 | return; | ||
| 726 | } | ||
| 727 | |||
| 728 | if ( | ||
| 729 | current.buffer.length >= previous.buffer.length && | ||
| 730 | current.buffer.startsWith(previous.buffer) | ||
| 731 | ) { | ||
| 732 | terminal.write(current.buffer.slice(previous.buffer.length)); | ||
| 733 | } else { | ||
| 734 | writeTerminalBuffer(terminal, current.buffer); | ||
| 735 | } | ||
| 736 | terminal.clearSelection(); | ||
| 737 | |||
| 738 | if (current.error !== null && current.error !== previous.error) { | ||
| 739 | writeSystemMessage(terminal, current.error); | ||
| 740 | } | ||
| 741 | |||
| 742 | if (current.status === "running") { | ||
| 743 | hasHandledExitRef.current = false; | ||
| 744 | } else if ( | ||
| 745 | (current.status === "closed" || current.status === "exited") && | ||
| 746 | current.status !== previous.status && | ||
| 747 | !hasHandledExitRef.current | ||
| 748 | ) { | ||
| 749 | hasHandledExitRef.current = true; | ||
| 750 | writeSystemMessage( | ||
| 751 | terminal, | ||
| 752 | current.status === "closed" ? "Terminal closed" : "Process exited", | ||
| 753 | ); | ||
| 754 | window.setTimeout(() => { | ||
| 755 | if (hasHandledExitRef.current) { | ||
| 756 | handleSessionExited(); | ||
| 757 | } | ||
| 758 | }, 0); | ||
| 759 | } | ||
| 760 | |||
| 761 | if (previous.version === 0 && autoFocus) { | ||
| 762 | window.requestAnimationFrame(() => { | ||
| 763 | terminal.focus(); | ||
| 764 | }); | ||
| 765 | } | ||
| 766 | previousSessionRef.current = current; | ||
| 767 | }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); | ||
| 768 | |||
| 794 | useEffect(() => { | 769 | useEffect(() => { |
| 795 | if (!autoFocus) return; | 770 | if (!autoFocus) return; |
| 796 | const terminal = terminalRef.current; | 771 | const terminal = terminalRef.current; |
| ... | @@ -804,24 +779,16 @@ export function TerminalViewport({ | ... | @@ -804,24 +779,16 @@ export function TerminalViewport({ |
| 804 | }, [autoFocus, focusRequestId]); | 779 | }, [autoFocus, focusRequestId]); |
| 805 | 780 | ||
| 806 | useEffect(() => { | 781 | useEffect(() => { |
| 807 | const api = readEnvironmentApi(environmentId); | ||
| 808 | const terminal = terminalRef.current; | 782 | const terminal = terminalRef.current; |
| 809 | const fitAddon = fitAddonRef.current; | 783 | const fitAddon = fitAddonRef.current; |
| 810 | if (!api || !terminal || !fitAddon) return; | 784 | if (!terminal || !fitAddon) return; |
| 811 | const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; | 785 | const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; |
| 812 | const frame = window.requestAnimationFrame(() => { | 786 | const frame = window.requestAnimationFrame(() => { |
| 813 | fitTerminalSafely(fitAddon); | 787 | fitTerminalSafely(fitAddon); |
| 814 | if (wasAtBottom) { | 788 | if (wasAtBottom) { |
| 815 | terminal.scrollToBottom(); | 789 | terminal.scrollToBottom(); |
| 816 | } | 790 | } |
| 817 | void api.terminal | 791 | void resizeTerminal(terminal.cols, terminal.rows); |
| 818 | .resize({ | ||
| 819 | threadId, | ||
| 820 | terminalId, | ||
| 821 | cols: terminal.cols, | ||
| 822 | rows: terminal.rows, | ||
| 823 | }) | ||
| 824 | .catch(() => undefined); | ||
| 825 | }); | 792 | }); |
| 826 | return () => { | 793 | return () => { |
| 827 | window.cancelAnimationFrame(frame); | 794 | window.cancelAnimationFrame(frame); |
| ... | @@ -926,10 +893,32 @@ export default function ThreadTerminalDrawer({ | ... | @@ -926,10 +893,32 @@ export default function ThreadTerminalDrawer({ |
| 926 | terminalLaunchLocationsById, | 893 | terminalLaunchLocationsById, |
| 927 | }: ThreadTerminalDrawerProps) { | 894 | }: ThreadTerminalDrawerProps) { |
| 928 | const isPanel = mode === "panel"; | 895 | const isPanel = mode === "panel"; |
| 929 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); | 896 | const controlledDrawerHeight = clampDrawerHeight(height); |
| 897 | const [drawerHeightState, setDrawerHeightState] = useState(() => ({ | ||
| 898 | threadId, | ||
| 899 | height: controlledDrawerHeight, | ||
| 900 | })); | ||
| 901 | const drawerHeight = | ||
| 902 | drawerHeightState.threadId === threadId ? drawerHeightState.height : controlledDrawerHeight; | ||
| 903 | const setDrawerHeight = useCallback( | ||
| 904 | (update: SetStateAction<number>) => { | ||
| 905 | setDrawerHeightState((current) => { | ||
| 906 | const currentHeight = | ||
| 907 | current.threadId === threadId ? current.height : controlledDrawerHeight; | ||
| 908 | const nextHeight = typeof update === "function" ? update(currentHeight) : update; | ||
| 909 | return nextHeight === currentHeight && current.threadId === threadId | ||
| 910 | ? current | ||
| 911 | : { threadId, height: nextHeight }; | ||
| 912 | }); | ||
| 913 | }, | ||
| 914 | [controlledDrawerHeight, threadId], | ||
| 915 | ); | ||
| 916 | const setDrawerHeightFromWindowResize = useEffectEvent((nextHeight: number) => { | ||
| 917 | setDrawerHeight(nextHeight); | ||
| 918 | }); | ||
| 930 | const [resizeEpoch, setResizeEpoch] = useState(0); | 919 | const [resizeEpoch, setResizeEpoch] = useState(0); |
| 931 | const drawerHeightRef = useRef(drawerHeight); | 920 | const drawerHeightRef = useRef(drawerHeight); |
| 932 | const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); | 921 | const lastSyncedHeightRef = useRef(controlledDrawerHeight); |
| 933 | const onHeightChangeRef = useRef(onHeightChange); | 922 | const onHeightChangeRef = useRef(onHeightChange); |
| 934 | const resizeStateRef = useRef<{ | 923 | const resizeStateRef = useRef<{ |
| 935 | pointerId: number; | 924 | pointerId: number; |
| ... | @@ -1060,17 +1049,6 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1060,17 +1049,6 @@ export default function ThreadTerminalDrawer({ |
| 1060 | } | 1049 | } |
| 1061 | return next; | 1050 | return next; |
| 1062 | }, [normalizedTerminalIds, terminalLabelsById]); | 1051 | }, [normalizedTerminalIds, terminalLabelsById]); |
| 1063 | const discoveredPorts = useDiscoveredPorts(threadRef.environmentId); | ||
| 1064 | const discoveredPortByTerminalId = useMemo(() => { | ||
| 1065 | const next = new Map<string, (typeof discoveredPorts)[number]>(); | ||
| 1066 | for (const port of discoveredPorts) { | ||
| 1067 | if (port.terminal?.threadId !== threadId) continue; | ||
| 1068 | if (!next.has(port.terminal.terminalId)) { | ||
| 1069 | next.set(port.terminal.terminalId, port); | ||
| 1070 | } | ||
| 1071 | } | ||
| 1072 | return next; | ||
| 1073 | }, [discoveredPorts, threadId]); | ||
| 1074 | const resolveTerminalLaunchLocation = useCallback( | 1052 | const resolveTerminalLaunchLocation = useCallback( |
| 1075 | (terminalId: string): TerminalLaunchLocation => { | 1053 | (terminalId: string): TerminalLaunchLocation => { |
| 1076 | return ( | 1054 | return ( |
| ... | @@ -1127,11 +1105,8 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1127,11 +1105,8 @@ export default function ThreadTerminalDrawer({ |
| 1127 | }, []); | 1105 | }, []); |
| 1128 | 1106 | ||
| 1129 | useEffect(() => { | 1107 | useEffect(() => { |
| 1130 | const clampedHeight = clampDrawerHeight(height); | 1108 | lastSyncedHeightRef.current = controlledDrawerHeight; |
| 1131 | setDrawerHeight(clampedHeight); | 1109 | }, [controlledDrawerHeight, threadId]); |
| 1132 | drawerHeightRef.current = clampedHeight; | ||
| 1133 | lastSyncedHeightRef.current = clampedHeight; | ||
| 1134 | }, [height, threadId]); | ||
| 1135 | 1110 | ||
| 1136 | const handleResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { | 1111 | const handleResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { |
| 1137 | if (event.button !== 0) return; | 1112 | if (event.button !== 0) return; |
| ... | @@ -1145,20 +1120,23 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1145,20 +1120,23 @@ export default function ThreadTerminalDrawer({ |
| 1145 | }; | 1120 | }; |
| 1146 | }, []); | 1121 | }, []); |
| 1147 | 1122 | ||
| 1148 | const handleResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { | 1123 | const handleResizePointerMove = useCallback( |
| 1149 | const resizeState = resizeStateRef.current; | 1124 | (event: ReactPointerEvent<HTMLDivElement>) => { |
| 1150 | if (!resizeState || resizeState.pointerId !== event.pointerId) return; | 1125 | const resizeState = resizeStateRef.current; |
| 1151 | event.preventDefault(); | 1126 | if (!resizeState || resizeState.pointerId !== event.pointerId) return; |
| 1152 | const clampedHeight = clampDrawerHeight( | 1127 | event.preventDefault(); |
| 1153 | resizeState.startHeight + (resizeState.startY - event.clientY), | 1128 | const clampedHeight = clampDrawerHeight( |
| 1154 | ); | 1129 | resizeState.startHeight + (resizeState.startY - event.clientY), |
| 1155 | if (clampedHeight === drawerHeightRef.current) { | 1130 | ); |
| 1156 | return; | 1131 | if (clampedHeight === drawerHeightRef.current) { |
| 1157 | } | 1132 | return; |
| 1158 | didResizeDuringDragRef.current = true; | 1133 | } |
| 1159 | drawerHeightRef.current = clampedHeight; | 1134 | didResizeDuringDragRef.current = true; |
| 1160 | setDrawerHeight(clampedHeight); | 1135 | drawerHeightRef.current = clampedHeight; |
| 1161 | }, []); | 1136 | setDrawerHeight(clampedHeight); |
| 1137 | }, | ||
| 1138 | [setDrawerHeight], | ||
| 1139 | ); | ||
| 1162 | 1140 | ||
| 1163 | const handleResizePointerEnd = useCallback( | 1141 | const handleResizePointerEnd = useCallback( |
| 1164 | (event: ReactPointerEvent<HTMLDivElement>) => { | 1142 | (event: ReactPointerEvent<HTMLDivElement>) => { |
| ... | @@ -1186,7 +1164,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1186,7 +1164,7 @@ export default function ThreadTerminalDrawer({ |
| 1186 | const clampedHeight = clampDrawerHeight(drawerHeightRef.current); | 1164 | const clampedHeight = clampDrawerHeight(drawerHeightRef.current); |
| 1187 | const changed = clampedHeight !== drawerHeightRef.current; | 1165 | const changed = clampedHeight !== drawerHeightRef.current; |
| 1188 | if (changed) { | 1166 | if (changed) { |
| 1189 | setDrawerHeight(clampedHeight); | 1167 | setDrawerHeightFromWindowResize(clampedHeight); |
| 1190 | drawerHeightRef.current = clampedHeight; | 1168 | drawerHeightRef.current = clampedHeight; |
| 1191 | } | 1169 | } |
| 1192 | if (!resizeStateRef.current) { | 1170 | if (!resizeStateRef.current) { |
| ... | @@ -1474,7 +1452,6 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1474,7 +1452,6 @@ export default function ThreadTerminalDrawer({ |
| 1474 | > | 1452 | > |
| 1475 | {terminalGroup.terminalIds.map((terminalId) => { | 1453 | {terminalGroup.terminalIds.map((terminalId) => { |
| 1476 | const isActive = terminalId === resolvedActiveTerminalId; | 1454 | const isActive = terminalId === resolvedActiveTerminalId; |
| 1477 | const discoveredPort = discoveredPortByTerminalId.get(terminalId); | ||
| 1478 | const closeTerminalLabel = `Close ${ | 1455 | const closeTerminalLabel = `Close ${ |
| 1479 | terminalLabelById.get(terminalId) ?? "terminal" | 1456 | terminalLabelById.get(terminalId) ?? "terminal" |
| 1480 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; | 1457 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; |
| ... | @@ -1500,37 +1477,6 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1500,37 +1477,6 @@ export default function ThreadTerminalDrawer({ |
| 1500 | {terminalLabelById.get(terminalId) ?? "Terminal"} | 1477 | {terminalLabelById.get(terminalId) ?? "Terminal"} |
| 1501 | </span> | 1478 | </span> |
| 1502 | </button> | 1479 | </button> |
| 1503 | {discoveredPort && ( | ||
| 1504 | <Popover> | ||
| 1505 | <PopoverTrigger | ||
| 1506 | openOnHover | ||
| 1507 | render={ | ||
| 1508 | <button | ||
| 1509 | type="button" | ||
| 1510 | className="inline-flex size-4 items-center justify-center rounded text-emerald-600 transition hover:bg-accent dark:text-emerald-400" | ||
| 1511 | onClick={() => | ||
| 1512 | void openDiscoveredPort({ | ||
| 1513 | threadRef, | ||
| 1514 | port: discoveredPort, | ||
| 1515 | }) | ||
| 1516 | } | ||
| 1517 | aria-label={`Open localhost:${discoveredPort.port}`} | ||
| 1518 | /> | ||
| 1519 | } | ||
| 1520 | > | ||
| 1521 | <Globe2 className="size-3" /> | ||
| 1522 | </PopoverTrigger> | ||
| 1523 | <PopoverPopup | ||
| 1524 | tooltipStyle | ||
| 1525 | side="bottom" | ||
| 1526 | sideOffset={6} | ||
| 1527 | align="center" | ||
| 1528 | className="pointer-events-none select-none" | ||
| 1529 | > | ||
| 1530 | Open localhost:{discoveredPort.port} | ||
| 1531 | </PopoverPopup> | ||
| 1532 | </Popover> | ||
| 1533 | )} | ||
| 1534 | {normalizedTerminalIds.length > 1 && ( | 1480 | {normalizedTerminalIds.length > 1 && ( |
| 1535 | <Popover> | 1481 | <Popover> |
| 1536 | <PopoverTrigger | 1482 | <PopoverTrigger |
apps/web/src/components/WebSocketConnectionSurface.logic.test.ts deleted-114| ... | @@ -1,114 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import type { WsConnectionStatus } from "../rpc/wsConnectionState"; | ||
| 4 | import { shouldAutoReconnect, shouldRestartStalledReconnect } from "./WebSocketConnectionSurface"; | ||
| 5 | |||
| 6 | function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionStatus { | ||
| 7 | return { | ||
| 8 | attemptCount: 0, | ||
| 9 | closeCode: null, | ||
| 10 | closeReason: null, | ||
| 11 | connectionLabel: null, | ||
| 12 | connectedAt: null, | ||
| 13 | disconnectedAt: null, | ||
| 14 | hasConnected: false, | ||
| 15 | lastError: null, | ||
| 16 | lastErrorAt: null, | ||
| 17 | nextRetryAt: null, | ||
| 18 | online: true, | ||
| 19 | phase: "idle", | ||
| 20 | reconnectAttemptCount: 0, | ||
| 21 | reconnectMaxAttempts: 8, | ||
| 22 | reconnectPhase: "idle", | ||
| 23 | socketUrl: null, | ||
| 24 | ...overrides, | ||
| 25 | }; | ||
| 26 | } | ||
| 27 | |||
| 28 | describe("WebSocketConnectionSurface.logic", () => { | ||
| 29 | it("forces reconnect on online when the app was offline", () => { | ||
| 30 | expect( | ||
| 31 | shouldAutoReconnect( | ||
| 32 | makeStatus({ | ||
| 33 | disconnectedAt: "2026-04-03T20:00:00.000Z", | ||
| 34 | online: false, | ||
| 35 | phase: "disconnected", | ||
| 36 | }), | ||
| 37 | "online", | ||
| 38 | ), | ||
| 39 | ).toBe(true); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("forces reconnect on focus only for previously connected disconnected states", () => { | ||
| 43 | expect( | ||
| 44 | shouldAutoReconnect( | ||
| 45 | makeStatus({ | ||
| 46 | hasConnected: true, | ||
| 47 | online: true, | ||
| 48 | phase: "disconnected", | ||
| 49 | reconnectAttemptCount: 3, | ||
| 50 | reconnectPhase: "waiting", | ||
| 51 | }), | ||
| 52 | "focus", | ||
| 53 | ), | ||
| 54 | ).toBe(true); | ||
| 55 | |||
| 56 | expect( | ||
| 57 | shouldAutoReconnect( | ||
| 58 | makeStatus({ | ||
| 59 | hasConnected: false, | ||
| 60 | online: true, | ||
| 61 | phase: "disconnected", | ||
| 62 | reconnectAttemptCount: 1, | ||
| 63 | reconnectPhase: "waiting", | ||
| 64 | }), | ||
| 65 | "focus", | ||
| 66 | ), | ||
| 67 | ).toBe(false); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("forces reconnect on focus for exhausted reconnect loops", () => { | ||
| 71 | expect( | ||
| 72 | shouldAutoReconnect( | ||
| 73 | makeStatus({ | ||
| 74 | hasConnected: true, | ||
| 75 | online: true, | ||
| 76 | phase: "disconnected", | ||
| 77 | reconnectAttemptCount: 8, | ||
| 78 | reconnectPhase: "exhausted", | ||
| 79 | }), | ||
| 80 | "focus", | ||
| 81 | ), | ||
| 82 | ).toBe(true); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("restarts a stalled reconnect window after the scheduled retry time passes", () => { | ||
| 86 | expect( | ||
| 87 | shouldRestartStalledReconnect( | ||
| 88 | makeStatus({ | ||
| 89 | hasConnected: true, | ||
| 90 | nextRetryAt: "2026-04-03T20:00:01.000Z", | ||
| 91 | online: true, | ||
| 92 | phase: "disconnected", | ||
| 93 | reconnectAttemptCount: 3, | ||
| 94 | reconnectPhase: "waiting", | ||
| 95 | }), | ||
| 96 | "2026-04-03T20:00:01.000Z", | ||
| 97 | ), | ||
| 98 | ).toBe(true); | ||
| 99 | |||
| 100 | expect( | ||
| 101 | shouldRestartStalledReconnect( | ||
| 102 | makeStatus({ | ||
| 103 | hasConnected: true, | ||
| 104 | nextRetryAt: "2026-04-03T20:00:01.000Z", | ||
| 105 | online: true, | ||
| 106 | phase: "disconnected", | ||
| 107 | reconnectAttemptCount: 3, | ||
| 108 | reconnectPhase: "attempting", | ||
| 109 | }), | ||
| 110 | "2026-04-03T20:00:01.000Z", | ||
| 111 | ), | ||
| 112 | ).toBe(false); | ||
| 113 | }); | ||
| 114 | }); | ||
apps/web/src/components/WebSocketConnectionSurface.tsx deleted-427| ... | @@ -1,427 +0,0 @@ | ||
| 1 | import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; | ||
| 2 | |||
| 3 | import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; | ||
| 4 | import { | ||
| 5 | getWsConnectionStatus, | ||
| 6 | getWsConnectionUiState, | ||
| 7 | setBrowserOnlineStatus, | ||
| 8 | type WsConnectionStatus, | ||
| 9 | type WsConnectionUiState, | ||
| 10 | useWsConnectionStatus, | ||
| 11 | WS_RECONNECT_MAX_ATTEMPTS, | ||
| 12 | } from "../rpc/wsConnectionState"; | ||
| 13 | import { stackedThreadToast, toastManager } from "./ui/toast"; | ||
| 14 | import { getPrimaryEnvironmentConnection } from "../environments/runtime"; | ||
| 15 | |||
| 16 | const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; | ||
| 17 | type WsAutoReconnectTrigger = "focus" | "online"; | ||
| 18 | |||
| 19 | const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, { | ||
| 20 | day: "numeric", | ||
| 21 | hour: "numeric", | ||
| 22 | minute: "2-digit", | ||
| 23 | month: "short", | ||
| 24 | second: "2-digit", | ||
| 25 | }); | ||
| 26 | |||
| 27 | function formatConnectionMoment(isoDate: string | null): string | null { | ||
| 28 | if (!isoDate) { | ||
| 29 | return null; | ||
| 30 | } | ||
| 31 | |||
| 32 | return connectionTimeFormatter.format(new Date(isoDate)); | ||
| 33 | } | ||
| 34 | |||
| 35 | function formatRetryCountdown(nextRetryAt: string, nowMs: number): string { | ||
| 36 | const remainingMs = Math.max(0, new Date(nextRetryAt).getTime() - nowMs); | ||
| 37 | return `${Math.max(1, Math.ceil(remainingMs / 1000))}s`; | ||
| 38 | } | ||
| 39 | |||
| 40 | function describeOfflineToast(): string { | ||
| 41 | return "WebSocket disconnected. Waiting for network."; | ||
| 42 | } | ||
| 43 | |||
| 44 | function formatReconnectAttemptLabel(status: WsConnectionStatus): string { | ||
| 45 | const reconnectAttempt = Math.max( | ||
| 46 | 1, | ||
| 47 | Math.min(status.reconnectAttemptCount, WS_RECONNECT_MAX_ATTEMPTS), | ||
| 48 | ); | ||
| 49 | return `Attempt ${reconnectAttempt}/${status.reconnectMaxAttempts}`; | ||
| 50 | } | ||
| 51 | |||
| 52 | function describeExhaustedToast(): string { | ||
| 53 | return "Retries exhausted trying to reconnect"; | ||
| 54 | } | ||
| 55 | |||
| 56 | function getConnectionDisplayName(status: WsConnectionStatus): string { | ||
| 57 | return status.connectionLabel?.trim() || "T3 Server"; | ||
| 58 | } | ||
| 59 | |||
| 60 | function buildReconnectTitle(status: WsConnectionStatus): string { | ||
| 61 | return `Disconnected from ${getConnectionDisplayName(status)}`; | ||
| 62 | } | ||
| 63 | |||
| 64 | function buildRecoveredTitle(status: WsConnectionStatus): string { | ||
| 65 | return `Reconnected to ${getConnectionDisplayName(status)}`; | ||
| 66 | } | ||
| 67 | |||
| 68 | function describeRecoveredToast( | ||
| 69 | previousDisconnectedAt: string | null, | ||
| 70 | connectedAt: string | null, | ||
| 71 | ): string { | ||
| 72 | const reconnectedAtLabel = formatConnectionMoment(connectedAt); | ||
| 73 | const disconnectedAtLabel = formatConnectionMoment(previousDisconnectedAt); | ||
| 74 | |||
| 75 | if (disconnectedAtLabel && reconnectedAtLabel) { | ||
| 76 | return `Disconnected at ${disconnectedAtLabel} and reconnected at ${reconnectedAtLabel}.`; | ||
| 77 | } | ||
| 78 | |||
| 79 | if (reconnectedAtLabel) { | ||
| 80 | return `Connection restored at ${reconnectedAtLabel}.`; | ||
| 81 | } | ||
| 82 | |||
| 83 | return "Connection restored."; | ||
| 84 | } | ||
| 85 | |||
| 86 | function describeSlowRpcAckToast(requests: ReadonlyArray<SlowRpcAckRequest>): string { | ||
| 87 | const count = requests.length; | ||
| 88 | const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); | ||
| 89 | |||
| 90 | return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; | ||
| 91 | } | ||
| 92 | |||
| 93 | function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray<SlowRpcAckRequest> }) { | ||
| 94 | return ( | ||
| 95 | <ul className="space-y-2.5 text-xs text-muted-foreground"> | ||
| 96 | {requests.map((req) => ( | ||
| 97 | <li | ||
| 98 | className="min-w-0 border-border/50 border-b pb-2 last:border-b-0 last:pb-0" | ||
| 99 | key={req.requestId} | ||
| 100 | > | ||
| 101 | <div className="wrap-break-word font-medium text-foreground">{req.tag}</div> | ||
| 102 | <div className="mt-0.5 font-mono text-[10px] leading-snug opacity-90"> | ||
| 103 | {req.requestId} | ||
| 104 | </div> | ||
| 105 | <div className="mt-0.5 text-[10px] opacity-75"> | ||
| 106 | Started {formatConnectionMoment(req.startedAt) ?? req.startedAt} | ||
| 107 | </div> | ||
| 108 | </li> | ||
| 109 | ))} | ||
| 110 | </ul> | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | export function shouldAutoReconnect( | ||
| 115 | status: WsConnectionStatus, | ||
| 116 | trigger: WsAutoReconnectTrigger, | ||
| 117 | ): boolean { | ||
| 118 | const uiState = getWsConnectionUiState(status); | ||
| 119 | |||
| 120 | if (trigger === "online") { | ||
| 121 | return ( | ||
| 122 | uiState === "offline" || | ||
| 123 | uiState === "reconnecting" || | ||
| 124 | uiState === "error" || | ||
| 125 | status.reconnectPhase === "exhausted" | ||
| 126 | ); | ||
| 127 | } | ||
| 128 | |||
| 129 | return ( | ||
| 130 | status.online && | ||
| 131 | status.hasConnected && | ||
| 132 | (uiState === "reconnecting" || status.reconnectPhase === "exhausted") | ||
| 133 | ); | ||
| 134 | } | ||
| 135 | |||
| 136 | export function shouldRestartStalledReconnect( | ||
| 137 | status: WsConnectionStatus, | ||
| 138 | expectedNextRetryAt: string, | ||
| 139 | ): boolean { | ||
| 140 | return ( | ||
| 141 | status.reconnectPhase === "waiting" && | ||
| 142 | status.nextRetryAt === expectedNextRetryAt && | ||
| 143 | status.online && | ||
| 144 | status.hasConnected | ||
| 145 | ); | ||
| 146 | } | ||
| 147 | |||
| 148 | export function WebSocketConnectionCoordinator() { | ||
| 149 | const status = useWsConnectionStatus(); | ||
| 150 | const [nowMs, setNowMs] = useState(() => Date.now()); | ||
| 151 | const lastForcedReconnectAtRef = useRef(0); | ||
| 152 | const toastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null); | ||
| 153 | const toastResetTimerRef = useRef<number | null>(null); | ||
| 154 | const previousUiStateRef = useRef<WsConnectionUiState>(getWsConnectionUiState(status)); | ||
| 155 | const previousDisconnectedAtRef = useRef<string | null>(status.disconnectedAt); | ||
| 156 | |||
| 157 | const runReconnect = useEffectEvent((showFailureToast: boolean) => { | ||
| 158 | if (toastResetTimerRef.current !== null) { | ||
| 159 | window.clearTimeout(toastResetTimerRef.current); | ||
| 160 | toastResetTimerRef.current = null; | ||
| 161 | } | ||
| 162 | lastForcedReconnectAtRef.current = Date.now(); | ||
| 163 | void getPrimaryEnvironmentConnection() | ||
| 164 | .reconnect() | ||
| 165 | .catch((error) => { | ||
| 166 | if (!showFailureToast) { | ||
| 167 | console.warn("Automatic WebSocket reconnect failed", { error }); | ||
| 168 | return; | ||
| 169 | } | ||
| 170 | toastManager.add( | ||
| 171 | stackedThreadToast({ | ||
| 172 | type: "error", | ||
| 173 | title: "Reconnect failed", | ||
| 174 | description: | ||
| 175 | error instanceof Error ? error.message : "Unable to restart the WebSocket.", | ||
| 176 | data: { | ||
| 177 | dismissAfterVisibleMs: 8_000, | ||
| 178 | hideCopyButton: true, | ||
| 179 | }, | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | }); | ||
| 183 | }); | ||
| 184 | const syncBrowserOnlineStatus = useEffectEvent(() => { | ||
| 185 | setBrowserOnlineStatus(navigator.onLine !== false); | ||
| 186 | }); | ||
| 187 | const triggerManualReconnect = useEffectEvent(() => { | ||
| 188 | runReconnect(true); | ||
| 189 | }); | ||
| 190 | const triggerAutoReconnect = useEffectEvent((trigger: WsAutoReconnectTrigger) => { | ||
| 191 | const currentStatus = | ||
| 192 | trigger === "online" ? setBrowserOnlineStatus(true) : getWsConnectionStatus(); | ||
| 193 | |||
| 194 | if (!shouldAutoReconnect(currentStatus, trigger)) { | ||
| 195 | return; | ||
| 196 | } | ||
| 197 | if (Date.now() - lastForcedReconnectAtRef.current < FORCED_WS_RECONNECT_DEBOUNCE_MS) { | ||
| 198 | return; | ||
| 199 | } | ||
| 200 | |||
| 201 | runReconnect(false); | ||
| 202 | }); | ||
| 203 | |||
| 204 | useEffect(() => { | ||
| 205 | const handleOnline = () => { | ||
| 206 | triggerAutoReconnect("online"); | ||
| 207 | }; | ||
| 208 | const handleFocus = () => { | ||
| 209 | triggerAutoReconnect("focus"); | ||
| 210 | }; | ||
| 211 | |||
| 212 | syncBrowserOnlineStatus(); | ||
| 213 | window.addEventListener("online", handleOnline); | ||
| 214 | window.addEventListener("offline", syncBrowserOnlineStatus); | ||
| 215 | window.addEventListener("focus", handleFocus); | ||
| 216 | return () => { | ||
| 217 | window.removeEventListener("online", handleOnline); | ||
| 218 | window.removeEventListener("offline", syncBrowserOnlineStatus); | ||
| 219 | window.removeEventListener("focus", handleFocus); | ||
| 220 | }; | ||
| 221 | }, []); | ||
| 222 | |||
| 223 | useEffect(() => { | ||
| 224 | if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) { | ||
| 225 | return; | ||
| 226 | } | ||
| 227 | |||
| 228 | setNowMs(Date.now()); | ||
| 229 | const intervalId = window.setInterval(() => { | ||
| 230 | setNowMs(Date.now()); | ||
| 231 | }, 1_000); | ||
| 232 | |||
| 233 | return () => { | ||
| 234 | window.clearInterval(intervalId); | ||
| 235 | }; | ||
| 236 | }, [status.nextRetryAt, status.reconnectPhase]); | ||
| 237 | |||
| 238 | useEffect(() => { | ||
| 239 | if ( | ||
| 240 | status.reconnectPhase !== "waiting" || | ||
| 241 | status.nextRetryAt === null || | ||
| 242 | !status.online || | ||
| 243 | !status.hasConnected | ||
| 244 | ) { | ||
| 245 | return; | ||
| 246 | } | ||
| 247 | |||
| 248 | const nextRetryAt = status.nextRetryAt; | ||
| 249 | const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500; | ||
| 250 | const timeoutId = window.setTimeout(() => { | ||
| 251 | const currentStatus = getWsConnectionStatus(); | ||
| 252 | if (!shouldRestartStalledReconnect(currentStatus, nextRetryAt)) { | ||
| 253 | return; | ||
| 254 | } | ||
| 255 | |||
| 256 | runReconnect(false); | ||
| 257 | }, timeoutMs); | ||
| 258 | |||
| 259 | return () => { | ||
| 260 | window.clearTimeout(timeoutId); | ||
| 261 | }; | ||
| 262 | }, [ | ||
| 263 | status.hasConnected, | ||
| 264 | status.nextRetryAt, | ||
| 265 | status.online, | ||
| 266 | status.reconnectAttemptCount, | ||
| 267 | status.reconnectPhase, | ||
| 268 | ]); | ||
| 269 | |||
| 270 | useEffect(() => { | ||
| 271 | const uiState = getWsConnectionUiState(status); | ||
| 272 | const previousUiState = previousUiStateRef.current; | ||
| 273 | const previousDisconnectedAt = previousDisconnectedAtRef.current; | ||
| 274 | const shouldShowReconnectToast = status.hasConnected && uiState === "reconnecting"; | ||
| 275 | const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null; | ||
| 276 | const shouldShowExhaustedToast = status.hasConnected && status.reconnectPhase === "exhausted"; | ||
| 277 | |||
| 278 | if ( | ||
| 279 | toastResetTimerRef.current !== null && | ||
| 280 | (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) | ||
| 281 | ) { | ||
| 282 | window.clearTimeout(toastResetTimerRef.current); | ||
| 283 | toastResetTimerRef.current = null; | ||
| 284 | } | ||
| 285 | |||
| 286 | if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { | ||
| 287 | const toastPayload = shouldShowOfflineToast | ||
| 288 | ? stackedThreadToast({ | ||
| 289 | data: { | ||
| 290 | hideCopyButton: true, | ||
| 291 | }, | ||
| 292 | description: describeOfflineToast(), | ||
| 293 | timeout: 0, | ||
| 294 | title: "Offline", | ||
| 295 | type: "warning", | ||
| 296 | }) | ||
| 297 | : shouldShowExhaustedToast | ||
| 298 | ? stackedThreadToast({ | ||
| 299 | actionProps: { | ||
| 300 | children: "Retry", | ||
| 301 | onClick: triggerManualReconnect, | ||
| 302 | }, | ||
| 303 | data: { | ||
| 304 | hideCopyButton: true, | ||
| 305 | }, | ||
| 306 | description: describeExhaustedToast(), | ||
| 307 | timeout: 0, | ||
| 308 | title: buildReconnectTitle(status), | ||
| 309 | type: "error", | ||
| 310 | }) | ||
| 311 | : stackedThreadToast({ | ||
| 312 | actionProps: { | ||
| 313 | children: "Retry now", | ||
| 314 | onClick: triggerManualReconnect, | ||
| 315 | }, | ||
| 316 | data: { | ||
| 317 | hideCopyButton: true, | ||
| 318 | }, | ||
| 319 | description: | ||
| 320 | status.nextRetryAt === null | ||
| 321 | ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` | ||
| 322 | : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, | ||
| 323 | timeout: 0, | ||
| 324 | title: buildReconnectTitle(status), | ||
| 325 | type: "loading", | ||
| 326 | }); | ||
| 327 | |||
| 328 | if (toastIdRef.current) { | ||
| 329 | toastManager.update(toastIdRef.current, toastPayload); | ||
| 330 | } else { | ||
| 331 | toastIdRef.current = toastManager.add(toastPayload); | ||
| 332 | } | ||
| 333 | } else if (toastIdRef.current) { | ||
| 334 | toastManager.close(toastIdRef.current); | ||
| 335 | toastIdRef.current = null; | ||
| 336 | } | ||
| 337 | |||
| 338 | if ( | ||
| 339 | uiState === "connected" && | ||
| 340 | (previousUiState === "offline" || previousUiState === "reconnecting") && | ||
| 341 | previousDisconnectedAt !== null | ||
| 342 | ) { | ||
| 343 | const successToast = { | ||
| 344 | description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), | ||
| 345 | title: buildRecoveredTitle(status), | ||
| 346 | type: "success" as const, | ||
| 347 | timeout: 0, | ||
| 348 | data: { | ||
| 349 | dismissAfterVisibleMs: 8_000, | ||
| 350 | hideCopyButton: true, | ||
| 351 | }, | ||
| 352 | }; | ||
| 353 | |||
| 354 | if (toastIdRef.current) { | ||
| 355 | toastManager.update(toastIdRef.current, successToast); | ||
| 356 | } else { | ||
| 357 | toastIdRef.current = toastManager.add(successToast); | ||
| 358 | } | ||
| 359 | |||
| 360 | toastResetTimerRef.current = window.setTimeout(() => { | ||
| 361 | toastIdRef.current = null; | ||
| 362 | toastResetTimerRef.current = null; | ||
| 363 | }, 8_250); | ||
| 364 | } | ||
| 365 | |||
| 366 | previousUiStateRef.current = uiState; | ||
| 367 | previousDisconnectedAtRef.current = status.disconnectedAt; | ||
| 368 | }, [nowMs, status]); | ||
| 369 | |||
| 370 | useEffect(() => { | ||
| 371 | return () => { | ||
| 372 | if (toastResetTimerRef.current !== null) { | ||
| 373 | window.clearTimeout(toastResetTimerRef.current); | ||
| 374 | } | ||
| 375 | }; | ||
| 376 | }, []); | ||
| 377 | |||
| 378 | return null; | ||
| 379 | } | ||
| 380 | |||
| 381 | export function SlowRpcAckToastCoordinator() { | ||
| 382 | const slowRequests = useSlowRpcAckRequests(); | ||
| 383 | const status = useWsConnectionStatus(); | ||
| 384 | const toastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null); | ||
| 385 | |||
| 386 | useEffect(() => { | ||
| 387 | if (getWsConnectionUiState(status) !== "connected") { | ||
| 388 | if (toastIdRef.current) { | ||
| 389 | toastManager.close(toastIdRef.current); | ||
| 390 | toastIdRef.current = null; | ||
| 391 | } | ||
| 392 | return; | ||
| 393 | } | ||
| 394 | |||
| 395 | if (slowRequests.length === 0) { | ||
| 396 | if (toastIdRef.current) { | ||
| 397 | toastManager.close(toastIdRef.current); | ||
| 398 | toastIdRef.current = null; | ||
| 399 | } | ||
| 400 | return; | ||
| 401 | } | ||
| 402 | |||
| 403 | const nextToast = { | ||
| 404 | data: { | ||
| 405 | expandableContent: <SlowRpcAckRequestDetails requests={slowRequests} />, | ||
| 406 | expandableDescriptionTrigger: true, | ||
| 407 | expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, | ||
| 408 | }, | ||
| 409 | description: describeSlowRpcAckToast(slowRequests), | ||
| 410 | timeout: 0, | ||
| 411 | title: "Some requests are slow", | ||
| 412 | type: "warning" as const, | ||
| 413 | }; | ||
| 414 | |||
| 415 | if (toastIdRef.current) { | ||
| 416 | toastManager.update(toastIdRef.current, nextToast); | ||
| 417 | } else { | ||
| 418 | toastIdRef.current = toastManager.add(nextToast); | ||
| 419 | } | ||
| 420 | }, [slowRequests, status]); | ||
| 421 | |||
| 422 | return null; | ||
| 423 | } | ||
| 424 | |||
| 425 | export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { | ||
| 426 | return children; | ||
| 427 | } | ||
apps/web/src/components/auth/PairingRouteSurface.tsx+21-16| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import type { AuthSessionState } from "@t3tools/contracts"; | 1 | import type { AuthSessionState } from "@t3tools/contracts"; |
| 2 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; | 3 | import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; |
| 3 | 4 | ||
| 4 | import { APP_DISPLAY_NAME } from "../../branding"; | 5 | import { APP_DISPLAY_NAME } from "../../branding"; |
| 5 | import { addSavedEnvironment } from "../../environments/runtime"; | 6 | import { connectPairing } from "../../connection/onboarding"; |
| 6 | import { | 7 | import { |
| 7 | peekPairingTokenFromUrl, | 8 | peekPairingTokenFromUrl, |
| 8 | stripPairingTokenFromUrl, | 9 | stripPairingTokenFromUrl, |
| ... | @@ -11,6 +12,7 @@ import { | ... | @@ -11,6 +12,7 @@ import { |
| 11 | import { readHostedPairingRequest } from "../../hostedPairing"; | 12 | import { readHostedPairingRequest } from "../../hostedPairing"; |
| 12 | import { Button } from "../ui/button"; | 13 | import { Button } from "../ui/button"; |
| 13 | import { Input } from "../ui/input"; | 14 | import { Input } from "../ui/input"; |
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 14 | 16 | ||
| 15 | export function PairingPendingSurface() { | 17 | export function PairingPendingSurface() { |
| 16 | return ( | 18 | return ( |
| ... | @@ -162,6 +164,9 @@ export function PairingRouteSurface({ | ... | @@ -162,6 +164,9 @@ export function PairingRouteSurface({ |
| 162 | } | 164 | } |
| 163 | 165 | ||
| 164 | export function HostedPairingRouteSurface() { | 166 | export function HostedPairingRouteSurface() { |
| 167 | const connectPairingEnvironment = useAtomCommand(connectPairing, { | ||
| 168 | reportFailure: false, | ||
| 169 | }); | ||
| 165 | const hostedPairingRequestRef = useRef(readHostedPairingRequest()); | 170 | const hostedPairingRequestRef = useRef(readHostedPairingRequest()); |
| 166 | const [status, setStatus] = useState<"pairing" | "paired" | "error">(() => | 171 | const [status, setStatus] = useState<"pairing" | "paired" | "error">(() => |
| 167 | hostedPairingRequestRef.current ? "pairing" : "error", | 172 | hostedPairingRequestRef.current ? "pairing" : "error", |
| ... | @@ -197,23 +202,23 @@ export function HostedPairingRouteSurface() { | ... | @@ -197,23 +202,23 @@ export function HostedPairingRouteSurface() { |
| 197 | setCanRetry(false); | 202 | setCanRetry(false); |
| 198 | tokenSubmittedRef.current = true; | 203 | tokenSubmittedRef.current = true; |
| 199 | 204 | ||
| 200 | try { | 205 | const result = await connectPairingEnvironment({ |
| 201 | const record = await addSavedEnvironment({ | 206 | host: request.host, |
| 202 | label: request.label, | 207 | pairingCode: request.token, |
| 203 | host: request.host, | 208 | }); |
| 204 | pairingCode: request.token, | 209 | if (result._tag === "Success") { |
| 205 | }); | ||
| 206 | setStatus("paired"); | 210 | setStatus("paired"); |
| 207 | setMessage(`${record.label} is saved in this browser.`); | 211 | setMessage(`${request.label || "The environment"} is saved in this browser.`); |
| 208 | } catch (error) { | 212 | return; |
| 209 | tokenSubmittedRef.current = false; | ||
| 210 | setStatus("error"); | ||
| 211 | setCanRetry(true); | ||
| 212 | setMessage( | ||
| 213 | `${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`, | ||
| 214 | ); | ||
| 215 | } | 213 | } |
| 216 | }, []); | 214 | |
| 215 | tokenSubmittedRef.current = false; | ||
| 216 | setStatus("error"); | ||
| 217 | setCanRetry(true); | ||
| 218 | setMessage( | ||
| 219 | `${errorMessageFromUnknown(squashAtomCommandFailure(result))} If the backend accepted this one-time token, request a new pairing link before retrying.`, | ||
| 220 | ); | ||
| 221 | }, [connectPairingEnvironment]); | ||
| 217 | 222 | ||
| 218 | useEffect(() => { | 223 | useEffect(() => { |
| 219 | if (submitAttemptedRef.current) { | 224 | if (submitAttemptedRef.current) { |
apps/web/src/components/chat/ChangedFilesTree.test.tsx+44-14| ... | @@ -9,8 +9,8 @@ describe("ChangedFilesTree", () => { | ... | @@ -9,8 +9,8 @@ describe("ChangedFilesTree", () => { |
| 9 | { | 9 | { |
| 10 | name: "a compacted single-chain directory", | 10 | name: "a compacted single-chain directory", |
| 11 | files: [ | 11 | files: [ |
| 12 | { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, | 12 | { path: "apps/web/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, |
| 13 | { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, | 13 | { path: "apps/web/src/main.ts", kind: "modified", additions: 3, deletions: 0 }, |
| 14 | ], | 14 | ], |
| 15 | visibleLabels: ["apps/web/src"], | 15 | visibleLabels: ["apps/web/src"], |
| 16 | hiddenLabels: ["index.ts", "main.ts"], | 16 | hiddenLabels: ["index.ts", "main.ts"], |
| ... | @@ -18,8 +18,18 @@ describe("ChangedFilesTree", () => { | ... | @@ -18,8 +18,18 @@ describe("ChangedFilesTree", () => { |
| 18 | { | 18 | { |
| 19 | name: "a branch point after a compacted prefix", | 19 | name: "a branch point after a compacted prefix", |
| 20 | files: [ | 20 | files: [ |
| 21 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, | 21 | { |
| 22 | { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, | 22 | path: "apps/server/src/git/Layers/GitCore.ts", |
| 23 | kind: "modified", | ||
| 24 | additions: 4, | ||
| 25 | deletions: 3, | ||
| 26 | }, | ||
| 27 | { | ||
| 28 | path: "apps/server/src/provider/Layers/CodexAdapter.ts", | ||
| 29 | kind: "modified", | ||
| 30 | additions: 7, | ||
| 31 | deletions: 2, | ||
| 32 | }, | ||
| 23 | ], | 33 | ], |
| 24 | visibleLabels: ["apps/server/src"], | 34 | visibleLabels: ["apps/server/src"], |
| 25 | hiddenLabels: ["git", "provider", "GitCore.ts", "CodexAdapter.ts"], | 35 | hiddenLabels: ["git", "provider", "GitCore.ts", "CodexAdapter.ts"], |
| ... | @@ -27,9 +37,14 @@ describe("ChangedFilesTree", () => { | ... | @@ -27,9 +37,14 @@ describe("ChangedFilesTree", () => { |
| 27 | { | 37 | { |
| 28 | name: "mixed root files and nested compacted directories", | 38 | name: "mixed root files and nested compacted directories", |
| 29 | files: [ | 39 | files: [ |
| 30 | { path: "README.md", additions: 1, deletions: 0 }, | 40 | { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, |
| 31 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | 41 | { path: "packages/shared/src/git.ts", kind: "modified", additions: 8, deletions: 2 }, |
| 32 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | 42 | { |
| 43 | path: "packages/contracts/src/orchestration.ts", | ||
| 44 | kind: "modified", | ||
| 45 | additions: 13, | ||
| 46 | deletions: 3, | ||
| 47 | }, | ||
| 33 | ], | 48 | ], |
| 34 | visibleLabels: ["README.md", "packages"], | 49 | visibleLabels: ["README.md", "packages"], |
| 35 | hiddenLabels: ["shared/src", "contracts/src", "git.ts", "orchestration.ts"], | 50 | hiddenLabels: ["shared/src", "contracts/src", "git.ts", "orchestration.ts"], |
| ... | @@ -60,16 +75,26 @@ describe("ChangedFilesTree", () => { | ... | @@ -60,16 +75,26 @@ describe("ChangedFilesTree", () => { |
| 60 | { | 75 | { |
| 61 | name: "a compacted single-chain directory", | 76 | name: "a compacted single-chain directory", |
| 62 | files: [ | 77 | files: [ |
| 63 | { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, | 78 | { path: "apps/web/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, |
| 64 | { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, | 79 | { path: "apps/web/src/main.ts", kind: "modified", additions: 3, deletions: 0 }, |
| 65 | ], | 80 | ], |
| 66 | visibleLabels: ["apps/web/src", "index.ts", "main.ts"], | 81 | visibleLabels: ["apps/web/src", "index.ts", "main.ts"], |
| 67 | }, | 82 | }, |
| 68 | { | 83 | { |
| 69 | name: "a branch point after a compacted prefix", | 84 | name: "a branch point after a compacted prefix", |
| 70 | files: [ | 85 | files: [ |
| 71 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, | 86 | { |
| 72 | { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, | 87 | path: "apps/server/src/git/Layers/GitCore.ts", |
| 88 | kind: "modified", | ||
| 89 | additions: 4, | ||
| 90 | deletions: 3, | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | path: "apps/server/src/provider/Layers/CodexAdapter.ts", | ||
| 94 | kind: "modified", | ||
| 95 | additions: 7, | ||
| 96 | deletions: 2, | ||
| 97 | }, | ||
| 73 | ], | 98 | ], |
| 74 | visibleLabels: [ | 99 | visibleLabels: [ |
| 75 | "apps/server/src", | 100 | "apps/server/src", |
| ... | @@ -82,9 +107,14 @@ describe("ChangedFilesTree", () => { | ... | @@ -82,9 +107,14 @@ describe("ChangedFilesTree", () => { |
| 82 | { | 107 | { |
| 83 | name: "mixed root files and nested compacted directories", | 108 | name: "mixed root files and nested compacted directories", |
| 84 | files: [ | 109 | files: [ |
| 85 | { path: "README.md", additions: 1, deletions: 0 }, | 110 | { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, |
| 86 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | 111 | { path: "packages/shared/src/git.ts", kind: "modified", additions: 8, deletions: 2 }, |
| 87 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | 112 | { |
| 113 | path: "packages/contracts/src/orchestration.ts", | ||
| 114 | kind: "modified", | ||
| 115 | additions: 13, | ||
| 116 | deletions: 3, | ||
| 117 | }, | ||
| 88 | ], | 118 | ], |
| 89 | visibleLabels: [ | 119 | visibleLabels: [ |
| 90 | "README.md", | 120 | "README.md", |
apps/web/src/components/chat/ChatComposer.tsx+9-7| ... | @@ -18,6 +18,10 @@ import { | ... | @@ -18,6 +18,10 @@ import { |
| 18 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 18 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 19 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 19 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 20 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | import { | ||
| 22 | connectionStatusText, | ||
| 23 | type EnvironmentConnectionPresentation, | ||
| 24 | } from "@t3tools/client-runtime/connection"; | ||
| 21 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | 25 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 22 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | 26 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 23 | import { | 27 | import { |
| ... | @@ -443,7 +447,7 @@ export interface ChatComposerProps { | ... | @@ -443,7 +447,7 @@ export interface ChatComposerProps { |
| 443 | isPreparingWorktree: boolean; | 447 | isPreparingWorktree: boolean; |
| 444 | environmentUnavailable: { | 448 | environmentUnavailable: { |
| 445 | readonly label: string; | 449 | readonly label: string; |
| 446 | readonly connectionState: "connecting" | "disconnected" | "error"; | 450 | readonly connection: EnvironmentConnectionPresentation; |
| 447 | } | null; | 451 | } | null; |
| 448 | 452 | ||
| 449 | // Pending approvals / inputs | 453 | // Pending approvals / inputs |
| ... | @@ -509,7 +513,7 @@ export interface ChatComposerProps { | ... | @@ -509,7 +513,7 @@ export interface ChatComposerProps { |
| 509 | onRespondToApproval: ( | 513 | onRespondToApproval: ( |
| 510 | requestId: ApprovalRequestId, | 514 | requestId: ApprovalRequestId, |
| 511 | decision: ProviderApprovalDecision, | 515 | decision: ProviderApprovalDecision, |
| 512 | ) => Promise<void>; | 516 | ) => Promise<unknown>; |
| 513 | onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; | 517 | onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; |
| 514 | onAdvanceActivePendingUserInput: () => void; | 518 | onAdvanceActivePendingUserInput: () => void; |
| 515 | onPreviousActivePendingUserInputQuestion: () => void; | 519 | onPreviousActivePendingUserInputQuestion: () => void; |
| ... | @@ -2419,11 +2423,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2419,11 +2423,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2419 | : showPlanFollowUpPrompt && activeProposedPlan | 2423 | : showPlanFollowUpPrompt && activeProposedPlan |
| 2420 | ? "Add feedback to refine the plan, or leave this blank to implement it" | 2424 | ? "Add feedback to refine the plan, or leave this blank to implement it" |
| 2421 | : environmentUnavailable | 2425 | : environmentUnavailable |
| 2422 | ? `${environmentUnavailable.label} is ${ | 2426 | ? `${environmentUnavailable.label}: ${connectionStatusText( |
| 2423 | environmentUnavailable.connectionState === "connecting" | 2427 | environmentUnavailable.connection, |
| 2424 | ? "connecting" | 2428 | )}` |
| 2425 | : "disconnected" | ||
| 2426 | }` | ||
| 2427 | : phase === "disconnected" | 2429 | : phase === "disconnected" |
| 2428 | ? "Ask for follow-up changes or attach images" | 2430 | ? "Ask for follow-up changes or attach images" |
| 2429 | : "Ask anything, @tag files/folders, $use skills, or / for commands" | 2431 | : "Ask anything, @tag files/folders, $use skills, or / for commands" |
apps/web/src/components/chat/ChatHeader.tsx+16-9| ... | @@ -5,15 +5,18 @@ import { | ... | @@ -5,15 +5,18 @@ import { |
| 5 | type ResolvedKeybindingsConfig, | 5 | type ResolvedKeybindingsConfig, |
| 6 | type ThreadId, | 6 | type ThreadId, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 8 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 9 | import { memo } from "react"; | 9 | import { memo } from "react"; |
| 10 | import GitActionsControl from "../GitActionsControl"; | 10 | import GitActionsControl from "../GitActionsControl"; |
| 11 | import { type DraftId } from "~/composerDraftStore"; | 11 | import { type DraftId } from "~/composerDraftStore"; |
| 12 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 12 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 13 | import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; | 13 | import ProjectScriptsControl, { |
| 14 | type NewProjectScriptInput, | ||
| 15 | type ProjectScriptActionResult, | ||
| 16 | } from "../ProjectScriptsControl"; | ||
| 14 | import { SidebarTrigger } from "../ui/sidebar"; | 17 | import { SidebarTrigger } from "../ui/sidebar"; |
| 15 | import { OpenInPicker } from "./OpenInPicker"; | 18 | import { OpenInPicker } from "./OpenInPicker"; |
| 16 | import { usePrimaryEnvironmentId } from "../../environments/primary/context"; | 19 | import { usePrimaryEnvironmentId } from "../../state/environments"; |
| 17 | import { cn } from "~/lib/utils"; | 20 | import { cn } from "~/lib/utils"; |
| 18 | 21 | ||
| 19 | interface ChatHeaderProps { | 22 | interface ChatHeaderProps { |
| ... | @@ -23,16 +26,19 @@ interface ChatHeaderProps { | ... | @@ -23,16 +26,19 @@ interface ChatHeaderProps { |
| 23 | activeThreadTitle: string; | 26 | activeThreadTitle: string; |
| 24 | activeProjectName: string | undefined; | 27 | activeProjectName: string | undefined; |
| 25 | openInCwd: string | null; | 28 | openInCwd: string | null; |
| 26 | activeProjectScripts: ProjectScript[] | undefined; | 29 | activeProjectScripts: ReadonlyArray<ProjectScript> | undefined; |
| 27 | preferredScriptId: string | null; | 30 | preferredScriptId: string | null; |
| 28 | keybindings: ResolvedKeybindingsConfig; | 31 | keybindings: ResolvedKeybindingsConfig; |
| 29 | availableEditors: ReadonlyArray<EditorId>; | 32 | availableEditors: ReadonlyArray<EditorId>; |
| 33 | rightPanelOpen: boolean; | ||
| 30 | gitCwd: string | null; | 34 | gitCwd: string | null; |
| 31 | onRunProjectScript: (script: ProjectScript) => void; | 35 | onRunProjectScript: (script: ProjectScript) => void; |
| 32 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | 36 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>; |
| 33 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | 37 | onUpdateProjectScript: ( |
| 34 | onDeleteProjectScript: (scriptId: string) => Promise<void>; | 38 | scriptId: string, |
| 35 | rightPanelOpen: boolean; | 39 | input: NewProjectScriptInput, |
| 40 | ) => Promise<ProjectScriptActionResult>; | ||
| 41 | onDeleteProjectScript: (scriptId: string) => Promise<ProjectScriptActionResult>; | ||
| 36 | } | 42 | } |
| 37 | 43 | ||
| 38 | export function shouldShowOpenInPicker(input: { | 44 | export function shouldShowOpenInPicker(input: { |
| ... | @@ -58,12 +64,12 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -58,12 +64,12 @@ export const ChatHeader = memo(function ChatHeader({ |
| 58 | preferredScriptId, | 64 | preferredScriptId, |
| 59 | keybindings, | 65 | keybindings, |
| 60 | availableEditors, | 66 | availableEditors, |
| 67 | rightPanelOpen, | ||
| 61 | gitCwd, | 68 | gitCwd, |
| 62 | onRunProjectScript, | 69 | onRunProjectScript, |
| 63 | onAddProjectScript, | 70 | onAddProjectScript, |
| 64 | onUpdateProjectScript, | 71 | onUpdateProjectScript, |
| 65 | onDeleteProjectScript, | 72 | onDeleteProjectScript, |
| 66 | rightPanelOpen, | ||
| 67 | }: ChatHeaderProps) { | 73 | }: ChatHeaderProps) { |
| 68 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 74 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 69 | const showOpenInPicker = shouldShowOpenInPicker({ | 75 | const showOpenInPicker = shouldShowOpenInPicker({ |
| ... | @@ -109,6 +115,7 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -109,6 +115,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 109 | )} | 115 | )} |
| 110 | {showOpenInPicker && ( | 116 | {showOpenInPicker && ( |
| 111 | <OpenInPicker | 117 | <OpenInPicker |
| 118 | environmentId={activeThreadEnvironmentId} | ||
| 112 | keybindings={keybindings} | 119 | keybindings={keybindings} |
| 113 | availableEditors={availableEditors} | 120 | availableEditors={availableEditors} |
| 114 | openInCwd={openInCwd} | 121 | openInCwd={openInCwd} |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx deleted-326| ... | @@ -1,326 +0,0 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_MODEL, | ||
| 3 | DEFAULT_MODEL_BY_PROVIDER, | ||
| 4 | EnvironmentId, | ||
| 5 | ModelSelection, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ProviderDriverKind, | ||
| 8 | ThreadId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 11 | import "../../index.css"; | ||
| 12 | |||
| 13 | import { page } from "vite-plus/test/browser"; | ||
| 14 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 15 | import { render } from "vitest-browser-react"; | ||
| 16 | import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; | ||
| 17 | |||
| 18 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; | ||
| 19 | import { TraitsMenuContent } from "./TraitsPicker"; | ||
| 20 | import { createEmptyThreadDraft, useComposerDraftStore } from "../../composerDraftStore"; | ||
| 21 | |||
| 22 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | ||
| 23 | |||
| 24 | function selectDescriptor( | ||
| 25 | id: string, | ||
| 26 | label: string, | ||
| 27 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | ||
| 28 | promptInjectedValues?: ReadonlyArray<string>, | ||
| 29 | ) { | ||
| 30 | return { | ||
| 31 | id, | ||
| 32 | label, | ||
| 33 | type: "select" as const, | ||
| 34 | options: [...options], | ||
| 35 | ...(options.find((option) => option.isDefault)?.id | ||
| 36 | ? { currentValue: options.find((option) => option.isDefault)?.id } | ||
| 37 | : {}), | ||
| 38 | ...(promptInjectedValues && promptInjectedValues.length > 0 | ||
| 39 | ? { promptInjectedValues: [...promptInjectedValues] } | ||
| 40 | : {}), | ||
| 41 | }; | ||
| 42 | } | ||
| 43 | |||
| 44 | function booleanDescriptor(id: string, label: string) { | ||
| 45 | return { | ||
| 46 | id, | ||
| 47 | label, | ||
| 48 | type: "boolean" as const, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { | ||
| 53 | const threadId = ThreadId.make("thread-compact-menu"); | ||
| 54 | const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); | ||
| 55 | const threadKey = scopedThreadKey(threadRef); | ||
| 56 | const provider = ProviderDriverKind.make("claudeAgent"); | ||
| 57 | const instanceId = ProviderInstanceId.make(props?.modelSelection?.instanceId ?? provider); | ||
| 58 | const model = | ||
| 59 | props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; | ||
| 60 | |||
| 61 | useComposerDraftStore.setState({ | ||
| 62 | draftsByThreadKey: { | ||
| 63 | // Compose from the canonical empty-draft factory so adding a new | ||
| 64 | // ComposerThreadDraftState slice (e.g. a future attachment kind) doesn't | ||
| 65 | // silently break this stub via `Property X is missing in type ...`. | ||
| 66 | [threadKey]: { | ||
| 67 | ...createEmptyThreadDraft(), | ||
| 68 | prompt: props?.prompt ?? "", | ||
| 69 | modelSelectionByProvider: { | ||
| 70 | [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options), | ||
| 71 | }, | ||
| 72 | activeProvider: instanceId, | ||
| 73 | }, | ||
| 74 | }, | ||
| 75 | draftThreadsByThreadKey: {}, | ||
| 76 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | ||
| 77 | }); | ||
| 78 | const host = document.createElement("div"); | ||
| 79 | document.body.append(host); | ||
| 80 | const onPromptChange = vi.fn(); | ||
| 81 | const providerOptions = props?.modelSelection?.options; | ||
| 82 | const models = [ | ||
| 83 | { | ||
| 84 | slug: "claude-opus-4-6", | ||
| 85 | name: "Claude Opus 4.6", | ||
| 86 | isCustom: false, | ||
| 87 | capabilities: createModelCapabilities({ | ||
| 88 | optionDescriptors: [ | ||
| 89 | selectDescriptor( | ||
| 90 | "effort", | ||
| 91 | "Reasoning", | ||
| 92 | [ | ||
| 93 | { id: "low", label: "Low" }, | ||
| 94 | { id: "medium", label: "Medium" }, | ||
| 95 | { id: "high", label: "High", isDefault: true }, | ||
| 96 | { id: "max", label: "Max" }, | ||
| 97 | { id: "ultrathink", label: "Ultrathink" }, | ||
| 98 | ], | ||
| 99 | ["ultrathink"], | ||
| 100 | ), | ||
| 101 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 102 | ], | ||
| 103 | }), | ||
| 104 | }, | ||
| 105 | { | ||
| 106 | slug: "claude-haiku-4-5", | ||
| 107 | name: "Claude Haiku 4.5", | ||
| 108 | isCustom: false, | ||
| 109 | capabilities: createModelCapabilities({ | ||
| 110 | optionDescriptors: [booleanDescriptor("thinking", "Thinking")], | ||
| 111 | }), | ||
| 112 | }, | ||
| 113 | { | ||
| 114 | slug: "claude-sonnet-4-6", | ||
| 115 | name: "Claude Sonnet 4.6", | ||
| 116 | isCustom: false, | ||
| 117 | capabilities: createModelCapabilities({ | ||
| 118 | optionDescriptors: [ | ||
| 119 | selectDescriptor( | ||
| 120 | "effort", | ||
| 121 | "Reasoning", | ||
| 122 | [ | ||
| 123 | { id: "low", label: "Low" }, | ||
| 124 | { id: "medium", label: "Medium" }, | ||
| 125 | { id: "high", label: "High", isDefault: true }, | ||
| 126 | { id: "ultrathink", label: "Ultrathink" }, | ||
| 127 | ], | ||
| 128 | ["ultrathink"], | ||
| 129 | ), | ||
| 130 | ], | ||
| 131 | }), | ||
| 132 | }, | ||
| 133 | ]; | ||
| 134 | const screen = await render( | ||
| 135 | <CompactComposerControlsMenu | ||
| 136 | activePlan={false} | ||
| 137 | interactionMode="default" | ||
| 138 | planSidebarLabel="Plan" | ||
| 139 | planSidebarOpen={false} | ||
| 140 | runtimeMode="approval-required" | ||
| 141 | showInteractionModeToggle | ||
| 142 | traitsMenuContent={ | ||
| 143 | <TraitsMenuContent | ||
| 144 | provider={provider} | ||
| 145 | models={models} | ||
| 146 | threadRef={threadRef} | ||
| 147 | model={model} | ||
| 148 | prompt={props?.prompt ?? ""} | ||
| 149 | modelOptions={providerOptions} | ||
| 150 | onPromptChange={onPromptChange} | ||
| 151 | /> | ||
| 152 | } | ||
| 153 | onToggleInteractionMode={vi.fn()} | ||
| 154 | onTogglePlanSidebar={vi.fn()} | ||
| 155 | onRuntimeModeChange={vi.fn()} | ||
| 156 | />, | ||
| 157 | { container: host }, | ||
| 158 | ); | ||
| 159 | |||
| 160 | const cleanup = async () => { | ||
| 161 | await screen.unmount(); | ||
| 162 | host.remove(); | ||
| 163 | }; | ||
| 164 | |||
| 165 | return { | ||
| 166 | [Symbol.asyncDispose]: cleanup, | ||
| 167 | cleanup, | ||
| 168 | }; | ||
| 169 | } | ||
| 170 | |||
| 171 | describe("CompactComposerControlsMenu", () => { | ||
| 172 | afterEach(() => { | ||
| 173 | document.body.innerHTML = ""; | ||
| 174 | useComposerDraftStore.setState({ | ||
| 175 | draftsByThreadKey: {}, | ||
| 176 | draftThreadsByThreadKey: {}, | ||
| 177 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | ||
| 178 | stickyModelSelectionByProvider: {}, | ||
| 179 | }); | ||
| 180 | }); | ||
| 181 | |||
| 182 | it("shows fast mode controls for Opus", async () => { | ||
| 183 | await using _ = await mountMenu({ | ||
| 184 | modelSelection: createModelSelection( | ||
| 185 | ProviderInstanceId.make("claudeAgent"), | ||
| 186 | "claude-opus-4-6", | ||
| 187 | ), | ||
| 188 | }); | ||
| 189 | |||
| 190 | await page.getByLabelText("More composer controls").click(); | ||
| 191 | |||
| 192 | await vi.waitFor(() => { | ||
| 193 | const text = document.body.textContent ?? ""; | ||
| 194 | expect(text).toContain("Fast Mode"); | ||
| 195 | expect(text).toContain("On"); | ||
| 196 | expect(text).toContain("Off"); | ||
| 197 | }); | ||
| 198 | }); | ||
| 199 | |||
| 200 | it("hides fast mode controls for non-Opus Claude models", async () => { | ||
| 201 | await using _ = await mountMenu({ | ||
| 202 | modelSelection: createModelSelection( | ||
| 203 | ProviderInstanceId.make("claudeAgent"), | ||
| 204 | "claude-sonnet-4-6", | ||
| 205 | ), | ||
| 206 | }); | ||
| 207 | |||
| 208 | await page.getByLabelText("More composer controls").click(); | ||
| 209 | |||
| 210 | await vi.waitFor(() => { | ||
| 211 | expect(document.body.textContent ?? "").not.toContain("Fast Mode"); | ||
| 212 | }); | ||
| 213 | }); | ||
| 214 | |||
| 215 | it("shows only the provided effort options", async () => { | ||
| 216 | await using _ = await mountMenu({ | ||
| 217 | modelSelection: createModelSelection( | ||
| 218 | ProviderInstanceId.make("claudeAgent"), | ||
| 219 | "claude-sonnet-4-6", | ||
| 220 | ), | ||
| 221 | }); | ||
| 222 | |||
| 223 | await page.getByLabelText("More composer controls").click(); | ||
| 224 | |||
| 225 | await vi.waitFor(() => { | ||
| 226 | const text = document.body.textContent ?? ""; | ||
| 227 | expect(text).toContain("Low"); | ||
| 228 | expect(text).toContain("Medium"); | ||
| 229 | expect(text).toContain("High"); | ||
| 230 | expect(text).not.toContain("Max"); | ||
| 231 | expect(text).toContain("Ultrathink"); | ||
| 232 | }); | ||
| 233 | }); | ||
| 234 | |||
| 235 | it("shows a Claude thinking on/off section for Haiku", async () => { | ||
| 236 | await using _ = await mountMenu({ | ||
| 237 | modelSelection: createModelSelection( | ||
| 238 | ProviderInstanceId.make("claudeAgent"), | ||
| 239 | "claude-haiku-4-5", | ||
| 240 | [{ id: "thinking", value: true }], | ||
| 241 | ), | ||
| 242 | }); | ||
| 243 | |||
| 244 | await page.getByLabelText("More composer controls").click(); | ||
| 245 | |||
| 246 | await vi.waitFor(() => { | ||
| 247 | const text = document.body.textContent ?? ""; | ||
| 248 | expect(text).toContain("Thinking"); | ||
| 249 | expect(text).toContain("On"); | ||
| 250 | expect(text).toContain("Off"); | ||
| 251 | }); | ||
| 252 | }); | ||
| 253 | |||
| 254 | it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { | ||
| 255 | await using _ = await mountMenu({ | ||
| 256 | modelSelection: createModelSelection( | ||
| 257 | ProviderInstanceId.make("claudeAgent"), | ||
| 258 | "claude-opus-4-6", | ||
| 259 | [{ id: "effort", value: "high" }], | ||
| 260 | ), | ||
| 261 | prompt: "Ultrathink:\nInvestigate this", | ||
| 262 | }); | ||
| 263 | |||
| 264 | await page.getByLabelText("More composer controls").click(); | ||
| 265 | |||
| 266 | await vi.waitFor(() => { | ||
| 267 | const text = document.body.textContent ?? ""; | ||
| 268 | expect(text).toContain("Reasoning"); | ||
| 269 | expect(text).not.toContain("ultrathink"); | ||
| 270 | }); | ||
| 271 | }); | ||
| 272 | |||
| 273 | it("warns when ultrathink appears in prompt body text", async () => { | ||
| 274 | await using _ = await mountMenu({ | ||
| 275 | modelSelection: createModelSelection( | ||
| 276 | ProviderInstanceId.make("claudeAgent"), | ||
| 277 | "claude-opus-4-6", | ||
| 278 | [{ id: "effort", value: "high" }], | ||
| 279 | ), | ||
| 280 | prompt: "Ultrathink:\nplease ultrathink about this problem", | ||
| 281 | }); | ||
| 282 | |||
| 283 | await page.getByLabelText("More composer controls").click(); | ||
| 284 | |||
| 285 | await vi.waitFor(() => { | ||
| 286 | const text = document.body.textContent ?? ""; | ||
| 287 | expect(text).toContain( | ||
| 288 | 'Your prompt contains "ultrathink" in the text. Remove it to change this option.', | ||
| 289 | ); | ||
| 290 | }); | ||
| 291 | }); | ||
| 292 | |||
| 293 | it("can hide the interaction mode section", async () => { | ||
| 294 | const host = document.createElement("div"); | ||
| 295 | document.body.append(host); | ||
| 296 | const screen = await render( | ||
| 297 | <CompactComposerControlsMenu | ||
| 298 | activePlan={false} | ||
| 299 | interactionMode="default" | ||
| 300 | planSidebarLabel="Plan" | ||
| 301 | planSidebarOpen={false} | ||
| 302 | runtimeMode="approval-required" | ||
| 303 | showInteractionModeToggle={false} | ||
| 304 | onToggleInteractionMode={vi.fn()} | ||
| 305 | onTogglePlanSidebar={vi.fn()} | ||
| 306 | onRuntimeModeChange={vi.fn()} | ||
| 307 | />, | ||
| 308 | { container: host }, | ||
| 309 | ); | ||
| 310 | |||
| 311 | await page.getByLabelText("More composer controls").click(); | ||
| 312 | |||
| 313 | await vi.waitFor(() => { | ||
| 314 | const text = document.body.textContent ?? ""; | ||
| 315 | expect(text).not.toContain("Mode"); | ||
| 316 | expect(text).not.toContain("Chat"); | ||
| 317 | expect(text).not.toContain("Plan"); | ||
| 318 | expect(text).toContain("Access"); | ||
| 319 | expect(text).toContain("Supervised"); | ||
| 320 | expect(text).toContain("Full access"); | ||
| 321 | }); | ||
| 322 | |||
| 323 | await screen.unmount(); | ||
| 324 | host.remove(); | ||
| 325 | }); | ||
| 326 | }); | ||
apps/web/src/components/chat/ComposerBannerStack.tsx+5-7| ... | @@ -40,14 +40,12 @@ interface ComposerBannerStackProps { | ... | @@ -40,14 +40,12 @@ interface ComposerBannerStackProps { |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { | 42 | export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { |
| 43 | const [exitingItemId, setExitingItemId] = useState<string | null>(null); | 43 | const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null); |
| 44 | const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 44 | const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 45 | 45 | const exitingItemId = | |
| 46 | useEffect(() => { | 46 | requestedExitingItemId !== null && items.some((item) => item.id === requestedExitingItemId) |
| 47 | if (exitingItemId && !items.some((item) => item.id === exitingItemId)) { | 47 | ? requestedExitingItemId |
| 48 | setExitingItemId(null); | 48 | : null; |
| 49 | } | ||
| 50 | }, [exitingItemId, items]); | ||
| 51 | 49 | ||
| 52 | useEffect(() => { | 50 | useEffect(() => { |
| 53 | return () => { | 51 | return () => { |
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+1-1| ... | @@ -8,7 +8,7 @@ interface ComposerPendingApprovalActionsProps { | ... | @@ -8,7 +8,7 @@ interface ComposerPendingApprovalActionsProps { |
| 8 | onRespondToApproval: ( | 8 | onRespondToApproval: ( |
| 9 | requestId: ApprovalRequestId, | 9 | requestId: ApprovalRequestId, |
| 10 | decision: ProviderApprovalDecision, | 10 | decision: ProviderApprovalDecision, |
| 11 | ) => Promise<void>; | 11 | ) => Promise<unknown>; |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ | 14 | export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ |
apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx deleted-41| ... | @@ -1,41 +0,0 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | import { page } from "vite-plus/test/browser"; | ||
| 5 | import { render } from "vitest-browser-react"; | ||
| 6 | |||
| 7 | import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; | ||
| 8 | |||
| 9 | describe("ComposerPendingReviewComments", () => { | ||
| 10 | afterEach(() => { | ||
| 11 | document.body.innerHTML = ""; | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("renders a removable file comment pill", async () => { | ||
| 15 | const onRemove = vi.fn(); | ||
| 16 | const screen = await render( | ||
| 17 | <ComposerPendingReviewComments | ||
| 18 | comments={[ | ||
| 19 | { | ||
| 20 | id: "comment-1", | ||
| 21 | sectionId: "file:src/app.ts", | ||
| 22 | sectionTitle: "File comment", | ||
| 23 | filePath: "src/app.ts", | ||
| 24 | startIndex: 1, | ||
| 25 | endIndex: 2, | ||
| 26 | rangeLabel: "L2 to L3", | ||
| 27 | text: "Keep this configurable.", | ||
| 28 | diff: "@@ -2,2 +2,2 @@\n two\n three", | ||
| 29 | }, | ||
| 30 | ]} | ||
| 31 | onRemove={onRemove} | ||
| 32 | />, | ||
| 33 | ); | ||
| 34 | |||
| 35 | await expect.element(page.getByText("src/app.ts L2 to L3")).toBeVisible(); | ||
| 36 | await page.getByRole("button", { name: "Remove comment on src/app.ts L2 to L3" }).click(); | ||
| 37 | expect(onRemove).toHaveBeenCalledWith("comment-1"); | ||
| 38 | |||
| 39 | await screen.unmount(); | ||
| 40 | }); | ||
| 41 | }); | ||
apps/web/src/components/chat/ExpandedImageDialog.tsx+6-16| ... | @@ -9,24 +9,14 @@ interface ExpandedImageDialogProps { | ... | @@ -9,24 +9,14 @@ interface ExpandedImageDialogProps { |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | 11 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 12 | preview: initialPreview, | 12 | preview, |
| 13 | onClose, | 13 | onClose, |
| 14 | }: ExpandedImageDialogProps) { | 14 | }: ExpandedImageDialogProps) { |
| 15 | const [preview, setPreview] = useState(initialPreview); | 15 | const [imageOffset, setImageOffset] = useState(0); |
| 16 | 16 | const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; | |
| 17 | // Sync when the parent hands us a new preview reference. | ||
| 18 | useEffect(() => { | ||
| 19 | setPreview(initialPreview); | ||
| 20 | }, [initialPreview]); | ||
| 21 | 17 | ||
| 22 | const navigateImage = useCallback((direction: -1 | 1) => { | 18 | const navigateImage = useCallback((direction: -1 | 1) => { |
| 23 | setPreview((existing) => { | 19 | setImageOffset((current) => current + direction); |
| 24 | if (existing.images.length <= 1) return existing; | ||
| 25 | const nextIndex = | ||
| 26 | (existing.index + direction + existing.images.length) % existing.images.length; | ||
| 27 | if (nextIndex === existing.index) return existing; | ||
| 28 | return { ...existing, index: nextIndex }; | ||
| 29 | }); | ||
| 30 | }, []); | 20 | }, []); |
| 31 | 21 | ||
| 32 | useEffect(() => { | 22 | useEffect(() => { |
| ... | @@ -53,7 +43,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | ... | @@ -53,7 +43,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 53 | return () => window.removeEventListener("keydown", onKeyDown); | 43 | return () => window.removeEventListener("keydown", onKeyDown); |
| 54 | }, [navigateImage, onClose, preview.images.length]); | 44 | }, [navigateImage, onClose, preview.images.length]); |
| 55 | 45 | ||
| 56 | const item = preview.images[preview.index]; | 46 | const item = preview.images[index]; |
| 57 | if (!item) return null; | 47 | if (!item) return null; |
| 58 | 48 | ||
| 59 | return ( | 49 | return ( |
| ... | @@ -100,7 +90,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | ... | @@ -100,7 +90,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 100 | /> | 90 | /> |
| 101 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> | 91 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> |
| 102 | {item.name} | 92 | {item.name} |
| 103 | {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} | 93 | {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""} |
| 104 | </p> | 94 | </p> |
| 105 | </div> | 95 | </div> |
| 106 | {preview.images.length > 1 && ( | 96 | {preview.images.length > 1 && ( |
apps/web/src/components/chat/MessagesTimeline.browser.tsx deleted-477| ... | @@ -1,477 +0,0 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import { createRef } from "react"; | ||
| 5 | import type { LegendListRef } from "@legendapp/list/react"; | ||
| 6 | import { page } from "vite-plus/test/browser"; | ||
| 7 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 8 | import { render } from "vitest-browser-react"; | ||
| 9 | |||
| 10 | const scrollToEndSpy = vi.fn(); | ||
| 11 | const getStateSpy = vi.fn(() => ({ isAtEnd: true })); | ||
| 12 | |||
| 13 | vi.mock("@legendapp/list/react", async () => { | ||
| 14 | const React = await import("react"); | ||
| 15 | |||
| 16 | function LegendList(props: { | ||
| 17 | data: Array<{ id: string }>; | ||
| 18 | keyExtractor: (item: { id: string }) => string; | ||
| 19 | renderItem: (args: { item: { id: string } }) => React.ReactNode; | ||
| 20 | ListHeaderComponent?: React.ReactNode; | ||
| 21 | ListFooterComponent?: React.ReactNode; | ||
| 22 | ref?: React.Ref<LegendListRef>; | ||
| 23 | }) { | ||
| 24 | React.useImperativeHandle( | ||
| 25 | props.ref, | ||
| 26 | () => | ||
| 27 | ({ | ||
| 28 | scrollToEnd: scrollToEndSpy, | ||
| 29 | getState: getStateSpy, | ||
| 30 | }) as unknown as LegendListRef, | ||
| 31 | ); | ||
| 32 | |||
| 33 | return ( | ||
| 34 | <div data-testid="legend-list"> | ||
| 35 | {props.ListHeaderComponent} | ||
| 36 | {props.data.map((item) => ( | ||
| 37 | <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div> | ||
| 38 | ))} | ||
| 39 | {props.ListFooterComponent} | ||
| 40 | </div> | ||
| 41 | ); | ||
| 42 | } | ||
| 43 | |||
| 44 | return { LegendList }; | ||
| 45 | }); | ||
| 46 | |||
| 47 | import { MessagesTimeline } from "./MessagesTimeline"; | ||
| 48 | |||
| 49 | const MESSAGE_CREATED_AT = "2026-04-13T12:00:00.000Z"; | ||
| 50 | |||
| 51 | function buildProps() { | ||
| 52 | return { | ||
| 53 | isWorking: false, | ||
| 54 | activeTurnInProgress: false, | ||
| 55 | activeTurnStartedAt: null, | ||
| 56 | listRef: createRef<LegendListRef | null>(), | ||
| 57 | latestTurn: null, | ||
| 58 | turnDiffSummaryByAssistantMessageId: new Map(), | ||
| 59 | routeThreadKey: "environment-local:thread-1", | ||
| 60 | onOpenTurnDiff: vi.fn(), | ||
| 61 | revertTurnCountByUserMessageId: new Map(), | ||
| 62 | onRevertUserMessage: vi.fn(), | ||
| 63 | isRevertingCheckpoint: false, | ||
| 64 | onImageExpand: vi.fn(), | ||
| 65 | activeThreadEnvironmentId: EnvironmentId.make("environment-local"), | ||
| 66 | markdownCwd: undefined, | ||
| 67 | resolvedTheme: "dark" as const, | ||
| 68 | timestampFormat: "24-hour" as const, | ||
| 69 | workspaceRoot: undefined, | ||
| 70 | onIsAtEndChange: vi.fn(), | ||
| 71 | }; | ||
| 72 | } | ||
| 73 | |||
| 74 | function buildLongUserMessageText(tail = "deep hidden detail only after expand") { | ||
| 75 | return Array.from({ length: 9 }, (_, index) => | ||
| 76 | index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`, | ||
| 77 | ).join("\n"); | ||
| 78 | } | ||
| 79 | |||
| 80 | function buildUserTimelineEntry(text: string) { | ||
| 81 | return { | ||
| 82 | id: "entry-1", | ||
| 83 | kind: "message" as const, | ||
| 84 | createdAt: MESSAGE_CREATED_AT, | ||
| 85 | message: { | ||
| 86 | id: "message-1" as never, | ||
| 87 | role: "user" as const, | ||
| 88 | text, | ||
| 89 | createdAt: MESSAGE_CREATED_AT, | ||
| 90 | streaming: false, | ||
| 91 | }, | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 95 | function buildAssistantTimelineEntry(text: string) { | ||
| 96 | return { | ||
| 97 | id: "entry-assistant-1", | ||
| 98 | kind: "message" as const, | ||
| 99 | createdAt: MESSAGE_CREATED_AT, | ||
| 100 | message: { | ||
| 101 | id: "message-assistant-1" as never, | ||
| 102 | role: "assistant" as const, | ||
| 103 | text, | ||
| 104 | createdAt: MESSAGE_CREATED_AT, | ||
| 105 | streaming: false, | ||
| 106 | }, | ||
| 107 | }; | ||
| 108 | } | ||
| 109 | |||
| 110 | describe("MessagesTimeline", () => { | ||
| 111 | afterEach(() => { | ||
| 112 | scrollToEndSpy.mockReset(); | ||
| 113 | getStateSpy.mockClear(); | ||
| 114 | vi.restoreAllMocks(); | ||
| 115 | document.body.innerHTML = ""; | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("renders activity rows instead of the empty placeholder when a thread has non-message timeline data", async () => { | ||
| 119 | const screen = await render( | ||
| 120 | <MessagesTimeline | ||
| 121 | {...buildProps()} | ||
| 122 | timelineEntries={[ | ||
| 123 | { | ||
| 124 | id: "work-1", | ||
| 125 | kind: "work", | ||
| 126 | createdAt: "2026-04-13T12:00:00.000Z", | ||
| 127 | entry: { | ||
| 128 | id: "work-1", | ||
| 129 | createdAt: "2026-04-13T12:00:00.000Z", | ||
| 130 | label: "read files", | ||
| 131 | detail: "Inspecting repository state", | ||
| 132 | tone: "tool", | ||
| 133 | }, | ||
| 134 | }, | ||
| 135 | ]} | ||
| 136 | />, | ||
| 137 | ); | ||
| 138 | |||
| 139 | try { | ||
| 140 | await expect | ||
| 141 | .element(page.getByText("Send a message to start the conversation.")) | ||
| 142 | .not.toBeInTheDocument(); | ||
| 143 | await expect.element(page.getByText("Inspecting repository state")).toBeVisible(); | ||
| 144 | expect(document.querySelector('[data-testid="legend-list"] [title]')).toBeNull(); | ||
| 145 | } finally { | ||
| 146 | await screen.unmount(); | ||
| 147 | } | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("uses accessible expansion instead of native titles or preview tooltips for work entry details", async () => { | ||
| 151 | const screen = await render( | ||
| 152 | <MessagesTimeline | ||
| 153 | {...buildProps()} | ||
| 154 | workspaceRoot="/repo" | ||
| 155 | timelineEntries={[ | ||
| 156 | { | ||
| 157 | id: "work-command", | ||
| 158 | kind: "work", | ||
| 159 | createdAt: MESSAGE_CREATED_AT, | ||
| 160 | entry: { | ||
| 161 | id: "work-command", | ||
| 162 | createdAt: MESSAGE_CREATED_AT, | ||
| 163 | label: "command", | ||
| 164 | detail: "Inspecting generated output", | ||
| 165 | command: "git diff -- apps/web/src/components/ChatMarkdown.tsx", | ||
| 166 | rawCommand: "git diff -- apps/web/src/components/ChatMarkdown.tsx --stat", | ||
| 167 | changedFiles: ["/repo/apps/web/src/components/ChatMarkdown.tsx"], | ||
| 168 | tone: "tool", | ||
| 169 | }, | ||
| 170 | }, | ||
| 171 | ]} | ||
| 172 | />, | ||
| 173 | ); | ||
| 174 | |||
| 175 | try { | ||
| 176 | expect(document.querySelector('[data-testid="legend-list"] [title]')).toBeNull(); | ||
| 177 | |||
| 178 | const commandTrigger = page.getByLabelText( | ||
| 179 | "Command - git diff -- apps/web/src/components/ChatMarkdown.tsx", | ||
| 180 | ); | ||
| 181 | await commandTrigger.hover(); | ||
| 182 | expect(document.querySelector('[data-slot="tooltip-popup"]')).toBeNull(); | ||
| 183 | |||
| 184 | await commandTrigger.click(); | ||
| 185 | await expect | ||
| 186 | .element(page.getByText("git diff -- apps/web/src/components/ChatMarkdown.tsx --stat")) | ||
| 187 | .toBeVisible(); | ||
| 188 | } finally { | ||
| 189 | await screen.unmount(); | ||
| 190 | } | ||
| 191 | }); | ||
| 192 | |||
| 193 | it("snaps to the bottom when timeline rows appear after an initially empty render", async () => { | ||
| 194 | const requestAnimationFrameSpy = vi | ||
| 195 | .spyOn(window, "requestAnimationFrame") | ||
| 196 | .mockImplementation((callback: FrameRequestCallback) => { | ||
| 197 | callback(0); | ||
| 198 | return 1; | ||
| 199 | }); | ||
| 200 | vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); | ||
| 201 | |||
| 202 | const props = buildProps(); | ||
| 203 | const screen = await render(<MessagesTimeline {...props} timelineEntries={[]} />); | ||
| 204 | |||
| 205 | try { | ||
| 206 | await expect | ||
| 207 | .element(page.getByText("Send a message to start the conversation.")) | ||
| 208 | .toBeVisible(); | ||
| 209 | |||
| 210 | await screen.rerender( | ||
| 211 | <MessagesTimeline | ||
| 212 | {...props} | ||
| 213 | timelineEntries={[ | ||
| 214 | { | ||
| 215 | id: "work-1", | ||
| 216 | kind: "work", | ||
| 217 | createdAt: "2026-04-13T12:00:00.000Z", | ||
| 218 | entry: { | ||
| 219 | id: "work-1", | ||
| 220 | createdAt: "2026-04-13T12:00:00.000Z", | ||
| 221 | label: "read files", | ||
| 222 | detail: "Inspecting repository state", | ||
| 223 | tone: "tool", | ||
| 224 | }, | ||
| 225 | }, | ||
| 226 | ]} | ||
| 227 | />, | ||
| 228 | ); | ||
| 229 | |||
| 230 | await expect.element(page.getByText("Inspecting repository state")).toBeVisible(); | ||
| 231 | expect(props.onIsAtEndChange).toHaveBeenCalledWith(true); | ||
| 232 | expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false }); | ||
| 233 | expect(requestAnimationFrameSpy).toHaveBeenCalled(); | ||
| 234 | } finally { | ||
| 235 | await screen.unmount(); | ||
| 236 | } | ||
| 237 | }); | ||
| 238 | |||
| 239 | it("starts long user messages collapsed by default", async () => { | ||
| 240 | const screen = await render( | ||
| 241 | <MessagesTimeline | ||
| 242 | {...buildProps()} | ||
| 243 | timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]} | ||
| 244 | />, | ||
| 245 | ); | ||
| 246 | |||
| 247 | try { | ||
| 248 | const toggle = page.getByRole("button", { name: "Show full message" }); | ||
| 249 | await expect.element(toggle).toBeVisible(); | ||
| 250 | await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); | ||
| 251 | |||
| 252 | const messageBody = document.querySelector( | ||
| 253 | "[data-user-message-body='true']", | ||
| 254 | ) as HTMLDivElement | null; | ||
| 255 | expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true"); | ||
| 256 | expect(messageBody?.className).toContain("max-h-44"); | ||
| 257 | expect(messageBody?.className).toContain("overflow-hidden"); | ||
| 258 | expect(messageBody?.getAttribute("data-user-message-fade")).toBe("true"); | ||
| 259 | expect(messageBody?.style.maskImage).toContain("linear-gradient"); | ||
| 260 | } finally { | ||
| 261 | await screen.unmount(); | ||
| 262 | } | ||
| 263 | }); | ||
| 264 | |||
| 265 | it("expands and re-collapses long user messages from the toggle", async () => { | ||
| 266 | const screen = await render( | ||
| 267 | <MessagesTimeline | ||
| 268 | {...buildProps()} | ||
| 269 | timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]} | ||
| 270 | />, | ||
| 271 | ); | ||
| 272 | |||
| 273 | try { | ||
| 274 | const expandButton = page.getByRole("button", { name: "Show full message" }); | ||
| 275 | await expect.element(expandButton).toBeVisible(); | ||
| 276 | |||
| 277 | expect(document.body.textContent ?? "").toContain("deep hidden detail only after expand"); | ||
| 278 | |||
| 279 | await expandButton.click(); | ||
| 280 | |||
| 281 | const collapseButton = page.getByRole("button", { name: "Show less" }); | ||
| 282 | await expect.element(collapseButton).toBeVisible(); | ||
| 283 | await expect.element(collapseButton).toHaveAttribute("aria-expanded", "true"); | ||
| 284 | |||
| 285 | let messageBody = document.querySelector("[data-user-message-body='true']"); | ||
| 286 | expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("false"); | ||
| 287 | expect(messageBody?.className).not.toContain("max-h-44"); | ||
| 288 | expect(messageBody?.getAttribute("data-user-message-fade")).toBe("false"); | ||
| 289 | expect((messageBody as HTMLDivElement | null)?.style.maskImage ?? "").toBe(""); | ||
| 290 | |||
| 291 | await collapseButton.click(); | ||
| 292 | |||
| 293 | await expect.element(page.getByRole("button", { name: "Show full message" })).toBeVisible(); | ||
| 294 | messageBody = document.querySelector("[data-user-message-body='true']"); | ||
| 295 | expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true"); | ||
| 296 | expect(messageBody?.className).toContain("max-h-44"); | ||
| 297 | expect(messageBody?.getAttribute("data-user-message-fade")).toBe("true"); | ||
| 298 | expect((messageBody as HTMLDivElement | null)?.style.maskImage).toContain("linear-gradient"); | ||
| 299 | } finally { | ||
| 300 | await screen.unmount(); | ||
| 301 | } | ||
| 302 | }); | ||
| 303 | |||
| 304 | it("starts the newest long user prompt collapsed", async () => { | ||
| 305 | const screen = await render( | ||
| 306 | <MessagesTimeline | ||
| 307 | {...buildProps()} | ||
| 308 | timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText("latest long prompt"))]} | ||
| 309 | />, | ||
| 310 | ); | ||
| 311 | |||
| 312 | try { | ||
| 313 | await expect.element(page.getByRole("button", { name: "Show full message" })).toBeVisible(); | ||
| 314 | |||
| 315 | const messageBody = document.querySelector("[data-user-message-body='true']"); | ||
| 316 | expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true"); | ||
| 317 | } finally { | ||
| 318 | await screen.unmount(); | ||
| 319 | } | ||
| 320 | }); | ||
| 321 | |||
| 322 | it("renders user messages as markdown with chat-style line breaks", async () => { | ||
| 323 | const screen = await render( | ||
| 324 | <MessagesTimeline | ||
| 325 | {...buildProps()} | ||
| 326 | timelineEntries={[ | ||
| 327 | buildUserTimelineEntry( | ||
| 328 | "## Plan\nuse **bold** and [a link](https://example.com)\nsecond line", | ||
| 329 | ), | ||
| 330 | ]} | ||
| 331 | />, | ||
| 332 | ); | ||
| 333 | |||
| 334 | try { | ||
| 335 | await expect.element(page.getByRole("heading", { level: 2, name: "Plan" })).toBeVisible(); | ||
| 336 | await expect | ||
| 337 | .element(page.getByRole("link", { name: "a link" })) | ||
| 338 | .toHaveAttribute("href", "https://example.com"); | ||
| 339 | |||
| 340 | const messageBody = document.querySelector("[data-user-message-body='true']"); | ||
| 341 | expect(messageBody?.querySelector("strong")?.textContent).toBe("bold"); | ||
| 342 | // remark-breaks: the single newline between the inline runs is a <br>. | ||
| 343 | expect(messageBody?.querySelectorAll("p br").length).toBe(1); | ||
| 344 | } finally { | ||
| 345 | await screen.unmount(); | ||
| 346 | } | ||
| 347 | }); | ||
| 348 | |||
| 349 | it("renders markdown file tags in user and assistant messages", async () => { | ||
| 350 | const fileLink = "[package.json](path/to/package.json)"; | ||
| 351 | const screen = await render( | ||
| 352 | <MessagesTimeline | ||
| 353 | {...buildProps()} | ||
| 354 | markdownCwd="/repo/project" | ||
| 355 | timelineEntries={[ | ||
| 356 | buildUserTimelineEntry(`Review ${fileLink}`), | ||
| 357 | buildAssistantTimelineEntry(`I reviewed ${fileLink}`), | ||
| 358 | ]} | ||
| 359 | />, | ||
| 360 | ); | ||
| 361 | |||
| 362 | try { | ||
| 363 | const userFileLink = document.querySelector( | ||
| 364 | '[data-message-role="user"] .chat-markdown-file-link', | ||
| 365 | ); | ||
| 366 | const assistantFileLink = document.querySelector( | ||
| 367 | '[data-message-role="assistant"] .chat-markdown-file-link', | ||
| 368 | ); | ||
| 369 | |||
| 370 | expect(userFileLink?.textContent).toContain("package.json"); | ||
| 371 | expect(userFileLink?.getAttribute("href")).toBe("/repo/project/path/to/package.json"); | ||
| 372 | expect(assistantFileLink?.textContent).toContain("package.json"); | ||
| 373 | expect(assistantFileLink?.getAttribute("href")).toBe("/repo/project/path/to/package.json"); | ||
| 374 | } finally { | ||
| 375 | await screen.unmount(); | ||
| 376 | } | ||
| 377 | }); | ||
| 378 | |||
| 379 | it("uses the file path without line suffix for markdown file tag icons", async () => { | ||
| 380 | const fileLink = "[package.json](path/to/package.json:25)"; | ||
| 381 | const screen = await render( | ||
| 382 | <MessagesTimeline | ||
| 383 | {...buildProps()} | ||
| 384 | markdownCwd="/repo/project" | ||
| 385 | timelineEntries={[buildAssistantTimelineEntry(`Updated ${fileLink}`)]} | ||
| 386 | />, | ||
| 387 | ); | ||
| 388 | |||
| 389 | try { | ||
| 390 | const assistantFileLink = document.querySelector( | ||
| 391 | '[data-message-role="assistant"] .chat-markdown-file-link', | ||
| 392 | ); | ||
| 393 | const icon = assistantFileLink?.querySelector("svg[data-pierre-icon]"); | ||
| 394 | |||
| 395 | expect(assistantFileLink?.textContent).toContain("package.json"); | ||
| 396 | expect(assistantFileLink?.textContent).toContain("L25"); | ||
| 397 | expect(assistantFileLink?.getAttribute("href")).toBe("/repo/project/path/to/package.json:25"); | ||
| 398 | expect(icon?.getAttribute("data-pierre-icon")).toBe("t3-file-icon-package-json"); | ||
| 399 | } finally { | ||
| 400 | await screen.unmount(); | ||
| 401 | } | ||
| 402 | }); | ||
| 403 | |||
| 404 | it("folds settled-turn work behind a Worked-for row and expands it on click", async () => { | ||
| 405 | const screen = await render( | ||
| 406 | <MessagesTimeline | ||
| 407 | {...buildProps()} | ||
| 408 | timelineEntries={[ | ||
| 409 | { | ||
| 410 | id: "entry-commentary", | ||
| 411 | kind: "message" as const, | ||
| 412 | createdAt: "2026-04-13T12:00:00.000Z", | ||
| 413 | message: { | ||
| 414 | id: "message-commentary" as never, | ||
| 415 | role: "assistant" as const, | ||
| 416 | text: "Let me look around first.", | ||
| 417 | turnId: "turn-1" as never, | ||
| 418 | createdAt: "2026-04-13T12:00:00.000Z", | ||
| 419 | completedAt: "2026-04-13T12:00:02.000Z", | ||
| 420 | streaming: false, | ||
| 421 | }, | ||
| 422 | }, | ||
| 423 | { | ||
| 424 | id: "entry-work", | ||
| 425 | kind: "work" as const, | ||
| 426 | createdAt: "2026-04-13T12:00:05.000Z", | ||
| 427 | entry: { | ||
| 428 | id: "work-1", | ||
| 429 | createdAt: "2026-04-13T12:00:05.000Z", | ||
| 430 | turnId: "turn-1" as never, | ||
| 431 | label: "read files", | ||
| 432 | detail: "Inspecting repository state", | ||
| 433 | tone: "tool" as const, | ||
| 434 | }, | ||
| 435 | }, | ||
| 436 | { | ||
| 437 | id: "entry-final", | ||
| 438 | kind: "message" as const, | ||
| 439 | createdAt: "2026-04-13T12:00:20.000Z", | ||
| 440 | message: { | ||
| 441 | id: "message-final" as never, | ||
| 442 | role: "assistant" as const, | ||
| 443 | text: "All done.", | ||
| 444 | turnId: "turn-1" as never, | ||
| 445 | createdAt: "2026-04-13T12:00:20.000Z", | ||
| 446 | completedAt: "2026-04-13T12:00:30.000Z", | ||
| 447 | streaming: false, | ||
| 448 | }, | ||
| 449 | }, | ||
| 450 | ]} | ||
| 451 | />, | ||
| 452 | ); | ||
| 453 | |||
| 454 | try { | ||
| 455 | const foldButton = page.getByRole("button", { name: "Worked for 30s" }); | ||
| 456 | await expect.element(foldButton).toBeVisible(); | ||
| 457 | await expect.element(foldButton).toHaveAttribute("aria-expanded", "false"); | ||
| 458 | |||
| 459 | expect(document.body.textContent).toContain("All done."); | ||
| 460 | expect(document.body.textContent).not.toContain("Let me look around first."); | ||
| 461 | expect(document.body.textContent).not.toContain("Inspecting repository state"); | ||
| 462 | |||
| 463 | await foldButton.click(); | ||
| 464 | |||
| 465 | await expect.element(foldButton).toHaveAttribute("aria-expanded", "true"); | ||
| 466 | expect(document.body.textContent).toContain("Let me look around first."); | ||
| 467 | expect(document.body.textContent).toContain("Inspecting repository state"); | ||
| 468 | |||
| 469 | await foldButton.click(); | ||
| 470 | |||
| 471 | await expect.element(foldButton).toHaveAttribute("aria-expanded", "false"); | ||
| 472 | expect(document.body.textContent).not.toContain("Inspecting repository state"); | ||
| 473 | } finally { | ||
| 474 | await screen.unmount(); | ||
| 475 | } | ||
| 476 | }); | ||
| 477 | }); | ||
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+103-34| ... | @@ -14,7 +14,8 @@ describe("computeMessageDurationStart", () => { | ... | @@ -14,7 +14,8 @@ describe("computeMessageDurationStart", () => { |
| 14 | id: "a1", | 14 | id: "a1", |
| 15 | role: "assistant", | 15 | role: "assistant", |
| 16 | createdAt: "2026-01-01T00:00:05Z", | 16 | createdAt: "2026-01-01T00:00:05Z", |
| 17 | completedAt: "2026-01-01T00:00:10Z", | 17 | updatedAt: "2026-01-01T00:00:10Z", |
| 18 | streaming: false, | ||
| 18 | }, | 19 | }, |
| 19 | ]); | 20 | ]); |
| 20 | expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]])); | 21 | expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]])); |
| ... | @@ -22,12 +23,19 @@ describe("computeMessageDurationStart", () => { | ... | @@ -22,12 +23,19 @@ describe("computeMessageDurationStart", () => { |
| 22 | 23 | ||
| 23 | it("uses the user message createdAt for the first assistant response", () => { | 24 | it("uses the user message createdAt for the first assistant response", () => { |
| 24 | const result = computeMessageDurationStart([ | 25 | const result = computeMessageDurationStart([ |
| 25 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | 26 | { |
| 27 | id: "u1", | ||
| 28 | role: "user", | ||
| 29 | createdAt: "2026-01-01T00:00:00Z", | ||
| 30 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 31 | streaming: false, | ||
| 32 | }, | ||
| 26 | { | 33 | { |
| 27 | id: "a1", | 34 | id: "a1", |
| 28 | role: "assistant", | 35 | role: "assistant", |
| 29 | createdAt: "2026-01-01T00:00:30Z", | 36 | createdAt: "2026-01-01T00:00:30Z", |
| 30 | completedAt: "2026-01-01T00:00:30Z", | 37 | updatedAt: "2026-01-01T00:00:30Z", |
| 38 | streaming: false, | ||
| 31 | }, | 39 | }, |
| 32 | ]); | 40 | ]); |
| 33 | 41 | ||
| ... | @@ -39,20 +47,28 @@ describe("computeMessageDurationStart", () => { | ... | @@ -39,20 +47,28 @@ describe("computeMessageDurationStart", () => { |
| 39 | ); | 47 | ); |
| 40 | }); | 48 | }); |
| 41 | 49 | ||
| 42 | it("uses the previous assistant completedAt for subsequent assistant responses", () => { | 50 | it("uses the previous completed assistant updatedAt for subsequent assistant responses", () => { |
| 43 | const result = computeMessageDurationStart([ | 51 | const result = computeMessageDurationStart([ |
| 44 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | 52 | { |
| 53 | id: "u1", | ||
| 54 | role: "user", | ||
| 55 | createdAt: "2026-01-01T00:00:00Z", | ||
| 56 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 57 | streaming: false, | ||
| 58 | }, | ||
| 45 | { | 59 | { |
| 46 | id: "a1", | 60 | id: "a1", |
| 47 | role: "assistant", | 61 | role: "assistant", |
| 48 | createdAt: "2026-01-01T00:00:30Z", | 62 | createdAt: "2026-01-01T00:00:30Z", |
| 49 | completedAt: "2026-01-01T00:00:30Z", | 63 | updatedAt: "2026-01-01T00:00:30Z", |
| 64 | streaming: false, | ||
| 50 | }, | 65 | }, |
| 51 | { | 66 | { |
| 52 | id: "a2", | 67 | id: "a2", |
| 53 | role: "assistant", | 68 | role: "assistant", |
| 54 | createdAt: "2026-01-01T00:00:55Z", | 69 | createdAt: "2026-01-01T00:00:55Z", |
| 55 | completedAt: "2026-01-01T00:00:55Z", | 70 | updatedAt: "2026-01-01T00:00:55Z", |
| 71 | streaming: false, | ||
| 56 | }, | 72 | }, |
| 57 | ]); | 73 | ]); |
| 58 | 74 | ||
| ... | @@ -65,15 +81,28 @@ describe("computeMessageDurationStart", () => { | ... | @@ -65,15 +81,28 @@ describe("computeMessageDurationStart", () => { |
| 65 | ); | 81 | ); |
| 66 | }); | 82 | }); |
| 67 | 83 | ||
| 68 | it("does not advance the boundary for a streaming message without completedAt", () => { | 84 | it("does not advance the boundary for a streaming message", () => { |
| 69 | const result = computeMessageDurationStart([ | 85 | const result = computeMessageDurationStart([ |
| 70 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | 86 | { |
| 71 | { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, | 87 | id: "u1", |
| 88 | role: "user", | ||
| 89 | createdAt: "2026-01-01T00:00:00Z", | ||
| 90 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 91 | streaming: false, | ||
| 92 | }, | ||
| 93 | { | ||
| 94 | id: "a1", | ||
| 95 | role: "assistant", | ||
| 96 | createdAt: "2026-01-01T00:00:30Z", | ||
| 97 | updatedAt: "2026-01-01T00:00:40Z", | ||
| 98 | streaming: true, | ||
| 99 | }, | ||
| 72 | { | 100 | { |
| 73 | id: "a2", | 101 | id: "a2", |
| 74 | role: "assistant", | 102 | role: "assistant", |
| 75 | createdAt: "2026-01-01T00:00:55Z", | 103 | createdAt: "2026-01-01T00:00:55Z", |
| 76 | completedAt: "2026-01-01T00:00:55Z", | 104 | updatedAt: "2026-01-01T00:00:55Z", |
| 105 | streaming: false, | ||
| 77 | }, | 106 | }, |
| 78 | ]); | 107 | ]); |
| 79 | 108 | ||
| ... | @@ -88,19 +117,33 @@ describe("computeMessageDurationStart", () => { | ... | @@ -88,19 +117,33 @@ describe("computeMessageDurationStart", () => { |
| 88 | 117 | ||
| 89 | it("resets the boundary on a new user message", () => { | 118 | it("resets the boundary on a new user message", () => { |
| 90 | const result = computeMessageDurationStart([ | 119 | const result = computeMessageDurationStart([ |
| 91 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | 120 | { |
| 121 | id: "u1", | ||
| 122 | role: "user", | ||
| 123 | createdAt: "2026-01-01T00:00:00Z", | ||
| 124 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 125 | streaming: false, | ||
| 126 | }, | ||
| 92 | { | 127 | { |
| 93 | id: "a1", | 128 | id: "a1", |
| 94 | role: "assistant", | 129 | role: "assistant", |
| 95 | createdAt: "2026-01-01T00:00:30Z", | 130 | createdAt: "2026-01-01T00:00:30Z", |
| 96 | completedAt: "2026-01-01T00:00:30Z", | 131 | updatedAt: "2026-01-01T00:00:30Z", |
| 132 | streaming: false, | ||
| 133 | }, | ||
| 134 | { | ||
| 135 | id: "u2", | ||
| 136 | role: "user", | ||
| 137 | createdAt: "2026-01-01T00:01:00Z", | ||
| 138 | updatedAt: "2026-01-01T00:01:00Z", | ||
| 139 | streaming: false, | ||
| 97 | }, | 140 | }, |
| 98 | { id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" }, | ||
| 99 | { | 141 | { |
| 100 | id: "a2", | 142 | id: "a2", |
| 101 | role: "assistant", | 143 | role: "assistant", |
| 102 | createdAt: "2026-01-01T00:01:20Z", | 144 | createdAt: "2026-01-01T00:01:20Z", |
| 103 | completedAt: "2026-01-01T00:01:20Z", | 145 | updatedAt: "2026-01-01T00:01:20Z", |
| 146 | streaming: false, | ||
| 104 | }, | 147 | }, |
| 105 | ]); | 148 | ]); |
| 106 | 149 | ||
| ... | @@ -116,13 +159,26 @@ describe("computeMessageDurationStart", () => { | ... | @@ -116,13 +159,26 @@ describe("computeMessageDurationStart", () => { |
| 116 | 159 | ||
| 117 | it("handles system messages without affecting the boundary", () => { | 160 | it("handles system messages without affecting the boundary", () => { |
| 118 | const result = computeMessageDurationStart([ | 161 | const result = computeMessageDurationStart([ |
| 119 | { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, | 162 | { |
| 120 | { id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" }, | 163 | id: "u1", |
| 164 | role: "user", | ||
| 165 | createdAt: "2026-01-01T00:00:00Z", | ||
| 166 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 167 | streaming: false, | ||
| 168 | }, | ||
| 169 | { | ||
| 170 | id: "s1", | ||
| 171 | role: "system", | ||
| 172 | createdAt: "2026-01-01T00:00:01Z", | ||
| 173 | updatedAt: "2026-01-01T00:00:01Z", | ||
| 174 | streaming: false, | ||
| 175 | }, | ||
| 121 | { | 176 | { |
| 122 | id: "a1", | 177 | id: "a1", |
| 123 | role: "assistant", | 178 | role: "assistant", |
| 124 | createdAt: "2026-01-01T00:00:30Z", | 179 | createdAt: "2026-01-01T00:00:30Z", |
| 125 | completedAt: "2026-01-01T00:00:30Z", | 180 | updatedAt: "2026-01-01T00:00:30Z", |
| 181 | streaming: false, | ||
| 126 | }, | 182 | }, |
| 127 | ]); | 183 | ]); |
| 128 | 184 | ||
| ... | @@ -218,6 +274,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -218,6 +274,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 218 | text: "Write a poem", | 274 | text: "Write a poem", |
| 219 | turnId: null, | 275 | turnId: null, |
| 220 | createdAt: "2026-01-01T00:00:00Z", | 276 | createdAt: "2026-01-01T00:00:00Z", |
| 277 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 221 | streaming: false, | 278 | streaming: false, |
| 222 | }, | 279 | }, |
| 223 | }, | 280 | }, |
| ... | @@ -231,7 +288,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -231,7 +288,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 231 | text: "I should ground this first.", | 288 | text: "I should ground this first.", |
| 232 | turnId: "turn-1" as never, | 289 | turnId: "turn-1" as never, |
| 233 | createdAt: "2026-01-01T00:00:10Z", | 290 | createdAt: "2026-01-01T00:00:10Z", |
| 234 | completedAt: "2026-01-01T00:00:11Z", | 291 | updatedAt: "2026-01-01T00:00:11Z", |
| 235 | streaming: false, | 292 | streaming: false, |
| 236 | }, | 293 | }, |
| 237 | }, | 294 | }, |
| ... | @@ -245,7 +302,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -245,7 +302,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 245 | text: "Here is the poem.", | 302 | text: "Here is the poem.", |
| 246 | turnId: "turn-1" as never, | 303 | turnId: "turn-1" as never, |
| 247 | createdAt: "2026-01-01T00:00:20Z", | 304 | createdAt: "2026-01-01T00:00:20Z", |
| 248 | completedAt: "2026-01-01T00:00:30Z", | 305 | updatedAt: "2026-01-01T00:00:30Z", |
| 249 | streaming: false, | 306 | streaming: false, |
| 250 | }, | 307 | }, |
| 251 | }, | 308 | }, |
| ... | @@ -280,7 +337,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -280,7 +337,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 280 | text: "Earlier response.", | 337 | text: "Earlier response.", |
| 281 | turnId: "turn-1" as never, | 338 | turnId: "turn-1" as never, |
| 282 | createdAt: "2026-01-01T00:00:10Z", | 339 | createdAt: "2026-01-01T00:00:10Z", |
| 283 | completedAt: "2026-01-01T00:00:11Z", | 340 | updatedAt: "2026-01-01T00:00:11Z", |
| 284 | streaming: false, | 341 | streaming: false, |
| 285 | }, | 342 | }, |
| 286 | }, | 343 | }, |
| ... | @@ -294,7 +351,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -294,7 +351,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 294 | text: "Active response.", | 351 | text: "Active response.", |
| 295 | turnId: "turn-2" as never, | 352 | turnId: "turn-2" as never, |
| 296 | createdAt: "2026-01-01T00:00:20Z", | 353 | createdAt: "2026-01-01T00:00:20Z", |
| 297 | completedAt: "2026-01-01T00:00:30Z", | 354 | updatedAt: "2026-01-01T00:00:30Z", |
| 298 | streaming: false, | 355 | streaming: false, |
| 299 | }, | 356 | }, |
| 300 | }, | 357 | }, |
| ... | @@ -326,7 +383,9 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -326,7 +383,9 @@ describe("deriveMessagesTimelineRows", () => { |
| 326 | completedAt: "2026-01-01T00:00:30Z", | 383 | completedAt: "2026-01-01T00:00:30Z", |
| 327 | assistantMessageId: "assistant-1" as never, | 384 | assistantMessageId: "assistant-1" as never, |
| 328 | checkpointTurnCount: 2, | 385 | checkpointTurnCount: 2, |
| 329 | files: [{ path: "src/index.ts", additions: 3, deletions: 1 }], | 386 | checkpointRef: "checkpoint-1" as never, |
| 387 | status: "ready" as const, | ||
| 388 | files: [{ path: "src/index.ts", kind: "modified", additions: 3, deletions: 1 }], | ||
| 330 | }; | 389 | }; |
| 331 | 390 | ||
| 332 | const rows = deriveMessagesTimelineRows({ | 391 | const rows = deriveMessagesTimelineRows({ |
| ... | @@ -341,6 +400,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -341,6 +400,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 341 | text: "Do the thing", | 400 | text: "Do the thing", |
| 342 | turnId: null, | 401 | turnId: null, |
| 343 | createdAt: "2026-01-01T00:00:00Z", | 402 | createdAt: "2026-01-01T00:00:00Z", |
| 403 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 344 | streaming: false, | 404 | streaming: false, |
| 345 | }, | 405 | }, |
| 346 | }, | 406 | }, |
| ... | @@ -354,7 +414,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -354,7 +414,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 354 | text: "Done", | 414 | text: "Done", |
| 355 | turnId: "turn-1" as never, | 415 | turnId: "turn-1" as never, |
| 356 | createdAt: "2026-01-01T00:00:20Z", | 416 | createdAt: "2026-01-01T00:00:20Z", |
| 357 | completedAt: "2026-01-01T00:00:30Z", | 417 | updatedAt: "2026-01-01T00:00:30Z", |
| 358 | streaming: false, | 418 | streaming: false, |
| 359 | }, | 419 | }, |
| 360 | }, | 420 | }, |
| ... | @@ -392,6 +452,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -392,6 +452,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 392 | text: "Build it", | 452 | text: "Build it", |
| 393 | turnId: null, | 453 | turnId: null, |
| 394 | createdAt: "2026-01-01T00:00:00Z", | 454 | createdAt: "2026-01-01T00:00:00Z", |
| 455 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 395 | streaming: false, | 456 | streaming: false, |
| 396 | }, | 457 | }, |
| 397 | }, | 458 | }, |
| ... | @@ -405,7 +466,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -405,7 +466,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 405 | text: "Looking around first.", | 466 | text: "Looking around first.", |
| 406 | turnId: "turn-1" as never, | 467 | turnId: "turn-1" as never, |
| 407 | createdAt: "2026-01-01T00:00:05Z", | 468 | createdAt: "2026-01-01T00:00:05Z", |
| 408 | completedAt: "2026-01-01T00:00:06Z", | 469 | updatedAt: "2026-01-01T00:00:06Z", |
| 409 | streaming: false, | 470 | streaming: false, |
| 410 | }, | 471 | }, |
| 411 | }, | 472 | }, |
| ... | @@ -431,7 +492,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -431,7 +492,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 431 | text: "Done", | 492 | text: "Done", |
| 432 | turnId: "turn-1" as never, | 493 | turnId: "turn-1" as never, |
| 433 | createdAt: "2026-01-01T00:00:20Z", | 494 | createdAt: "2026-01-01T00:00:20Z", |
| 434 | completedAt: "2026-01-01T00:00:22Z", | 495 | updatedAt: "2026-01-01T00:00:22Z", |
| 435 | streaming: false, | 496 | streaming: false, |
| 436 | }, | 497 | }, |
| 437 | }, | 498 | }, |
| ... | @@ -451,7 +512,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -451,7 +512,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 451 | ); | 512 | ); |
| 452 | expect(foldRow?.turnId).toBe("turn-1"); | 513 | expect(foldRow?.turnId).toBe("turn-1"); |
| 453 | expect(foldRow?.expanded).toBe(false); | 514 | expect(foldRow?.expanded).toBe(false); |
| 454 | // User message boundary (00:00:00) → terminal message completedAt (00:00:22). | 515 | // User message boundary (00:00:00) → terminal message updatedAt (00:00:22). |
| 455 | expect(foldRow?.label).toBe("Worked for 22s"); | 516 | expect(foldRow?.label).toBe("Worked for 22s"); |
| 456 | expect(collapsedRows.map((row) => row.id)).toEqual([ | 517 | expect(collapsedRows.map((row) => row.id)).toEqual([ |
| 457 | "user-entry", | 518 | "user-entry", |
| ... | @@ -484,7 +545,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -484,7 +545,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 484 | // A steer ends the previous turn early: its only message completes the | 545 | // A steer ends the previous turn early: its only message completes the |
| 485 | // instant it is created, and trailing work entries land after it. The | 546 | // instant it is created, and trailing work entries land after it. The |
| 486 | // fold duration must span from the user message that started the turn to | 547 | // fold duration must span from the user message that started the turn to |
| 487 | // the last entry, not message createdAt → message completedAt (~0ms). | 548 | // the last entry, not message createdAt → message updatedAt (~0ms). |
| 488 | const rows = deriveMessagesTimelineRows({ | 549 | const rows = deriveMessagesTimelineRows({ |
| 489 | timelineEntries: [ | 550 | timelineEntries: [ |
| 490 | { | 551 | { |
| ... | @@ -497,6 +558,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -497,6 +558,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 497 | text: "do it once more", | 558 | text: "do it once more", |
| 498 | turnId: null, | 559 | turnId: null, |
| 499 | createdAt: "2026-01-01T00:00:00Z", | 560 | createdAt: "2026-01-01T00:00:00Z", |
| 561 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 500 | streaming: false, | 562 | streaming: false, |
| 501 | }, | 563 | }, |
| 502 | }, | 564 | }, |
| ... | @@ -510,7 +572,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -510,7 +572,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 510 | text: "Kicking off call 1.", | 572 | text: "Kicking off call 1.", |
| 511 | turnId: "turn-1" as never, | 573 | turnId: "turn-1" as never, |
| 512 | createdAt: "2026-01-01T00:00:09Z", | 574 | createdAt: "2026-01-01T00:00:09Z", |
| 513 | completedAt: "2026-01-01T00:00:09Z", | 575 | updatedAt: "2026-01-01T00:00:09Z", |
| 514 | streaming: false, | 576 | streaming: false, |
| 515 | }, | 577 | }, |
| 516 | }, | 578 | }, |
| ... | @@ -536,6 +598,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -536,6 +598,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 536 | text: "actually do 15", | 598 | text: "actually do 15", |
| 537 | turnId: null, | 599 | turnId: null, |
| 538 | createdAt: "2026-01-01T00:00:14Z", | 600 | createdAt: "2026-01-01T00:00:14Z", |
| 601 | updatedAt: "2026-01-01T00:00:14Z", | ||
| 539 | streaming: false, | 602 | streaming: false, |
| 540 | }, | 603 | }, |
| 541 | }, | 604 | }, |
| ... | @@ -549,6 +612,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -549,6 +612,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 549 | text: "One down — adjusting.", | 612 | text: "One down — adjusting.", |
| 550 | turnId: "turn-2" as never, | 613 | turnId: "turn-2" as never, |
| 551 | createdAt: "2026-01-01T00:00:17Z", | 614 | createdAt: "2026-01-01T00:00:17Z", |
| 615 | updatedAt: "2026-01-01T00:00:17Z", | ||
| 552 | streaming: true, | 616 | streaming: true, |
| 553 | }, | 617 | }, |
| 554 | }, | 618 | }, |
| ... | @@ -639,7 +703,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -639,7 +703,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 639 | text: "Done", | 703 | text: "Done", |
| 640 | turnId: "turn-1" as never, | 704 | turnId: "turn-1" as never, |
| 641 | createdAt: "2026-01-01T00:00:20Z", | 705 | createdAt: "2026-01-01T00:00:20Z", |
| 642 | completedAt: "2026-01-01T00:00:22Z", | 706 | updatedAt: "2026-01-01T00:00:22Z", |
| 643 | streaming: false, | 707 | streaming: false, |
| 644 | }, | 708 | }, |
| 645 | }, | 709 | }, |
| ... | @@ -653,6 +717,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -653,6 +717,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 653 | text: "yooo", | 717 | text: "yooo", |
| 654 | turnId: null, | 718 | turnId: null, |
| 655 | createdAt: "2026-01-01T00:01:00Z", | 719 | createdAt: "2026-01-01T00:01:00Z", |
| 720 | updatedAt: "2026-01-01T00:01:00Z", | ||
| 656 | streaming: false, | 721 | streaming: false, |
| 657 | }, | 722 | }, |
| 658 | }, | 723 | }, |
| ... | @@ -692,7 +757,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -692,7 +757,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 692 | text: "Working on it.", | 757 | text: "Working on it.", |
| 693 | turnId: "turn-1" as never, | 758 | turnId: "turn-1" as never, |
| 694 | createdAt: "2026-01-01T00:00:05Z", | 759 | createdAt: "2026-01-01T00:00:05Z", |
| 695 | completedAt: "2026-01-01T00:00:06Z", | 760 | updatedAt: "2026-01-01T00:00:06Z", |
| 696 | streaming: false, | 761 | streaming: false, |
| 697 | }, | 762 | }, |
| 698 | }, | 763 | }, |
| ... | @@ -742,7 +807,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -742,7 +807,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 742 | text: "Checking first.", | 807 | text: "Checking first.", |
| 743 | turnId: "turn-1" as never, | 808 | turnId: "turn-1" as never, |
| 744 | createdAt: "2026-01-01T00:00:10Z", | 809 | createdAt: "2026-01-01T00:00:10Z", |
| 745 | completedAt: "2026-01-01T00:00:11Z", | 810 | updatedAt: "2026-01-01T00:00:11Z", |
| 746 | streaming: false, | 811 | streaming: false, |
| 747 | }, | 812 | }, |
| 748 | }, | 813 | }, |
| ... | @@ -756,7 +821,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -756,7 +821,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 756 | text: "Done.", | 821 | text: "Done.", |
| 757 | turnId: "turn-1" as never, | 822 | turnId: "turn-1" as never, |
| 758 | createdAt: "2026-01-01T00:00:20Z", | 823 | createdAt: "2026-01-01T00:00:20Z", |
| 759 | completedAt: "2026-01-01T00:00:30Z", | 824 | updatedAt: "2026-01-01T00:00:30Z", |
| 760 | streaming: false, | 825 | streaming: false, |
| 761 | }, | 826 | }, |
| 762 | }, | 827 | }, |
| ... | @@ -789,7 +854,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -789,7 +854,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 789 | text: "Working on it.", | 854 | text: "Working on it.", |
| 790 | turnId: "turn-1" as never, | 855 | turnId: "turn-1" as never, |
| 791 | createdAt: "2026-01-01T00:00:10Z", | 856 | createdAt: "2026-01-01T00:00:10Z", |
| 792 | completedAt: "2026-01-01T00:00:11Z", | 857 | updatedAt: "2026-01-01T00:00:11Z", |
| 793 | streaming: false, | 858 | streaming: false, |
| 794 | }, | 859 | }, |
| 795 | }, | 860 | }, |
| ... | @@ -824,6 +889,7 @@ describe("computeStableMessagesTimelineRows", () => { | ... | @@ -824,6 +889,7 @@ describe("computeStableMessagesTimelineRows", () => { |
| 824 | text: "First", | 889 | text: "First", |
| 825 | turnId: null, | 890 | turnId: null, |
| 826 | createdAt: "2026-01-01T00:00:00Z", | 891 | createdAt: "2026-01-01T00:00:00Z", |
| 892 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 827 | streaming: false, | 893 | streaming: false, |
| 828 | }; | 894 | }; |
| 829 | const secondUserMessage = { | 895 | const secondUserMessage = { |
| ... | @@ -832,6 +898,7 @@ describe("computeStableMessagesTimelineRows", () => { | ... | @@ -832,6 +898,7 @@ describe("computeStableMessagesTimelineRows", () => { |
| 832 | text: "Second", | 898 | text: "Second", |
| 833 | turnId: null, | 899 | turnId: null, |
| 834 | createdAt: "2026-01-01T00:00:10Z", | 900 | createdAt: "2026-01-01T00:00:10Z", |
| 901 | updatedAt: "2026-01-01T00:00:10Z", | ||
| 835 | streaming: false, | 902 | streaming: false, |
| 836 | }; | 903 | }; |
| 837 | 904 | ||
| ... | @@ -927,6 +994,7 @@ describe("computeStableMessagesTimelineRows", () => { | ... | @@ -927,6 +994,7 @@ describe("computeStableMessagesTimelineRows", () => { |
| 927 | text: "First", | 994 | text: "First", |
| 928 | turnId: null, | 995 | turnId: null, |
| 929 | createdAt: "2026-01-01T00:00:00Z", | 996 | createdAt: "2026-01-01T00:00:00Z", |
| 997 | updatedAt: "2026-01-01T00:00:00Z", | ||
| 930 | streaming: false, | 998 | streaming: false, |
| 931 | }; | 999 | }; |
| 932 | const secondUserMessage = { | 1000 | const secondUserMessage = { |
| ... | @@ -935,6 +1003,7 @@ describe("computeStableMessagesTimelineRows", () => { | ... | @@ -935,6 +1003,7 @@ describe("computeStableMessagesTimelineRows", () => { |
| 935 | text: "Second", | 1003 | text: "Second", |
| 936 | turnId: null, | 1004 | turnId: null, |
| 937 | createdAt: "2026-01-01T00:00:10Z", | 1005 | createdAt: "2026-01-01T00:00:10Z", |
| 1006 | updatedAt: "2026-01-01T00:00:10Z", | ||
| 938 | streaming: false, | 1007 | streaming: false, |
| 939 | }; | 1008 | }; |
| 940 | 1009 |
apps/web/src/components/chat/MessagesTimeline.logic.ts+6-7| ... | @@ -26,7 +26,8 @@ export interface TimelineDurationMessage { | ... | @@ -26,7 +26,8 @@ export interface TimelineDurationMessage { |
| 26 | id: string; | 26 | id: string; |
| 27 | role: "user" | "assistant" | "system"; | 27 | role: "user" | "assistant" | "system"; |
| 28 | createdAt: string; | 28 | createdAt: string; |
| 29 | completedAt?: string | undefined; | 29 | updatedAt: string; |
| 30 | streaming: boolean; | ||
| 30 | } | 31 | } |
| 31 | 32 | ||
| 32 | export type TimelineLatestTurn = Pick< | 33 | export type TimelineLatestTurn = Pick< |
| ... | @@ -85,8 +86,8 @@ export function computeMessageDurationStart( | ... | @@ -85,8 +86,8 @@ export function computeMessageDurationStart( |
| 85 | lastBoundary = message.createdAt; | 86 | lastBoundary = message.createdAt; |
| 86 | } | 87 | } |
| 87 | result.set(message.id, lastBoundary ?? message.createdAt); | 88 | result.set(message.id, lastBoundary ?? message.createdAt); |
| 88 | if (message.role === "assistant" && message.completedAt) { | 89 | if (message.role === "assistant" && !message.streaming) { |
| 89 | lastBoundary = message.completedAt; | 90 | lastBoundary = message.updatedAt; |
| 90 | } | 91 | } |
| 91 | } | 92 | } |
| 92 | 93 | ||
| ... | @@ -256,9 +257,7 @@ function deriveTurnFolds(input: { | ... | @@ -256,9 +257,7 @@ function deriveTurnFolds(input: { |
| 256 | // A turn cut short by a steer leaves trailing work entries behind its | 257 | // A turn cut short by a steer leaves trailing work entries behind its |
| 257 | // terminal message — take whichever ended last. | 258 | // terminal message — take whichever ended last. |
| 258 | const lastEntryEnd = | 259 | const lastEntryEnd = |
| 259 | lastEntry.kind === "message" | 260 | lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; |
| 260 | ? (lastEntry.message.completedAt ?? lastEntry.createdAt) | ||
| 261 | : lastEntry.createdAt; | ||
| 262 | const elapsedMs = | 261 | const elapsedMs = |
| 263 | input.latestTurn?.turnId === turnId && | 262 | input.latestTurn?.turnId === turnId && |
| 264 | input.latestTurn.startedAt && | 263 | input.latestTurn.startedAt && |
| ... | @@ -266,7 +265,7 @@ function deriveTurnFolds(input: { | ... | @@ -266,7 +265,7 @@ function deriveTurnFolds(input: { |
| 266 | ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt) | 265 | ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt) |
| 267 | : computeElapsedMs( | 266 | : computeElapsedMs( |
| 268 | group.startBoundary ?? firstEntry.createdAt, | 267 | group.startBoundary ?? firstEntry.createdAt, |
| 269 | maxIsoTimestamp(group.terminalEntry?.message.completedAt ?? null, lastEntryEnd) ?? | 268 | maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? |
| 270 | lastEntryEnd, | 269 | lastEntryEnd, |
| 271 | ); | 270 | ); |
| 272 | const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; | 271 | const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; |
apps/web/src/components/chat/MessagesTimeline.test.tsx+6| ... | @@ -128,7 +128,9 @@ function buildUserTimelineEntry(text: string) { | ... | @@ -128,7 +128,9 @@ function buildUserTimelineEntry(text: string) { |
| 128 | id: MessageId.make("message-1"), | 128 | id: MessageId.make("message-1"), |
| 129 | role: "user" as const, | 129 | role: "user" as const, |
| 130 | text, | 130 | text, |
| 131 | turnId: null, | ||
| 131 | createdAt: MESSAGE_CREATED_AT, | 132 | createdAt: MESSAGE_CREATED_AT, |
| 133 | updatedAt: MESSAGE_CREATED_AT, | ||
| 132 | streaming: false, | 134 | streaming: false, |
| 133 | }, | 135 | }, |
| 134 | }; | 136 | }; |
| ... | @@ -280,7 +282,9 @@ describe("MessagesTimeline", () => { | ... | @@ -280,7 +282,9 @@ describe("MessagesTimeline", () => { |
| 280 | "```", | 282 | "```", |
| 281 | "</review_comment>", | 283 | "</review_comment>", |
| 282 | ].join("\n"), | 284 | ].join("\n"), |
| 285 | turnId: null, | ||
| 283 | createdAt: "2026-03-17T19:12:28.000Z", | 286 | createdAt: "2026-03-17T19:12:28.000Z", |
| 287 | updatedAt: "2026-03-17T19:12:28.000Z", | ||
| 284 | streaming: false, | 288 | streaming: false, |
| 285 | }, | 289 | }, |
| 286 | }, | 290 | }, |
| ... | @@ -318,7 +322,9 @@ describe("MessagesTimeline", () => { | ... | @@ -318,7 +322,9 @@ describe("MessagesTimeline", () => { |
| 318 | "```", | 322 | "```", |
| 319 | "</review_comment>", | 323 | "</review_comment>", |
| 320 | ].join("\n"), | 324 | ].join("\n"), |
| 325 | turnId: null, | ||
| 321 | createdAt: "2026-03-17T19:12:28.000Z", | 326 | createdAt: "2026-03-17T19:12:28.000Z", |
| 327 | updatedAt: "2026-03-17T19:12:28.000Z", | ||
| 322 | streaming: false, | 328 | streaming: false, |
| 323 | }, | 329 | }, |
| 324 | }, | 330 | }, |
apps/web/src/components/chat/MessagesTimeline.tsx+3-9| ... | @@ -5,7 +5,7 @@ import { | ... | @@ -5,7 +5,7 @@ import { |
| 5 | type ServerProviderSkill, | 5 | type ServerProviderSkill, |
| 6 | type TurnId, | 6 | type TurnId, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { parseScopedThreadKey } from "@t3tools/client-runtime"; | 8 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 9 | import { | 9 | import { |
| 10 | createContext, | 10 | createContext, |
| 11 | Fragment, | 11 | Fragment, |
| ... | @@ -607,16 +607,10 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess | ... | @@ -607,16 +607,10 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 607 | <TooltipTrigger | 607 | <TooltipTrigger |
| 608 | render={<p className="text-muted-foreground text-xs tabular-nums" />} | 608 | render={<p className="text-muted-foreground text-xs tabular-nums" />} |
| 609 | > | 609 | > |
| 610 | {formatShortTimestamp( | 610 | {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} |
| 611 | row.message.completedAt ?? row.message.createdAt, | ||
| 612 | ctx.timestampFormat, | ||
| 613 | )} | ||
| 614 | </TooltipTrigger> | 611 | </TooltipTrigger> |
| 615 | <TooltipPopup> | 612 | <TooltipPopup> |
| 616 | {formatChatTimestampTooltip( | 613 | {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} |
| 617 | row.message.completedAt ?? row.message.createdAt, | ||
| 618 | ctx.timestampFormat, | ||
| 619 | )} | ||
| 620 | </TooltipPopup> | 614 | </TooltipPopup> |
| 621 | </Tooltip> | 615 | </Tooltip> |
| 622 | )} | 616 | )} |
apps/web/src/components/chat/ModelPickerContent.tsx+1-1| ... | @@ -113,7 +113,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -113,7 +113,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 113 | () => providedKeybindings ?? [], | 113 | () => providedKeybindings ?? [], |
| 114 | [providedKeybindings], | 114 | [providedKeybindings], |
| 115 | ); | 115 | ); |
| 116 | const { updateSettings } = useUpdateSettings(); | 116 | const updateSettings = useUpdateSettings(); |
| 117 | 117 | ||
| 118 | const focusSearchInput = useCallback(() => { | 118 | const focusSearchInput = useCallback(() => { |
| 119 | searchInputRef.current?.focus({ preventScroll: true }); | 119 | searchInputRef.current?.focus({ preventScroll: true }); |
apps/web/src/components/chat/OpenInPicker.tsx+32-10| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | 1 | import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useEffect, useMemo } from "react"; | 2 | import { memo, useCallback, useEffect, useMemo } from "react"; |
| 3 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; | 3 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; |
| 4 | import { usePreferredEditor } from "../../editorPreferences"; | 4 | import { usePreferredEditor } from "../../editorPreferences"; |
| ... | @@ -32,7 +32,8 @@ import { | ... | @@ -32,7 +32,8 @@ import { |
| 32 | WebStormIcon, | 32 | WebStormIcon, |
| 33 | } from "../JetBrainsIcons"; | 33 | } from "../JetBrainsIcons"; |
| 34 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 34 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 35 | import { readLocalApi } from "~/localApi"; | 35 | import { shellEnvironment } from "~/state/shell"; |
| 36 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 36 | 37 | ||
| 37 | const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => { | 38 | const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => { |
| 38 | const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ | 39 | const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ |
| ... | @@ -151,18 +152,21 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor | ... | @@ -151,18 +152,21 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor |
| 151 | }; | 152 | }; |
| 152 | 153 | ||
| 153 | export const OpenInPicker = memo(function OpenInPicker({ | 154 | export const OpenInPicker = memo(function OpenInPicker({ |
| 155 | environmentId, | ||
| 154 | keybindings, | 156 | keybindings, |
| 155 | availableEditors, | 157 | availableEditors, |
| 156 | openInCwd, | 158 | openInCwd, |
| 157 | compact = false, | 159 | compact = false, |
| 158 | enableShortcut = true, | 160 | enableShortcut = true, |
| 159 | }: { | 161 | }: { |
| 162 | environmentId: EnvironmentId; | ||
| 160 | keybindings: ResolvedKeybindingsConfig; | 163 | keybindings: ResolvedKeybindingsConfig; |
| 161 | availableEditors: ReadonlyArray<EditorId>; | 164 | availableEditors: ReadonlyArray<EditorId>; |
| 162 | openInCwd: string | null; | 165 | openInCwd: string | null; |
| 163 | compact?: boolean; | 166 | compact?: boolean; |
| 164 | enableShortcut?: boolean; | 167 | enableShortcut?: boolean; |
| 165 | }) { | 168 | }) { |
| 169 | const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); | ||
| 166 | const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); | 170 | const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); |
| 167 | const options = useMemo( | 171 | const options = useMemo( |
| 168 | () => resolveOptions(navigator.platform, availableEditors), | 172 | () => resolveOptions(navigator.platform, availableEditors), |
| ... | @@ -172,14 +176,20 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -172,14 +176,20 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 172 | 176 | ||
| 173 | const openInEditor = useCallback( | 177 | const openInEditor = useCallback( |
| 174 | (editorId: EditorId | null) => { | 178 | (editorId: EditorId | null) => { |
| 175 | const api = readLocalApi(); | 179 | if (!openInCwd) return; |
| 176 | if (!api || !openInCwd) return; | ||
| 177 | const editor = editorId ?? preferredEditor; | 180 | const editor = editorId ?? preferredEditor; |
| 178 | if (!editor) return; | 181 | if (!editor) return; |
| 179 | void api.shell.openInEditor(openInCwd, editor); | 182 | const result = openInEditorMutation({ |
| 183 | environmentId, | ||
| 184 | input: { | ||
| 185 | cwd: openInCwd, | ||
| 186 | editor, | ||
| 187 | }, | ||
| 188 | }); | ||
| 180 | setPreferredEditor(editor); | 189 | setPreferredEditor(editor); |
| 190 | return result; | ||
| 181 | }, | 191 | }, |
| 182 | [preferredEditor, openInCwd, setPreferredEditor], | 192 | [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], |
| 183 | ); | 193 | ); |
| 184 | 194 | ||
| 185 | const openFavoriteEditorShortcutLabel = useMemo( | 195 | const openFavoriteEditorShortcutLabel = useMemo( |
| ... | @@ -190,17 +200,29 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -190,17 +200,29 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 190 | useEffect(() => { | 200 | useEffect(() => { |
| 191 | if (!enableShortcut) return; | 201 | if (!enableShortcut) return; |
| 192 | const handler = (e: globalThis.KeyboardEvent) => { | 202 | const handler = (e: globalThis.KeyboardEvent) => { |
| 193 | const api = readLocalApi(); | ||
| 194 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; | 203 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 195 | if (!api || !openInCwd) return; | 204 | if (!openInCwd) return; |
| 196 | if (!preferredEditor) return; | 205 | if (!preferredEditor) return; |
| 197 | 206 | ||
| 198 | e.preventDefault(); | 207 | e.preventDefault(); |
| 199 | void api.shell.openInEditor(openInCwd, preferredEditor); | 208 | void openInEditorMutation({ |
| 209 | environmentId, | ||
| 210 | input: { | ||
| 211 | cwd: openInCwd, | ||
| 212 | editor: preferredEditor, | ||
| 213 | }, | ||
| 214 | }); | ||
| 200 | }; | 215 | }; |
| 201 | window.addEventListener("keydown", handler); | 216 | window.addEventListener("keydown", handler); |
| 202 | return () => window.removeEventListener("keydown", handler); | 217 | return () => window.removeEventListener("keydown", handler); |
| 203 | }, [enableShortcut, preferredEditor, keybindings, openInCwd]); | 218 | }, [ |
| 219 | enableShortcut, | ||
| 220 | environmentId, | ||
| 221 | keybindings, | ||
| 222 | openInCwd, | ||
| 223 | openInEditorMutation, | ||
| 224 | preferredEditor, | ||
| 225 | ]); | ||
| 204 | 226 | ||
| 205 | return ( | 227 | return ( |
| 206 | <Group aria-label="Open in editor"> | 228 | <Group aria-label="Open in editor"> |
apps/web/src/components/chat/ProposedPlanCard.tsx+28-22| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { memo, useState, useId } from "react"; | 1 | import { memo, useState, useId } from "react"; |
| 2 | import { | ||
| 3 | isAtomCommandInterrupted, | ||
| 4 | squashAtomCommandFailure, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | 6 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { | 7 | import { |
| 4 | buildCollapsedProposedPlanPreviewMarkdown, | 8 | buildCollapsedProposedPlanPreviewMarkdown, |
| ... | @@ -25,8 +29,9 @@ import { | ... | @@ -25,8 +29,9 @@ import { |
| 25 | DialogTitle, | 29 | DialogTitle, |
| 26 | } from "../ui/dialog"; | 30 | } from "../ui/dialog"; |
| 27 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 31 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 28 | import { readEnvironmentApi } from "~/environmentApi"; | 32 | import { projectEnvironment } from "~/state/projects"; |
| 29 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 33 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 30 | 35 | ||
| 31 | export const ProposedPlanCard = memo(function ProposedPlanCard({ | 36 | export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 32 | planMarkdown, | 37 | planMarkdown, |
| ... | @@ -45,6 +50,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -45,6 +50,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 45 | const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); | 50 | const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); |
| 46 | const [savePath, setSavePath] = useState(""); | 51 | const [savePath, setSavePath] = useState(""); |
| 47 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); | 52 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); |
| 53 | const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { | ||
| 54 | reportFailure: false, | ||
| 55 | }); | ||
| 48 | const { copyToClipboard, isCopied } = useCopyToClipboard({ | 56 | const { copyToClipboard, isCopied } = useCopyToClipboard({ |
| 49 | onError: (error) => { | 57 | onError: (error) => { |
| 50 | toastManager.add( | 58 | toastManager.add( |
| ... | @@ -91,9 +99,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -91,9 +99,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 91 | }; | 99 | }; |
| 92 | 100 | ||
| 93 | const handleSaveToWorkspace = () => { | 101 | const handleSaveToWorkspace = () => { |
| 94 | const api = readEnvironmentApi(environmentId); | ||
| 95 | const relativePath = savePath.trim(); | 102 | const relativePath = savePath.trim(); |
| 96 | if (!api || !workspaceRoot) { | 103 | if (!workspaceRoot) { |
| 97 | return; | 104 | return; |
| 98 | } | 105 | } |
| 99 | if (!relativePath) { | 106 | if (!relativePath) { |
| ... | @@ -105,21 +112,27 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -105,21 +112,27 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 105 | } | 112 | } |
| 106 | 113 | ||
| 107 | setIsSavingToWorkspace(true); | 114 | setIsSavingToWorkspace(true); |
| 108 | void api.projects | 115 | void (async () => { |
| 109 | .writeFile({ | 116 | const result = await writeProjectFile({ |
| 110 | cwd: workspaceRoot, | 117 | environmentId, |
| 111 | relativePath, | 118 | input: { |
| 112 | contents: saveContents, | 119 | cwd: workspaceRoot, |
| 113 | }) | 120 | relativePath, |
| 114 | .then((result) => { | 121 | contents: saveContents, |
| 122 | }, | ||
| 123 | }); | ||
| 124 | setIsSavingToWorkspace(false); | ||
| 125 | if (result._tag === "Success") { | ||
| 115 | setIsSaveDialogOpen(false); | 126 | setIsSaveDialogOpen(false); |
| 116 | toastManager.add({ | 127 | toastManager.add({ |
| 117 | type: "success", | 128 | type: "success", |
| 118 | title: "Plan saved to workspace", | 129 | title: "Plan saved to workspace", |
| 119 | description: result.relativePath, | 130 | description: result.value.relativePath, |
| 120 | }); | 131 | }); |
| 121 | }) | 132 | return; |
| 122 | .catch((error) => { | 133 | } |
| 134 | if (!isAtomCommandInterrupted(result)) { | ||
| 135 | const error = squashAtomCommandFailure(result); | ||
| 123 | toastManager.add( | 136 | toastManager.add( |
| 124 | stackedThreadToast({ | 137 | stackedThreadToast({ |
| 125 | type: "error", | 138 | type: "error", |
| ... | @@ -127,15 +140,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -127,15 +140,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 127 | description: error instanceof Error ? error.message : "An error occurred while saving.", | 140 | description: error instanceof Error ? error.message : "An error occurred while saving.", |
| 128 | }), | 141 | }), |
| 129 | ); | 142 | ); |
| 130 | }) | 143 | } |
| 131 | .then( | 144 | })(); |
| 132 | () => { | ||
| 133 | setIsSavingToWorkspace(false); | ||
| 134 | }, | ||
| 135 | () => { | ||
| 136 | setIsSavingToWorkspace(false); | ||
| 137 | }, | ||
| 138 | ); | ||
| 139 | }; | 145 | }; |
| 140 | 146 | ||
| 141 | return ( | 147 | return ( |
apps/web/src/components/chat/ProviderModelPicker.browser.tsx deleted-1316| ... | @@ -1,1316 +0,0 @@ | ||
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 4 | import { page, userEvent } from "vite-plus/test/browser"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | import { render } from "vitest-browser-react"; | ||
| 7 | |||
| 8 | import { ProviderModelPicker } from "./ProviderModelPicker"; | ||
| 9 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; | ||
| 10 | import { | ||
| 11 | deriveProviderInstanceEntries, | ||
| 12 | sortProviderInstanceEntries, | ||
| 13 | } from "../../providerInstances"; | ||
| 14 | import type { ModelEsque } from "./providerIconUtils"; | ||
| 15 | import { | ||
| 16 | DEFAULT_CLIENT_SETTINGS, | ||
| 17 | DEFAULT_UNIFIED_SETTINGS, | ||
| 18 | type UnifiedSettings, | ||
| 19 | } from "@t3tools/contracts/settings"; | ||
| 20 | import { __resetLocalApiForTests } from "../../localApi"; | ||
| 21 | |||
| 22 | // Mock the environments/runtime module to provide a mock primary environment connection | ||
| 23 | vi.mock("../../environments/runtime", () => { | ||
| 24 | const primaryConnection = { | ||
| 25 | kind: "primary" as const, | ||
| 26 | knownEnvironment: { | ||
| 27 | id: "environment-local", | ||
| 28 | label: "Local environment", | ||
| 29 | source: "manual" as const, | ||
| 30 | environmentId: EnvironmentId.make("environment-local"), | ||
| 31 | target: { | ||
| 32 | httpBaseUrl: "http://localhost:3000", | ||
| 33 | wsBaseUrl: "ws://localhost:3000", | ||
| 34 | }, | ||
| 35 | }, | ||
| 36 | environmentId: EnvironmentId.make("environment-local"), | ||
| 37 | client: { | ||
| 38 | server: { | ||
| 39 | getConfig: vi.fn(), | ||
| 40 | updateSettings: vi.fn(), | ||
| 41 | }, | ||
| 42 | }, | ||
| 43 | ensureBootstrapped: async () => undefined, | ||
| 44 | reconnect: async () => undefined, | ||
| 45 | dispose: async () => undefined, | ||
| 46 | }; | ||
| 47 | |||
| 48 | return { | ||
| 49 | getEnvironmentHttpBaseUrl: () => "http://localhost:3000", | ||
| 50 | getSavedEnvironmentRecord: () => null, | ||
| 51 | getSavedEnvironmentRuntimeState: () => null, | ||
| 52 | hasSavedEnvironmentRegistryHydrated: () => true, | ||
| 53 | listSavedEnvironmentRecords: () => [], | ||
| 54 | resetSavedEnvironmentRegistryStoreForTests: vi.fn(), | ||
| 55 | resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), | ||
| 56 | resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => | ||
| 57 | new URL(path, "http://localhost:3000").toString(), | ||
| 58 | waitForSavedEnvironmentRegistryHydration: async () => undefined, | ||
| 59 | addSavedEnvironment: vi.fn(), | ||
| 60 | disconnectSavedEnvironment: vi.fn(), | ||
| 61 | ensureEnvironmentConnectionBootstrapped: async () => undefined, | ||
| 62 | getPrimaryEnvironmentConnection: () => primaryConnection, | ||
| 63 | readEnvironmentConnection: () => primaryConnection, | ||
| 64 | reconnectSavedEnvironment: vi.fn(), | ||
| 65 | removeSavedEnvironment: vi.fn(), | ||
| 66 | requireEnvironmentConnection: () => primaryConnection, | ||
| 67 | resetEnvironmentServiceForTests: vi.fn(), | ||
| 68 | startEnvironmentConnectionService: vi.fn(), | ||
| 69 | subscribeEnvironmentConnections: () => () => {}, | ||
| 70 | useSavedEnvironmentRegistryStore: ( | ||
| 71 | selector: (state: { byId: Record<string, never> }) => unknown, | ||
| 72 | ) => selector({ byId: {} }), | ||
| 73 | useSavedEnvironmentRuntimeStore: ( | ||
| 74 | selector: (state: { byId: Record<string, never> }) => unknown, | ||
| 75 | ) => selector({ byId: {} }), | ||
| 76 | }; | ||
| 77 | }); | ||
| 78 | |||
| 79 | function selectDescriptor( | ||
| 80 | id: string, | ||
| 81 | label: string, | ||
| 82 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | ||
| 83 | ) { | ||
| 84 | return { | ||
| 85 | id, | ||
| 86 | label, | ||
| 87 | type: "select" as const, | ||
| 88 | options: [...options], | ||
| 89 | ...(options.find((option) => option.isDefault)?.id | ||
| 90 | ? { currentValue: options.find((option) => option.isDefault)?.id } | ||
| 91 | : {}), | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 95 | function booleanDescriptor(id: string, label: string) { | ||
| 96 | return { | ||
| 97 | id, | ||
| 98 | label, | ||
| 99 | type: "boolean" as const, | ||
| 100 | }; | ||
| 101 | } | ||
| 102 | |||
| 103 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | ||
| 104 | { | ||
| 105 | driver: ProviderDriverKind.make("codex"), | ||
| 106 | instanceId: ProviderInstanceId.make("codex"), | ||
| 107 | displayName: "Codex", | ||
| 108 | enabled: true, | ||
| 109 | installed: true, | ||
| 110 | version: "0.116.0", | ||
| 111 | status: "ready", | ||
| 112 | auth: { status: "authenticated" }, | ||
| 113 | checkedAt: new Date().toISOString(), | ||
| 114 | slashCommands: [], | ||
| 115 | skills: [], | ||
| 116 | models: [ | ||
| 117 | { | ||
| 118 | slug: "gpt-5-codex", | ||
| 119 | name: "GPT-5 Codex", | ||
| 120 | isCustom: false, | ||
| 121 | capabilities: createModelCapabilities({ | ||
| 122 | optionDescriptors: [ | ||
| 123 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 124 | { id: "low", label: "low" }, | ||
| 125 | { id: "medium", label: "medium", isDefault: true }, | ||
| 126 | { id: "high", label: "high" }, | ||
| 127 | ]), | ||
| 128 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 129 | ], | ||
| 130 | }), | ||
| 131 | }, | ||
| 132 | { | ||
| 133 | slug: "gpt-5.3-codex", | ||
| 134 | name: "GPT-5.3 Codex", | ||
| 135 | isCustom: false, | ||
| 136 | capabilities: createModelCapabilities({ | ||
| 137 | optionDescriptors: [ | ||
| 138 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 139 | { id: "low", label: "low" }, | ||
| 140 | { id: "medium", label: "medium", isDefault: true }, | ||
| 141 | { id: "high", label: "high" }, | ||
| 142 | ]), | ||
| 143 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 144 | ], | ||
| 145 | }), | ||
| 146 | }, | ||
| 147 | ], | ||
| 148 | }, | ||
| 149 | { | ||
| 150 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 151 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 152 | displayName: "Claude", | ||
| 153 | enabled: true, | ||
| 154 | installed: true, | ||
| 155 | version: "1.0.0", | ||
| 156 | status: "ready", | ||
| 157 | auth: { status: "authenticated" }, | ||
| 158 | checkedAt: new Date().toISOString(), | ||
| 159 | slashCommands: [], | ||
| 160 | skills: [], | ||
| 161 | models: [ | ||
| 162 | { | ||
| 163 | slug: "claude-opus-4-6", | ||
| 164 | name: "Claude Opus 4.6", | ||
| 165 | isCustom: false, | ||
| 166 | capabilities: createModelCapabilities({ | ||
| 167 | optionDescriptors: [ | ||
| 168 | selectDescriptor("effort", "Reasoning", [ | ||
| 169 | { id: "low", label: "low" }, | ||
| 170 | { id: "medium", label: "medium", isDefault: true }, | ||
| 171 | { id: "high", label: "high" }, | ||
| 172 | { id: "max", label: "max" }, | ||
| 173 | ]), | ||
| 174 | booleanDescriptor("thinking", "Thinking"), | ||
| 175 | ], | ||
| 176 | }), | ||
| 177 | }, | ||
| 178 | { | ||
| 179 | slug: "claude-sonnet-4-6", | ||
| 180 | name: "Claude Sonnet 4.6", | ||
| 181 | isCustom: false, | ||
| 182 | capabilities: createModelCapabilities({ | ||
| 183 | optionDescriptors: [ | ||
| 184 | selectDescriptor("effort", "Reasoning", [ | ||
| 185 | { id: "low", label: "low" }, | ||
| 186 | { id: "medium", label: "medium", isDefault: true }, | ||
| 187 | { id: "high", label: "high" }, | ||
| 188 | { id: "max", label: "max" }, | ||
| 189 | ]), | ||
| 190 | booleanDescriptor("thinking", "Thinking"), | ||
| 191 | ], | ||
| 192 | }), | ||
| 193 | }, | ||
| 194 | { | ||
| 195 | slug: "claude-haiku-4-5", | ||
| 196 | name: "Claude Haiku 4.5", | ||
| 197 | isCustom: false, | ||
| 198 | capabilities: createModelCapabilities({ | ||
| 199 | optionDescriptors: [ | ||
| 200 | selectDescriptor("effort", "Reasoning", [ | ||
| 201 | { id: "low", label: "low" }, | ||
| 202 | { id: "medium", label: "medium", isDefault: true }, | ||
| 203 | { id: "high", label: "high" }, | ||
| 204 | ]), | ||
| 205 | booleanDescriptor("thinking", "Thinking"), | ||
| 206 | ], | ||
| 207 | }), | ||
| 208 | }, | ||
| 209 | ], | ||
| 210 | }, | ||
| 211 | ]; | ||
| 212 | |||
| 213 | const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); | ||
| 214 | const CLAUDE_INSTANCE_ID = ProviderInstanceId.make("claudeAgent"); | ||
| 215 | const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); | ||
| 216 | |||
| 217 | function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { | ||
| 218 | return { | ||
| 219 | driver: ProviderDriverKind.make("codex"), | ||
| 220 | instanceId: ProviderInstanceId.make("codex"), | ||
| 221 | displayName: "Codex", | ||
| 222 | enabled: true, | ||
| 223 | installed: true, | ||
| 224 | version: "0.116.0", | ||
| 225 | status: "ready", | ||
| 226 | auth: { status: "authenticated" }, | ||
| 227 | checkedAt: new Date().toISOString(), | ||
| 228 | models, | ||
| 229 | slashCommands: [], | ||
| 230 | skills: [], | ||
| 231 | }; | ||
| 232 | } | ||
| 233 | |||
| 234 | function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider { | ||
| 235 | return { | ||
| 236 | driver: ProviderDriverKind.make("opencode"), | ||
| 237 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 238 | enabled: true, | ||
| 239 | installed: true, | ||
| 240 | version: "1.0.0", | ||
| 241 | status: "ready", | ||
| 242 | auth: { status: "authenticated" }, | ||
| 243 | checkedAt: new Date().toISOString(), | ||
| 244 | models, | ||
| 245 | slashCommands: [], | ||
| 246 | skills: [], | ||
| 247 | }; | ||
| 248 | } | ||
| 249 | |||
| 250 | async function mountPicker(props: { | ||
| 251 | activeInstanceId?: ProviderInstanceId; | ||
| 252 | model: string; | ||
| 253 | lockedProvider: ProviderDriverKind | null; | ||
| 254 | lockedContinuationGroupKey?: string | null; | ||
| 255 | providers?: ReadonlyArray<ServerProvider>; | ||
| 256 | settings?: UnifiedSettings; | ||
| 257 | triggerVariant?: "ghost" | "outline"; | ||
| 258 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; | ||
| 259 | }) { | ||
| 260 | const host = document.createElement("div"); | ||
| 261 | document.body.append(host); | ||
| 262 | const onInstanceModelChange = vi.fn(); | ||
| 263 | const providers = props.providers ?? TEST_PROVIDERS; | ||
| 264 | const instanceEntries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); | ||
| 265 | const activeInstanceId = props.activeInstanceId ?? CODEX_INSTANCE_ID; | ||
| 266 | const modelOptionsByInstance = getCustomModelOptionsByInstance( | ||
| 267 | props.settings ?? DEFAULT_UNIFIED_SETTINGS, | ||
| 268 | providers, | ||
| 269 | activeInstanceId, | ||
| 270 | props.model, | ||
| 271 | ); | ||
| 272 | const screen = await render( | ||
| 273 | <ProviderModelPicker | ||
| 274 | activeInstanceId={activeInstanceId} | ||
| 275 | model={props.model} | ||
| 276 | lockedProvider={props.lockedProvider} | ||
| 277 | lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} | ||
| 278 | instanceEntries={instanceEntries} | ||
| 279 | modelOptionsByInstance={modelOptionsByInstance} | ||
| 280 | triggerVariant={props.triggerVariant} | ||
| 281 | {...(props.getModelDisabledReason | ||
| 282 | ? { getModelDisabledReason: props.getModelDisabledReason } | ||
| 283 | : {})} | ||
| 284 | onInstanceModelChange={onInstanceModelChange} | ||
| 285 | />, | ||
| 286 | { container: host }, | ||
| 287 | ); | ||
| 288 | |||
| 289 | return { | ||
| 290 | onInstanceModelChange, | ||
| 291 | // Back-compat alias used by callers that still assert on the old callback | ||
| 292 | // name. Delegates to the instance-aware mock so existing expectations work. | ||
| 293 | get onProviderModelChange() { | ||
| 294 | return onInstanceModelChange; | ||
| 295 | }, | ||
| 296 | cleanup: async () => { | ||
| 297 | await screen.unmount(); | ||
| 298 | host.remove(); | ||
| 299 | }, | ||
| 300 | }; | ||
| 301 | } | ||
| 302 | |||
| 303 | function getModelPickerListElement() { | ||
| 304 | const modelPickerList = document.querySelector<HTMLElement>(".model-picker-list"); | ||
| 305 | expect(modelPickerList).not.toBeNull(); | ||
| 306 | return modelPickerList!; | ||
| 307 | } | ||
| 308 | |||
| 309 | function getModelPickerListText() { | ||
| 310 | return getModelPickerListElement().textContent ?? ""; | ||
| 311 | } | ||
| 312 | |||
| 313 | function getVisibleModelNames() { | ||
| 314 | return Array.from(getModelPickerListElement().querySelectorAll<HTMLDivElement>("div.font-medium")) | ||
| 315 | .map((element) => element.textContent?.replace(/New$/u, "").trim() ?? "") | ||
| 316 | .filter((text) => text.length > 0); | ||
| 317 | } | ||
| 318 | |||
| 319 | function getSidebarProviderOrder() { | ||
| 320 | return Array.from(document.querySelectorAll<HTMLElement>("[data-model-picker-provider]")).map( | ||
| 321 | (element) => element.dataset.modelPickerProvider ?? "", | ||
| 322 | ); | ||
| 323 | } | ||
| 324 | |||
| 325 | describe("ProviderModelPicker", () => { | ||
| 326 | beforeEach(async () => { | ||
| 327 | // Reset test environment before each test | ||
| 328 | await __resetLocalApiForTests(); | ||
| 329 | }); | ||
| 330 | |||
| 331 | afterEach(async () => { | ||
| 332 | document.body.innerHTML = ""; | ||
| 333 | await __resetLocalApiForTests(); | ||
| 334 | }); | ||
| 335 | |||
| 336 | it("shows provider sidebar in unlocked mode", async () => { | ||
| 337 | const mounted = await mountPicker({ | ||
| 338 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 339 | model: "claude-opus-4-6", | ||
| 340 | lockedProvider: null, | ||
| 341 | }); | ||
| 342 | |||
| 343 | try { | ||
| 344 | await page.getByRole("button").click(); | ||
| 345 | |||
| 346 | await vi.waitFor(() => { | ||
| 347 | const text = document.body.textContent ?? ""; | ||
| 348 | expect(text).not.toContain("Codex"); | ||
| 349 | expect(text).toContain("Claude"); | ||
| 350 | expect(text).toContain("Claude Opus 4.6"); | ||
| 351 | }); | ||
| 352 | } finally { | ||
| 353 | await mounted.cleanup(); | ||
| 354 | } | ||
| 355 | }); | ||
| 356 | |||
| 357 | it("shows favorites first in the provider sidebar", async () => { | ||
| 358 | const mounted = await mountPicker({ | ||
| 359 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 360 | model: "claude-opus-4-6", | ||
| 361 | lockedProvider: null, | ||
| 362 | }); | ||
| 363 | |||
| 364 | try { | ||
| 365 | await page.getByRole("button").click(); | ||
| 366 | |||
| 367 | await vi.waitFor(() => { | ||
| 368 | expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ | ||
| 369 | "favorites", | ||
| 370 | "codex", | ||
| 371 | "claudeAgent", | ||
| 372 | ]); | ||
| 373 | }); | ||
| 374 | } finally { | ||
| 375 | await mounted.cleanup(); | ||
| 376 | } | ||
| 377 | }); | ||
| 378 | |||
| 379 | it("filters models by selected provider in sidebar", async () => { | ||
| 380 | const mounted = await mountPicker({ | ||
| 381 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 382 | model: "claude-opus-4-6", | ||
| 383 | lockedProvider: null, | ||
| 384 | }); | ||
| 385 | |||
| 386 | try { | ||
| 387 | await page.getByRole("button").click(); | ||
| 388 | |||
| 389 | // Start with Claude models visible | ||
| 390 | await vi.waitFor(() => { | ||
| 391 | const text = document.body.textContent ?? ""; | ||
| 392 | expect(text).not.toContain("GPT-5 Codex"); | ||
| 393 | expect(text).toContain("Claude Opus 4.6"); | ||
| 394 | }); | ||
| 395 | |||
| 396 | // Click on Codex provider in sidebar | ||
| 397 | await vi.waitFor(() => { | ||
| 398 | expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); | ||
| 399 | }); | ||
| 400 | await page.getByRole("button", { name: "Codex", exact: true }).click(); | ||
| 401 | |||
| 402 | // Now should only show Codex models | ||
| 403 | await vi.waitFor(() => { | ||
| 404 | const listText = getModelPickerListText(); | ||
| 405 | expect(listText).toContain("GPT-5 Codex"); | ||
| 406 | expect(listText).not.toContain("Claude Opus 4.6"); | ||
| 407 | }); | ||
| 408 | } finally { | ||
| 409 | await mounted.cleanup(); | ||
| 410 | } | ||
| 411 | }); | ||
| 412 | |||
| 413 | it("uses client model visibility and ordering preferences", async () => { | ||
| 414 | const mounted = await mountPicker({ | ||
| 415 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 416 | model: "claude-opus-4-6", | ||
| 417 | lockedProvider: null, | ||
| 418 | settings: { | ||
| 419 | ...DEFAULT_UNIFIED_SETTINGS, | ||
| 420 | providerModelPreferences: { | ||
| 421 | [CLAUDE_INSTANCE_ID]: { | ||
| 422 | hiddenModels: ["claude-opus-4-6"], | ||
| 423 | modelOrder: ["claude-haiku-4-5", "claude-sonnet-4-6"], | ||
| 424 | }, | ||
| 425 | }, | ||
| 426 | }, | ||
| 427 | }); | ||
| 428 | |||
| 429 | try { | ||
| 430 | await page.getByRole("button").click(); | ||
| 431 | |||
| 432 | await vi.waitFor(() => { | ||
| 433 | expect(getVisibleModelNames()).toEqual(["Claude Haiku 4.5", "Claude Sonnet 4.6"]); | ||
| 434 | expect(getModelPickerListText()).not.toContain("Claude Opus 4.6"); | ||
| 435 | }); | ||
| 436 | } finally { | ||
| 437 | await mounted.cleanup(); | ||
| 438 | } | ||
| 439 | }); | ||
| 440 | |||
| 441 | it("focuses the search input after selecting a sidebar provider", async () => { | ||
| 442 | const mounted = await mountPicker({ | ||
| 443 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 444 | model: "claude-opus-4-6", | ||
| 445 | lockedProvider: null, | ||
| 446 | }); | ||
| 447 | |||
| 448 | try { | ||
| 449 | await page.getByRole("button").click(); | ||
| 450 | |||
| 451 | await vi.waitFor(() => { | ||
| 452 | expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); | ||
| 453 | }); | ||
| 454 | await page.getByRole("button", { name: "Codex", exact: true }).click(); | ||
| 455 | |||
| 456 | await vi.waitFor(() => { | ||
| 457 | const searchInput = document.querySelector<HTMLInputElement>( | ||
| 458 | 'input[placeholder="Search models..."]', | ||
| 459 | ); | ||
| 460 | expect(searchInput).not.toBeNull(); | ||
| 461 | expect(document.activeElement).toBe(searchInput); | ||
| 462 | }); | ||
| 463 | } finally { | ||
| 464 | await mounted.cleanup(); | ||
| 465 | } | ||
| 466 | }); | ||
| 467 | |||
| 468 | it("keeps the full provider rail in locked mode and only lists compatible models", async () => { | ||
| 469 | localStorage.setItem( | ||
| 470 | "t3code:client-settings:v1", | ||
| 471 | JSON.stringify({ | ||
| 472 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 473 | favorites: [ | ||
| 474 | { provider: "codex", model: "gpt-5-codex" }, | ||
| 475 | { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | ||
| 476 | ], | ||
| 477 | }), | ||
| 478 | ); | ||
| 479 | |||
| 480 | const mounted = await mountPicker({ | ||
| 481 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 482 | model: "claude-opus-4-6", | ||
| 483 | lockedProvider: ProviderDriverKind.make("claudeAgent"), | ||
| 484 | }); | ||
| 485 | |||
| 486 | try { | ||
| 487 | await page.getByRole("button").click(); | ||
| 488 | |||
| 489 | await vi.waitFor(() => { | ||
| 490 | const text = document.body.textContent ?? ""; | ||
| 491 | expect(text).toContain("Claude"); | ||
| 492 | // Locked-compatible instances render first, then disabled ones. | ||
| 493 | expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ | ||
| 494 | "favorites", | ||
| 495 | "claudeAgent", | ||
| 496 | "codex", | ||
| 497 | ]); | ||
| 498 | expect( | ||
| 499 | document.querySelector<HTMLButtonElement>('[data-model-picker-provider="codex"]') | ||
| 500 | ?.disabled, | ||
| 501 | ).toBe(true); | ||
| 502 | expect( | ||
| 503 | document.querySelector<HTMLButtonElement>('[data-model-picker-provider="claudeAgent"]') | ||
| 504 | ?.disabled, | ||
| 505 | ).toBe(false); | ||
| 506 | expect(getVisibleModelNames()).toEqual([ | ||
| 507 | "Claude Sonnet 4.6", | ||
| 508 | "Claude Opus 4.6", | ||
| 509 | "Claude Haiku 4.5", | ||
| 510 | ]); | ||
| 511 | }); | ||
| 512 | } finally { | ||
| 513 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 514 | await mounted.cleanup(); | ||
| 515 | } | ||
| 516 | }); | ||
| 517 | |||
| 518 | it("keeps an instance sidebar in locked mode when that provider has multiple instances", async () => { | ||
| 519 | const defaultCodexModels: ServerProvider["models"] = [ | ||
| 520 | { | ||
| 521 | slug: "gpt-work", | ||
| 522 | name: "GPT Work", | ||
| 523 | isCustom: false, | ||
| 524 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 525 | }, | ||
| 526 | ]; | ||
| 527 | const personalCodexModels: ServerProvider["models"] = [ | ||
| 528 | { | ||
| 529 | slug: "gpt-personal", | ||
| 530 | name: "GPT Personal", | ||
| 531 | isCustom: false, | ||
| 532 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 533 | }, | ||
| 534 | ]; | ||
| 535 | const isolatedCodexModels: ServerProvider["models"] = [ | ||
| 536 | { | ||
| 537 | slug: "gpt-isolated", | ||
| 538 | name: "GPT Isolated", | ||
| 539 | isCustom: false, | ||
| 540 | capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| 541 | }, | ||
| 542 | ]; | ||
| 543 | const providers: ReadonlyArray<ServerProvider> = [ | ||
| 544 | { | ||
| 545 | ...buildCodexProvider(defaultCodexModels), | ||
| 546 | instanceId: "codex" as ProviderInstanceId, | ||
| 547 | displayName: "Codex Work", | ||
| 548 | accentColor: "#2563eb", | ||
| 549 | continuation: { groupKey: "codex:home:/Users/julius/.codex" }, | ||
| 550 | }, | ||
| 551 | { | ||
| 552 | ...buildCodexProvider(personalCodexModels), | ||
| 553 | instanceId: "codex_personal" as ProviderInstanceId, | ||
| 554 | displayName: "Codex Personal", | ||
| 555 | accentColor: "#dc2626", | ||
| 556 | continuation: { groupKey: "codex:home:/Users/julius/.codex" }, | ||
| 557 | }, | ||
| 558 | { | ||
| 559 | ...buildCodexProvider(isolatedCodexModels), | ||
| 560 | instanceId: "codex_isolated" as ProviderInstanceId, | ||
| 561 | displayName: "Codex Isolated", | ||
| 562 | accentColor: "#16a34a", | ||
| 563 | continuation: { groupKey: "codex:home:/Users/julius/.codex_isolated" }, | ||
| 564 | }, | ||
| 565 | TEST_PROVIDERS[1]!, | ||
| 566 | ]; | ||
| 567 | const mounted = await mountPicker({ | ||
| 568 | activeInstanceId: "codex" as ProviderInstanceId, | ||
| 569 | model: "gpt-work", | ||
| 570 | lockedProvider: ProviderDriverKind.make("codex"), | ||
| 571 | lockedContinuationGroupKey: "codex:home:/Users/julius/.codex", | ||
| 572 | providers, | ||
| 573 | }); | ||
| 574 | |||
| 575 | try { | ||
| 576 | await page.getByRole("button").click(); | ||
| 577 | |||
| 578 | await vi.waitFor(() => { | ||
| 579 | expect(getSidebarProviderOrder().slice(0, 5)).toEqual([ | ||
| 580 | "favorites", | ||
| 581 | "codex", | ||
| 582 | "codex_personal", | ||
| 583 | "codex_isolated", | ||
| 584 | "claudeAgent", | ||
| 585 | ]); | ||
| 586 | expect( | ||
| 587 | document.querySelector<HTMLButtonElement>('[data-model-picker-provider="codex_isolated"]') | ||
| 588 | ?.disabled, | ||
| 589 | ).toBe(true); | ||
| 590 | expect( | ||
| 591 | document.querySelector<HTMLButtonElement>('[data-model-picker-provider="claudeAgent"]') | ||
| 592 | ?.disabled, | ||
| 593 | ).toBe(true); | ||
| 594 | expect(getModelPickerListText()).not.toContain("Codex Isolated"); | ||
| 595 | expect( | ||
| 596 | document.querySelector<HTMLElement>('[data-model-picker-provider="codex_personal"]') | ||
| 597 | ?.dataset.providerAccentColor, | ||
| 598 | ).toBe("#dc2626"); | ||
| 599 | expect(getModelPickerListText()).toContain("Codex Work"); | ||
| 600 | expect(getVisibleModelNames()).toEqual(["GPT Work"]); | ||
| 601 | }); | ||
| 602 | |||
| 603 | await page.getByRole("button", { name: "Codex Personal" }).click(); | ||
| 604 | |||
| 605 | await vi.waitFor(() => { | ||
| 606 | expect(getModelPickerListText()).toContain("Codex Personal"); | ||
| 607 | expect(getVisibleModelNames()).toEqual(["GPT Personal"]); | ||
| 608 | }); | ||
| 609 | } finally { | ||
| 610 | await mounted.cleanup(); | ||
| 611 | } | ||
| 612 | }); | ||
| 613 | |||
| 614 | it("falls back to the active provider's first model when props.model belongs to another provider (#1982)", async () => { | ||
| 615 | const host = document.createElement("div"); | ||
| 616 | document.body.append(host); | ||
| 617 | const onInstanceModelChange = vi.fn(); | ||
| 618 | const modelOptionsByInstance = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>([ | ||
| 619 | [ | ||
| 620 | "claudeAgent" as ProviderInstanceId, | ||
| 621 | [ | ||
| 622 | { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, | ||
| 623 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | ||
| 624 | ], | ||
| 625 | ], | ||
| 626 | ["codex" as ProviderInstanceId, [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }]], | ||
| 627 | ["cursor" as ProviderInstanceId, []], | ||
| 628 | ["opencode" as ProviderInstanceId, []], | ||
| 629 | ]); | ||
| 630 | const instanceEntries = sortProviderInstanceEntries( | ||
| 631 | deriveProviderInstanceEntries(TEST_PROVIDERS), | ||
| 632 | ); | ||
| 633 | const screen = await render( | ||
| 634 | <ProviderModelPicker | ||
| 635 | activeInstanceId={"claudeAgent" as ProviderInstanceId} | ||
| 636 | model="gpt-5-codex" | ||
| 637 | lockedProvider={null} | ||
| 638 | instanceEntries={instanceEntries} | ||
| 639 | modelOptionsByInstance={modelOptionsByInstance} | ||
| 640 | onInstanceModelChange={onInstanceModelChange} | ||
| 641 | />, | ||
| 642 | { container: host }, | ||
| 643 | ); | ||
| 644 | |||
| 645 | try { | ||
| 646 | const trigger = document.querySelector<HTMLElement>( | ||
| 647 | '[data-chat-provider-model-picker="true"]', | ||
| 648 | ); | ||
| 649 | expect(trigger).not.toBeNull(); | ||
| 650 | const label = trigger?.textContent ?? ""; | ||
| 651 | expect(label).not.toContain("gpt-5-codex"); | ||
| 652 | expect(label).toContain("Claude Opus 4.6"); | ||
| 653 | } finally { | ||
| 654 | await screen.unmount(); | ||
| 655 | host.remove(); | ||
| 656 | } | ||
| 657 | }); | ||
| 658 | |||
| 659 | it("shows the plain model name in the trigger and provider details on locked opencode rows", async () => { | ||
| 660 | const providers: ReadonlyArray<ServerProvider> = [ | ||
| 661 | buildOpenCodeProvider([ | ||
| 662 | { | ||
| 663 | slug: "github-copilot/claude-opus-4.5", | ||
| 664 | name: "Claude Opus 4.5", | ||
| 665 | subProvider: "GitHub Copilot", | ||
| 666 | shortName: "Opus 4.5", | ||
| 667 | isCustom: false, | ||
| 668 | capabilities: createModelCapabilities({ | ||
| 669 | optionDescriptors: [ | ||
| 670 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 671 | { id: "low", label: "low" }, | ||
| 672 | { id: "medium", label: "medium", isDefault: true }, | ||
| 673 | { id: "high", label: "high" }, | ||
| 674 | ]), | ||
| 675 | ], | ||
| 676 | }), | ||
| 677 | }, | ||
| 678 | ]), | ||
| 679 | ]; | ||
| 680 | const mounted = await mountPicker({ | ||
| 681 | activeInstanceId: OPENCODE_INSTANCE_ID, | ||
| 682 | model: "github-copilot/claude-opus-4.5", | ||
| 683 | lockedProvider: ProviderDriverKind.make("opencode"), | ||
| 684 | providers, | ||
| 685 | }); | ||
| 686 | |||
| 687 | try { | ||
| 688 | await vi.waitFor(() => { | ||
| 689 | const trigger = document.querySelector<HTMLElement>( | ||
| 690 | '[data-chat-provider-model-picker="true"]', | ||
| 691 | ); | ||
| 692 | expect(trigger?.textContent).toContain("Opus 4.5"); | ||
| 693 | expect(trigger?.textContent).not.toContain("GitHub Copilot"); | ||
| 694 | }); | ||
| 695 | |||
| 696 | await page.getByRole("button").click(); | ||
| 697 | |||
| 698 | await vi.waitFor(() => { | ||
| 699 | expect(getVisibleModelNames()).toEqual(["Claude Opus 4.5"]); | ||
| 700 | expect(getModelPickerListText()).toContain("OpenCode · GitHub Copilot"); | ||
| 701 | }); | ||
| 702 | } finally { | ||
| 703 | await mounted.cleanup(); | ||
| 704 | } | ||
| 705 | }); | ||
| 706 | |||
| 707 | it("searches models by name in flat list", async () => { | ||
| 708 | const mounted = await mountPicker({ | ||
| 709 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 710 | model: "claude-opus-4-6", | ||
| 711 | lockedProvider: null, | ||
| 712 | }); | ||
| 713 | |||
| 714 | try { | ||
| 715 | await page.getByRole("button").click(); | ||
| 716 | |||
| 717 | await vi.waitFor(() => { | ||
| 718 | const text = document.body.textContent ?? ""; | ||
| 719 | expect(text).toContain("Claude Opus 4.6"); | ||
| 720 | expect(text).not.toContain("GPT-5 Codex"); | ||
| 721 | }); | ||
| 722 | |||
| 723 | // Find and type in search box | ||
| 724 | const searchInput = page.getByPlaceholder("Search models..."); | ||
| 725 | await searchInput.fill("claude"); | ||
| 726 | |||
| 727 | await vi.waitFor(() => { | ||
| 728 | const text = document.body.textContent ?? ""; | ||
| 729 | expect(text).toContain("Claude Opus 4.6"); | ||
| 730 | expect(text).not.toContain("GPT-5 Codex"); | ||
| 731 | }); | ||
| 732 | } finally { | ||
| 733 | await mounted.cleanup(); | ||
| 734 | } | ||
| 735 | }); | ||
| 736 | |||
| 737 | it("supports arrow-key navigation in the model picker", async () => { | ||
| 738 | const mounted = await mountPicker({ | ||
| 739 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 740 | model: "claude-opus-4-6", | ||
| 741 | lockedProvider: ProviderDriverKind.make("claudeAgent"), | ||
| 742 | }); | ||
| 743 | |||
| 744 | try { | ||
| 745 | await page.getByRole("button").click(); | ||
| 746 | |||
| 747 | const searchInput = page.getByPlaceholder("Search models..."); | ||
| 748 | await userEvent.click(searchInput); | ||
| 749 | await userEvent.keyboard("{ArrowDown}"); | ||
| 750 | await vi.waitFor(() => { | ||
| 751 | const highlightedItem = document.querySelector<HTMLElement>( | ||
| 752 | '[data-slot="combobox-item"][data-highlighted]', | ||
| 753 | ); | ||
| 754 | expect(highlightedItem).not.toBeNull(); | ||
| 755 | expect(highlightedItem?.textContent).toContain("Claude Opus 4.6"); | ||
| 756 | }); | ||
| 757 | await userEvent.keyboard("{ArrowDown}"); | ||
| 758 | await vi.waitFor(() => { | ||
| 759 | const highlightedItem = document.querySelector<HTMLElement>( | ||
| 760 | '[data-slot="combobox-item"][data-highlighted]', | ||
| 761 | ); | ||
| 762 | expect(highlightedItem).not.toBeNull(); | ||
| 763 | expect(highlightedItem?.textContent).toContain("Claude Sonnet 4.6"); | ||
| 764 | }); | ||
| 765 | await userEvent.keyboard("{Enter}"); | ||
| 766 | |||
| 767 | expect(mounted.onProviderModelChange).toHaveBeenCalledWith( | ||
| 768 | "claudeAgent", | ||
| 769 | "claude-sonnet-4-6", | ||
| 770 | ); | ||
| 771 | } finally { | ||
| 772 | await mounted.cleanup(); | ||
| 773 | } | ||
| 774 | }); | ||
| 775 | |||
| 776 | it("hides the provider sidebar while searching", async () => { | ||
| 777 | const mounted = await mountPicker({ | ||
| 778 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 779 | model: "claude-opus-4-6", | ||
| 780 | lockedProvider: null, | ||
| 781 | }); | ||
| 782 | |||
| 783 | try { | ||
| 784 | await page.getByRole("button").click(); | ||
| 785 | |||
| 786 | await vi.waitFor(() => { | ||
| 787 | expect(getSidebarProviderOrder().length).toBeGreaterThan(0); | ||
| 788 | }); | ||
| 789 | |||
| 790 | await page.getByPlaceholder("Search models...").fill("cla"); | ||
| 791 | |||
| 792 | await vi.waitFor(() => { | ||
| 793 | expect(getSidebarProviderOrder()).toEqual([]); | ||
| 794 | }); | ||
| 795 | } finally { | ||
| 796 | await mounted.cleanup(); | ||
| 797 | } | ||
| 798 | }); | ||
| 799 | |||
| 800 | it("closes the picker when escape is pressed in search", async () => { | ||
| 801 | const mounted = await mountPicker({ | ||
| 802 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 803 | model: "claude-opus-4-6", | ||
| 804 | lockedProvider: null, | ||
| 805 | }); | ||
| 806 | |||
| 807 | try { | ||
| 808 | await page.getByRole("button").click(); | ||
| 809 | |||
| 810 | const searchInput = page.getByPlaceholder("Search models..."); | ||
| 811 | await searchInput.click(); | ||
| 812 | const searchInputElement = document.querySelector<HTMLInputElement>( | ||
| 813 | 'input[placeholder="Search models..."]', | ||
| 814 | ); | ||
| 815 | expect(searchInputElement).not.toBeNull(); | ||
| 816 | searchInputElement!.dispatchEvent( | ||
| 817 | new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), | ||
| 818 | ); | ||
| 819 | |||
| 820 | await vi.waitFor(() => { | ||
| 821 | expect(document.querySelector(".model-picker-list")).toBeNull(); | ||
| 822 | }); | ||
| 823 | } finally { | ||
| 824 | await mounted.cleanup(); | ||
| 825 | } | ||
| 826 | }); | ||
| 827 | |||
| 828 | it("searches models by provider name", async () => { | ||
| 829 | const mounted = await mountPicker({ | ||
| 830 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 831 | model: "claude-opus-4-6", | ||
| 832 | lockedProvider: null, | ||
| 833 | }); | ||
| 834 | |||
| 835 | try { | ||
| 836 | await page.getByRole("button").click(); | ||
| 837 | |||
| 838 | await vi.waitFor(() => { | ||
| 839 | const text = document.body.textContent ?? ""; | ||
| 840 | expect(text).toContain("Claude Opus 4.6"); | ||
| 841 | expect(text).not.toContain("GPT-5 Codex"); | ||
| 842 | }); | ||
| 843 | |||
| 844 | // Search by provider name | ||
| 845 | const searchInput = page.getByPlaceholder("Search models..."); | ||
| 846 | await searchInput.fill("codex"); | ||
| 847 | |||
| 848 | await vi.waitFor(() => { | ||
| 849 | const listText = getModelPickerListText(); | ||
| 850 | expect(listText).toContain("GPT-5 Codex"); | ||
| 851 | expect(listText).not.toContain("Claude Opus 4.6"); | ||
| 852 | }); | ||
| 853 | } finally { | ||
| 854 | await mounted.cleanup(); | ||
| 855 | } | ||
| 856 | }); | ||
| 857 | |||
| 858 | it("matches fuzzy multi-token queries across provider and model text", async () => { | ||
| 859 | const providers: ReadonlyArray<ServerProvider> = [ | ||
| 860 | buildCodexProvider([ | ||
| 861 | { | ||
| 862 | slug: "gpt-5-codex", | ||
| 863 | name: "GPT-5 Codex", | ||
| 864 | isCustom: false, | ||
| 865 | capabilities: createModelCapabilities({ | ||
| 866 | optionDescriptors: [ | ||
| 867 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 868 | { id: "low", label: "low" }, | ||
| 869 | { id: "medium", label: "medium", isDefault: true }, | ||
| 870 | { id: "high", label: "high" }, | ||
| 871 | ]), | ||
| 872 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 873 | ], | ||
| 874 | }), | ||
| 875 | }, | ||
| 876 | ]), | ||
| 877 | buildOpenCodeProvider([ | ||
| 878 | { | ||
| 879 | slug: "github-copilot/claude-opus-4.7", | ||
| 880 | name: "Claude Opus 4.7", | ||
| 881 | subProvider: "GitHub Copilot", | ||
| 882 | isCustom: false, | ||
| 883 | capabilities: createModelCapabilities({ | ||
| 884 | optionDescriptors: [ | ||
| 885 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 886 | { id: "low", label: "low" }, | ||
| 887 | { id: "medium", label: "medium", isDefault: true }, | ||
| 888 | { id: "high", label: "high" }, | ||
| 889 | ]), | ||
| 890 | ], | ||
| 891 | }), | ||
| 892 | }, | ||
| 893 | ]), | ||
| 894 | ]; | ||
| 895 | const mounted = await mountPicker({ | ||
| 896 | activeInstanceId: OPENCODE_INSTANCE_ID, | ||
| 897 | model: "github-copilot/claude-opus-4.7", | ||
| 898 | lockedProvider: null, | ||
| 899 | providers, | ||
| 900 | }); | ||
| 901 | |||
| 902 | try { | ||
| 903 | await page.getByRole("button").click(); | ||
| 904 | await page.getByPlaceholder("Search models...").fill("coplt op"); | ||
| 905 | |||
| 906 | await vi.waitFor(() => { | ||
| 907 | const listText = getModelPickerListText(); | ||
| 908 | expect(listText).toContain("Claude Opus 4.7"); | ||
| 909 | expect(listText).not.toContain("GPT-5 Codex"); | ||
| 910 | }); | ||
| 911 | } finally { | ||
| 912 | await mounted.cleanup(); | ||
| 913 | } | ||
| 914 | }); | ||
| 915 | |||
| 916 | it("renders each search result with its own provider branding", async () => { | ||
| 917 | const providers: ReadonlyArray<ServerProvider> = [ | ||
| 918 | buildOpenCodeProvider([ | ||
| 919 | { | ||
| 920 | slug: "github-copilot/claude-opus-4.7", | ||
| 921 | name: "Claude Opus 4.7", | ||
| 922 | subProvider: "GitHub Copilot", | ||
| 923 | isCustom: false, | ||
| 924 | capabilities: createModelCapabilities({ | ||
| 925 | optionDescriptors: [ | ||
| 926 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 927 | { id: "low", label: "low" }, | ||
| 928 | { id: "medium", label: "medium", isDefault: true }, | ||
| 929 | { id: "high", label: "high" }, | ||
| 930 | ]), | ||
| 931 | ], | ||
| 932 | }), | ||
| 933 | }, | ||
| 934 | ]), | ||
| 935 | { | ||
| 936 | ...TEST_PROVIDERS[1]!, | ||
| 937 | models: [ | ||
| 938 | { | ||
| 939 | slug: "claude-opus-4-6", | ||
| 940 | name: "Claude Opus 4.6", | ||
| 941 | isCustom: false, | ||
| 942 | capabilities: createModelCapabilities({ | ||
| 943 | optionDescriptors: [ | ||
| 944 | selectDescriptor("effort", "Reasoning", [ | ||
| 945 | { id: "low", label: "low" }, | ||
| 946 | { id: "medium", label: "medium", isDefault: true }, | ||
| 947 | { id: "high", label: "high" }, | ||
| 948 | { id: "max", label: "max" }, | ||
| 949 | ]), | ||
| 950 | booleanDescriptor("thinking", "Thinking"), | ||
| 951 | ], | ||
| 952 | }), | ||
| 953 | }, | ||
| 954 | ], | ||
| 955 | }, | ||
| 956 | ]; | ||
| 957 | const mounted = await mountPicker({ | ||
| 958 | activeInstanceId: OPENCODE_INSTANCE_ID, | ||
| 959 | model: "github-copilot/claude-opus-4.7", | ||
| 960 | lockedProvider: null, | ||
| 961 | providers, | ||
| 962 | }); | ||
| 963 | |||
| 964 | try { | ||
| 965 | await page.getByRole("button").click(); | ||
| 966 | await page.getByPlaceholder("Search models...").fill("opus"); | ||
| 967 | |||
| 968 | await vi.waitFor(() => { | ||
| 969 | const listText = getModelPickerListText(); | ||
| 970 | expect(listText).toContain("OpenCode · GitHub Copilot"); | ||
| 971 | expect(listText).toContain("Claude"); | ||
| 972 | expect(listText).not.toContain("OpenCodeClaude Opus 4.6"); | ||
| 973 | }); | ||
| 974 | } finally { | ||
| 975 | await mounted.cleanup(); | ||
| 976 | } | ||
| 977 | }); | ||
| 978 | |||
| 979 | it("toggles favorite stars when clicked", async () => { | ||
| 980 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 981 | |||
| 982 | const mounted = await mountPicker({ | ||
| 983 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 984 | model: "claude-opus-4-6", | ||
| 985 | lockedProvider: null, | ||
| 986 | }); | ||
| 987 | |||
| 988 | try { | ||
| 989 | await page.getByRole("button").click(); | ||
| 990 | |||
| 991 | await vi.waitFor(() => { | ||
| 992 | const text = document.body.textContent ?? ""; | ||
| 993 | expect(text).toContain("Claude Opus 4.6"); | ||
| 994 | }); | ||
| 995 | |||
| 996 | const getFavoriteButton = () => { | ||
| 997 | const modelRow = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find( | ||
| 998 | (row) => row.textContent?.includes("Claude Opus 4.6"), | ||
| 999 | ); | ||
| 1000 | const starButton = modelRow?.querySelector<HTMLButtonElement>( | ||
| 1001 | 'button[aria-label*="favorites"]', | ||
| 1002 | ); | ||
| 1003 | expect(starButton).not.toBeNull(); | ||
| 1004 | return starButton!; | ||
| 1005 | }; | ||
| 1006 | |||
| 1007 | const favoriteButton = getFavoriteButton(); | ||
| 1008 | const initialAriaLabel = favoriteButton.getAttribute("aria-label"); | ||
| 1009 | expect( | ||
| 1010 | initialAriaLabel === "Add to favorites" || initialAriaLabel === "Remove from favorites", | ||
| 1011 | ).toBe(true); | ||
| 1012 | |||
| 1013 | await userEvent.click(favoriteButton); | ||
| 1014 | |||
| 1015 | const expectedAriaLabel = | ||
| 1016 | initialAriaLabel === "Add to favorites" ? "Remove from favorites" : "Add to favorites"; | ||
| 1017 | |||
| 1018 | await vi.waitFor(() => { | ||
| 1019 | expect(getFavoriteButton().getAttribute("aria-label")).toBe(expectedAriaLabel); | ||
| 1020 | }); | ||
| 1021 | } finally { | ||
| 1022 | await mounted.cleanup(); | ||
| 1023 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 1024 | } | ||
| 1025 | }); | ||
| 1026 | |||
| 1027 | it("does not duplicate favorited models across favorites and all models sections", async () => { | ||
| 1028 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 1029 | |||
| 1030 | const mounted = await mountPicker({ | ||
| 1031 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 1032 | model: "claude-opus-4-6", | ||
| 1033 | lockedProvider: null, | ||
| 1034 | }); | ||
| 1035 | |||
| 1036 | try { | ||
| 1037 | await page.getByRole("button").click(); | ||
| 1038 | |||
| 1039 | await vi.waitFor(() => { | ||
| 1040 | const text = document.body.textContent ?? ""; | ||
| 1041 | expect(text).toContain("Claude Opus 4.6"); | ||
| 1042 | }); | ||
| 1043 | |||
| 1044 | const favoriteButton = page.getByRole("button", { | ||
| 1045 | name: "Add to favorites", | ||
| 1046 | }); | ||
| 1047 | await favoriteButton.first().click(); | ||
| 1048 | |||
| 1049 | await vi.waitFor(async () => { | ||
| 1050 | const favoritedModelRows = Array.from( | ||
| 1051 | getModelPickerListElement().querySelectorAll<HTMLDivElement>("div.font-medium"), | ||
| 1052 | ).filter((element) => element.textContent?.trim() === "Claude Opus 4.6"); | ||
| 1053 | expect(favoritedModelRows.length).toBe(1); | ||
| 1054 | }); | ||
| 1055 | } finally { | ||
| 1056 | await mounted.cleanup(); | ||
| 1057 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 1058 | } | ||
| 1059 | }); | ||
| 1060 | |||
| 1061 | it("shows favorited models first within the selected provider list", async () => { | ||
| 1062 | localStorage.setItem( | ||
| 1063 | "t3code:client-settings:v1", | ||
| 1064 | JSON.stringify({ | ||
| 1065 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 1066 | favorites: [{ provider: "codex", model: "gpt-5.3-codex" }], | ||
| 1067 | }), | ||
| 1068 | ); | ||
| 1069 | |||
| 1070 | const mounted = await mountPicker({ | ||
| 1071 | model: "gpt-5-codex", | ||
| 1072 | lockedProvider: null, | ||
| 1073 | }); | ||
| 1074 | |||
| 1075 | try { | ||
| 1076 | await page.getByRole("button").click(); | ||
| 1077 | await page.getByRole("button", { name: "Codex", exact: true }).click(); | ||
| 1078 | |||
| 1079 | await vi.waitFor(() => { | ||
| 1080 | expect(getVisibleModelNames().slice(0, 2)).toEqual(["GPT-5.3 Codex", "GPT-5 Codex"]); | ||
| 1081 | }); | ||
| 1082 | } finally { | ||
| 1083 | await mounted.cleanup(); | ||
| 1084 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 1085 | } | ||
| 1086 | }); | ||
| 1087 | |||
| 1088 | it("filters favorites to compatible models in locked mode", async () => { | ||
| 1089 | localStorage.setItem( | ||
| 1090 | "t3code:client-settings:v1", | ||
| 1091 | JSON.stringify({ | ||
| 1092 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 1093 | favorites: [ | ||
| 1094 | { provider: "codex", model: "gpt-5.3-codex" }, | ||
| 1095 | { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | ||
| 1096 | ], | ||
| 1097 | }), | ||
| 1098 | ); | ||
| 1099 | |||
| 1100 | const mounted = await mountPicker({ | ||
| 1101 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 1102 | model: "claude-opus-4-6", | ||
| 1103 | lockedProvider: ProviderDriverKind.make("claudeAgent"), | ||
| 1104 | }); | ||
| 1105 | |||
| 1106 | try { | ||
| 1107 | await page.getByRole("button").click(); | ||
| 1108 | await page.getByRole("button", { name: "Favorites", exact: true }).click(); | ||
| 1109 | |||
| 1110 | await vi.waitFor(() => { | ||
| 1111 | expect(getVisibleModelNames()).toEqual(["Claude Sonnet 4.6"]); | ||
| 1112 | expect(getModelPickerListText()).not.toContain("GPT-5.3 Codex"); | ||
| 1113 | }); | ||
| 1114 | } finally { | ||
| 1115 | await mounted.cleanup(); | ||
| 1116 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 1117 | } | ||
| 1118 | }); | ||
| 1119 | |||
| 1120 | it("dispatches callback with correct provider and model when selected", async () => { | ||
| 1121 | const mounted = await mountPicker({ | ||
| 1122 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 1123 | model: "claude-opus-4-6", | ||
| 1124 | lockedProvider: ProviderDriverKind.make("claudeAgent"), | ||
| 1125 | }); | ||
| 1126 | |||
| 1127 | try { | ||
| 1128 | await page.getByRole("button").click(); | ||
| 1129 | |||
| 1130 | await vi.waitFor(() => { | ||
| 1131 | const text = document.body.textContent ?? ""; | ||
| 1132 | expect(text).toContain("Claude Sonnet 4.6"); | ||
| 1133 | }); | ||
| 1134 | |||
| 1135 | // Click on a model | ||
| 1136 | const modelRow = page.getByText("Claude Sonnet 4.6").first(); | ||
| 1137 | await modelRow.click(); | ||
| 1138 | |||
| 1139 | // Verify callback was called with correct values | ||
| 1140 | expect(mounted.onProviderModelChange).toHaveBeenCalledWith( | ||
| 1141 | "claudeAgent", | ||
| 1142 | "claude-sonnet-4-6", | ||
| 1143 | ); | ||
| 1144 | } finally { | ||
| 1145 | await mounted.cleanup(); | ||
| 1146 | } | ||
| 1147 | }); | ||
| 1148 | |||
| 1149 | it("does not select models blocked by the provider", async () => { | ||
| 1150 | const disabledReason = | ||
| 1151 | "This provider does not allow switching models after a conversation has started. Start a new thread to use this model."; | ||
| 1152 | const mounted = await mountPicker({ | ||
| 1153 | activeInstanceId: CLAUDE_INSTANCE_ID, | ||
| 1154 | model: "claude-opus-4-6", | ||
| 1155 | lockedProvider: ProviderDriverKind.make("claudeAgent"), | ||
| 1156 | getModelDisabledReason: (instanceId, model) => | ||
| 1157 | instanceId === CLAUDE_INSTANCE_ID && model !== "claude-opus-4-6" ? disabledReason : null, | ||
| 1158 | }); | ||
| 1159 | |||
| 1160 | try { | ||
| 1161 | await page.getByRole("button").click(); | ||
| 1162 | |||
| 1163 | const blockedModel = page.getByText("Claude Sonnet 4.6").first(); | ||
| 1164 | await blockedModel.click(); | ||
| 1165 | expect(mounted.onProviderModelChange).not.toHaveBeenCalled(); | ||
| 1166 | expect(document.querySelector(".model-picker-list")).not.toBeNull(); | ||
| 1167 | } finally { | ||
| 1168 | await mounted.cleanup(); | ||
| 1169 | } | ||
| 1170 | }); | ||
| 1171 | |||
| 1172 | it("only shows codex spark when the server reports it", async () => { | ||
| 1173 | const providersWithoutSpark: ReadonlyArray<ServerProvider> = [ | ||
| 1174 | buildCodexProvider([ | ||
| 1175 | { | ||
| 1176 | slug: "gpt-5.3-codex", | ||
| 1177 | name: "GPT-5.3 Codex", | ||
| 1178 | isCustom: false, | ||
| 1179 | capabilities: createModelCapabilities({ | ||
| 1180 | optionDescriptors: [ | ||
| 1181 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 1182 | { id: "low", label: "low" }, | ||
| 1183 | { id: "medium", label: "medium", isDefault: true }, | ||
| 1184 | { id: "high", label: "high" }, | ||
| 1185 | ]), | ||
| 1186 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 1187 | ], | ||
| 1188 | }), | ||
| 1189 | }, | ||
| 1190 | ]), | ||
| 1191 | TEST_PROVIDERS[1]!, | ||
| 1192 | ]; | ||
| 1193 | const providersWithSpark: ReadonlyArray<ServerProvider> = [ | ||
| 1194 | buildCodexProvider([ | ||
| 1195 | { | ||
| 1196 | slug: "gpt-5.3-codex", | ||
| 1197 | name: "GPT-5.3 Codex", | ||
| 1198 | isCustom: false, | ||
| 1199 | capabilities: createModelCapabilities({ | ||
| 1200 | optionDescriptors: [ | ||
| 1201 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 1202 | { id: "low", label: "low" }, | ||
| 1203 | { id: "medium", label: "medium", isDefault: true }, | ||
| 1204 | { id: "high", label: "high" }, | ||
| 1205 | ]), | ||
| 1206 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 1207 | ], | ||
| 1208 | }), | ||
| 1209 | }, | ||
| 1210 | { | ||
| 1211 | slug: "gpt-5.3-codex-spark", | ||
| 1212 | name: "GPT-5.3 Codex Spark", | ||
| 1213 | isCustom: false, | ||
| 1214 | capabilities: createModelCapabilities({ | ||
| 1215 | optionDescriptors: [ | ||
| 1216 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 1217 | { id: "low", label: "low" }, | ||
| 1218 | { id: "medium", label: "medium", isDefault: true }, | ||
| 1219 | { id: "high", label: "high" }, | ||
| 1220 | ]), | ||
| 1221 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 1222 | ], | ||
| 1223 | }), | ||
| 1224 | }, | ||
| 1225 | ]), | ||
| 1226 | TEST_PROVIDERS[1]!, | ||
| 1227 | ]; | ||
| 1228 | |||
| 1229 | const hidden = await mountPicker({ | ||
| 1230 | model: "gpt-5.3-codex", | ||
| 1231 | lockedProvider: null, | ||
| 1232 | providers: providersWithoutSpark, | ||
| 1233 | }); | ||
| 1234 | |||
| 1235 | try { | ||
| 1236 | await page.getByRole("button").click(); | ||
| 1237 | |||
| 1238 | await vi.waitFor(() => { | ||
| 1239 | const text = document.body.textContent ?? ""; | ||
| 1240 | expect(text).toContain("GPT-5.3 Codex"); | ||
| 1241 | expect(text).not.toContain("GPT-5.3 Codex Spark"); | ||
| 1242 | }); | ||
| 1243 | } finally { | ||
| 1244 | await hidden.cleanup(); | ||
| 1245 | } | ||
| 1246 | |||
| 1247 | const visible = await mountPicker({ | ||
| 1248 | model: "gpt-5.3-codex", | ||
| 1249 | lockedProvider: null, | ||
| 1250 | providers: providersWithSpark, | ||
| 1251 | }); | ||
| 1252 | |||
| 1253 | try { | ||
| 1254 | await page.getByRole("button").click(); | ||
| 1255 | |||
| 1256 | await vi.waitFor(() => { | ||
| 1257 | expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark"); | ||
| 1258 | }); | ||
| 1259 | } finally { | ||
| 1260 | await visible.cleanup(); | ||
| 1261 | } | ||
| 1262 | }); | ||
| 1263 | |||
| 1264 | it("shows disabled providers grayed out in sidebar", async () => { | ||
| 1265 | const disabledProviders = TEST_PROVIDERS.slice(); | ||
| 1266 | const claudeIndex = disabledProviders.findIndex( | ||
| 1267 | (provider) => provider.instanceId === ProviderInstanceId.make("claudeAgent"), | ||
| 1268 | ); | ||
| 1269 | if (claudeIndex >= 0) { | ||
| 1270 | const claudeProvider = disabledProviders[claudeIndex]!; | ||
| 1271 | disabledProviders[claudeIndex] = { | ||
| 1272 | ...claudeProvider, | ||
| 1273 | enabled: false, | ||
| 1274 | status: "disabled", | ||
| 1275 | }; | ||
| 1276 | } | ||
| 1277 | |||
| 1278 | const mounted = await mountPicker({ | ||
| 1279 | model: "gpt-5-codex", | ||
| 1280 | lockedProvider: null, | ||
| 1281 | providers: disabledProviders, | ||
| 1282 | }); | ||
| 1283 | |||
| 1284 | try { | ||
| 1285 | await page.getByRole("button").click(); | ||
| 1286 | |||
| 1287 | await vi.waitFor(() => { | ||
| 1288 | const text = document.body.textContent ?? ""; | ||
| 1289 | expect(text).toContain("GPT-5 Codex"); | ||
| 1290 | // Disabled provider should not have its models shown | ||
| 1291 | expect(text).not.toContain("Claude Opus 4.6"); | ||
| 1292 | }); | ||
| 1293 | } finally { | ||
| 1294 | await mounted.cleanup(); | ||
| 1295 | } | ||
| 1296 | }); | ||
| 1297 | |||
| 1298 | it("accepts outline trigger styling", async () => { | ||
| 1299 | const mounted = await mountPicker({ | ||
| 1300 | model: "gpt-5-codex", | ||
| 1301 | lockedProvider: null, | ||
| 1302 | triggerVariant: "outline", | ||
| 1303 | }); | ||
| 1304 | |||
| 1305 | try { | ||
| 1306 | const button = document.querySelector("button"); | ||
| 1307 | if (!(button instanceof HTMLButtonElement)) { | ||
| 1308 | throw new Error("Expected picker trigger button to be rendered."); | ||
| 1309 | } | ||
| 1310 | expect(button.className).toContain("border-input"); | ||
| 1311 | expect(button.className).toContain("bg-popover"); | ||
| 1312 | } finally { | ||
| 1313 | await mounted.cleanup(); | ||
| 1314 | } | ||
| 1315 | }); | ||
| 1316 | }); | ||
apps/web/src/components/chat/ProviderModelPicker.tsx-8| ... | @@ -17,7 +17,6 @@ import { | ... | @@ -17,7 +17,6 @@ import { |
| 17 | getTriggerDisplayModelLabel, | 17 | getTriggerDisplayModelLabel, |
| 18 | getTriggerDisplayModelName, | 18 | getTriggerDisplayModelName, |
| 19 | } from "./providerIconUtils"; | 19 | } from "./providerIconUtils"; |
| 20 | import { setModelPickerOpen } from "../../modelPickerOpenState"; | ||
| 21 | import type { ProviderInstanceEntry } from "../../providerInstances"; | 20 | import type { ProviderInstanceEntry } from "../../providerInstances"; |
| 22 | 21 | ||
| 23 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | 22 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| ... | @@ -79,13 +78,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -79,13 +78,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 79 | } | 78 | } |
| 80 | }; | 79 | }; |
| 81 | 80 | ||
| 82 | useEffect(() => { | ||
| 83 | setModelPickerOpen(isMenuOpen); | ||
| 84 | return () => { | ||
| 85 | setModelPickerOpen(false); | ||
| 86 | }; | ||
| 87 | }, [isMenuOpen]); | ||
| 88 | |||
| 89 | useEffect(() => { | 81 | useEffect(() => { |
| 90 | if (!isMenuOpen) { | 82 | if (!isMenuOpen) { |
| 91 | return; | 83 | return; |
apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx deleted-71| ... | @@ -1,71 +0,0 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import { page, userEvent } from "vite-plus/test/browser"; | ||
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | import { render } from "vitest-browser-react"; | ||
| 6 | |||
| 7 | import type { DesktopCloudAuthOAuthOption } from "../../cloud/desktopAuth"; | ||
| 8 | import { DesktopClerkSignInCard } from "./DesktopClerkSignIn"; | ||
| 9 | |||
| 10 | const GOOGLE: DesktopCloudAuthOAuthOption = { | ||
| 11 | strategy: "oauth_google", | ||
| 12 | label: "Google", | ||
| 13 | providerId: "google", | ||
| 14 | iconUrl: null, | ||
| 15 | }; | ||
| 16 | |||
| 17 | const PROVIDERS: readonly DesktopCloudAuthOAuthOption[] = [ | ||
| 18 | { | ||
| 19 | strategy: "oauth_apple", | ||
| 20 | label: "Apple", | ||
| 21 | providerId: "apple", | ||
| 22 | iconUrl: null, | ||
| 23 | }, | ||
| 24 | GOOGLE, | ||
| 25 | { | ||
| 26 | strategy: "oauth_microsoft", | ||
| 27 | label: "Microsoft", | ||
| 28 | providerId: "microsoft", | ||
| 29 | iconUrl: null, | ||
| 30 | }, | ||
| 31 | ]; | ||
| 32 | |||
| 33 | describe("DesktopClerkSignInCard", () => { | ||
| 34 | afterEach(() => { | ||
| 35 | document.body.innerHTML = ""; | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("uses Clerk's compact provider grid when more than two providers are enabled", async () => { | ||
| 39 | await render( | ||
| 40 | <DesktopClerkSignInCard | ||
| 41 | isStarting={false} | ||
| 42 | oauthOptions={PROVIDERS} | ||
| 43 | startingStrategy={null} | ||
| 44 | onJoinWaitlist={vi.fn()} | ||
| 45 | onStartOAuth={vi.fn()} | ||
| 46 | />, | ||
| 47 | ); | ||
| 48 | |||
| 49 | expect(document.querySelectorAll('button[aria-label^="Continue with "]')).toHaveLength(3); | ||
| 50 | expect(document.body.textContent).toContain("Want early access?"); | ||
| 51 | expect(document.body.textContent).not.toContain("Continue with Google"); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("renders a full provider label and starts OAuth for a single provider", async () => { | ||
| 55 | const onStartOAuth = vi.fn(); | ||
| 56 | await render( | ||
| 57 | <DesktopClerkSignInCard | ||
| 58 | isStarting={false} | ||
| 59 | oauthOptions={[GOOGLE]} | ||
| 60 | startingStrategy={null} | ||
| 61 | onJoinWaitlist={vi.fn()} | ||
| 62 | onStartOAuth={onStartOAuth} | ||
| 63 | />, | ||
| 64 | ); | ||
| 65 | |||
| 66 | await userEvent.click(page.getByRole("button", { name: "Continue with Google" })); | ||
| 67 | |||
| 68 | expect(document.body.textContent).toContain("Continue with Google"); | ||
| 69 | expect(onStartOAuth).toHaveBeenCalledWith("oauth_google"); | ||
| 70 | }); | ||
| 71 | }); | ||
apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx deleted-47| ... | @@ -1,47 +0,0 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import { page } from "vite-plus/test/browser"; | ||
| 4 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | ||
| 5 | import { render } from "vitest-browser-react"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | finishRelayClientInstall, | ||
| 9 | readRelayClientInstallDialogState, | ||
| 10 | reportRelayClientInstallProgress, | ||
| 11 | requestRelayClientInstallConfirmation, | ||
| 12 | resetRelayClientInstallDialogForTests, | ||
| 13 | } from "../../cloud/relayClientInstallDialog"; | ||
| 14 | import { RelayClientInstallDialog } from "./RelayClientInstallDialog"; | ||
| 15 | |||
| 16 | describe("RelayClientInstallDialog", () => { | ||
| 17 | beforeEach(() => { | ||
| 18 | resetRelayClientInstallDialogForTests(); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("confirms installation and renders streamed progress", async () => { | ||
| 22 | render(<RelayClientInstallDialog />); | ||
| 23 | const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); | ||
| 24 | |||
| 25 | await expect.element(page.getByText("Install relay client?")).toBeInTheDocument(); | ||
| 26 | await expect.element(page.getByText(/version 2026\.5\.2 locally/)).toBeInTheDocument(); | ||
| 27 | |||
| 28 | await page.getByRole("button", { name: "Download and install" }).click(); | ||
| 29 | await expect(confirmation).resolves.toBe(true); | ||
| 30 | await expect | ||
| 31 | .element(page.getByRole("heading", { name: "Installing relay client" })) | ||
| 32 | .toBeInTheDocument(); | ||
| 33 | |||
| 34 | reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); | ||
| 35 | await expect.element(page.getByText("Downloading relay client")).toBeInTheDocument(); | ||
| 36 | await expect | ||
| 37 | .element(page.getByRole("progressbar", { name: "Relay client installation progress" })) | ||
| 38 | .toHaveAttribute("value", "3"); | ||
| 39 | |||
| 40 | finishRelayClientInstall(); | ||
| 41 | expect(readRelayClientInstallDialogState().status).toBe("closing"); | ||
| 42 | await expect | ||
| 43 | .element(page.getByRole("heading", { name: "Installing relay client" })) | ||
| 44 | .not.toBeInTheDocument(); | ||
| 45 | expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); | ||
| 46 | }); | ||
| 47 | }); | ||
apps/web/src/components/desktop/SshPasswordPromptDialog.tsx+50-50| ... | @@ -30,14 +30,7 @@ function getPromptErrorMessage(error: unknown): string { | ... | @@ -30,14 +30,7 @@ function getPromptErrorMessage(error: unknown): string { |
| 30 | 30 | ||
| 31 | export function SshPasswordPromptDialog() { | 31 | export function SshPasswordPromptDialog() { |
| 32 | const [queue, setQueue] = useState<readonly DesktopSshPasswordPromptRequest[]>([]); | 32 | const [queue, setQueue] = useState<readonly DesktopSshPasswordPromptRequest[]>([]); |
| 33 | const [password, setPassword] = useState(""); | ||
| 34 | const [isResponding, setIsResponding] = useState(false); | ||
| 35 | const [now, setNow] = useState(() => Date.now()); | ||
| 36 | const [responseError, setResponseError] = useState<string | null>(null); | ||
| 37 | const currentRequest = queue[0] ?? null; | 33 | const currentRequest = queue[0] ?? null; |
| 38 | const inputRef = useRef<HTMLInputElement | null>(null); | ||
| 39 | const isRespondingRef = useRef(false); | ||
| 40 | const formId = useId(); | ||
| 41 | 34 | ||
| 42 | useEffect(() => { | 35 | useEffect(() => { |
| 43 | const bridge = window.desktopBridge; | 36 | const bridge = window.desktopBridge; |
| ... | @@ -50,14 +43,39 @@ export function SshPasswordPromptDialog() { | ... | @@ -50,14 +43,39 @@ export function SshPasswordPromptDialog() { |
| 50 | }); | 43 | }); |
| 51 | }, []); | 44 | }, []); |
| 52 | 45 | ||
| 53 | useEffect(() => { | 46 | if (!currentRequest) { |
| 54 | setPassword(""); | 47 | return null; |
| 55 | setResponseError(null); | 48 | } |
| 56 | if (!currentRequest) { | 49 | |
| 57 | return; | 50 | return ( |
| 58 | } | 51 | <ActiveSshPasswordPrompt |
| 52 | key={currentRequest.requestId} | ||
| 53 | request={currentRequest} | ||
| 54 | onRemove={(requestId) => { | ||
| 55 | setQueue((currentQueue) => | ||
| 56 | currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, | ||
| 57 | ); | ||
| 58 | }} | ||
| 59 | /> | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | |||
| 63 | function ActiveSshPasswordPrompt({ | ||
| 64 | request, | ||
| 65 | onRemove, | ||
| 66 | }: { | ||
| 67 | readonly request: DesktopSshPasswordPromptRequest; | ||
| 68 | readonly onRemove: (requestId: string) => void; | ||
| 69 | }) { | ||
| 70 | const [password, setPassword] = useState(""); | ||
| 71 | const [isResponding, setIsResponding] = useState(false); | ||
| 72 | const [now, setNow] = useState(() => Date.now()); | ||
| 73 | const [responseError, setResponseError] = useState<string | null>(null); | ||
| 74 | const inputRef = useRef<HTMLInputElement | null>(null); | ||
| 75 | const isRespondingRef = useRef(false); | ||
| 76 | const formId = useId(); | ||
| 59 | 77 | ||
| 60 | setNow(Date.now()); | 78 | useEffect(() => { |
| 61 | const frame = window.requestAnimationFrame(() => { | 79 | const frame = window.requestAnimationFrame(() => { |
| 62 | inputRef.current?.focus(); | 80 | inputRef.current?.focus(); |
| 63 | inputRef.current?.select(); | 81 | inputRef.current?.select(); |
| ... | @@ -65,48 +83,33 @@ export function SshPasswordPromptDialog() { | ... | @@ -65,48 +83,33 @@ export function SshPasswordPromptDialog() { |
| 65 | return () => { | 83 | return () => { |
| 66 | window.cancelAnimationFrame(frame); | 84 | window.cancelAnimationFrame(frame); |
| 67 | }; | 85 | }; |
| 68 | }, [currentRequest]); | 86 | }, []); |
| 69 | 87 | ||
| 70 | useEffect(() => { | 88 | useEffect(() => { |
| 71 | if (!currentRequest) { | ||
| 72 | return; | ||
| 73 | } | ||
| 74 | |||
| 75 | const interval = window.setInterval(() => { | 89 | const interval = window.setInterval(() => { |
| 76 | setNow(Date.now()); | 90 | setNow(Date.now()); |
| 77 | }, 1_000); | 91 | }, 1_000); |
| 78 | return () => { | 92 | return () => { |
| 79 | window.clearInterval(interval); | 93 | window.clearInterval(interval); |
| 80 | }; | 94 | }; |
| 81 | }, [currentRequest]); | 95 | }, []); |
| 82 | 96 | ||
| 83 | const expiresAtMs = currentRequest ? Date.parse(currentRequest.expiresAt) : Number.NaN; | 97 | const expiresAtMs = Date.parse(request.expiresAt); |
| 84 | const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; | 98 | const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; |
| 85 | const isExpired = remainingMs !== null && remainingMs <= 0; | 99 | const isExpired = remainingMs !== null && remainingMs <= 0; |
| 86 | const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000); | 100 | const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000); |
| 87 | const remainingLabel = | 101 | const remainingLabel = |
| 88 | remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds); | 102 | remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds); |
| 89 | 103 | const visibleResponseError = isExpired | |
| 90 | useEffect(() => { | 104 | ? "This SSH password prompt expired. Try connecting again." |
| 91 | if (isExpired) { | 105 | : responseError; |
| 92 | setResponseError("This SSH password prompt expired. Try connecting again."); | ||
| 93 | } | ||
| 94 | }, [isExpired]); | ||
| 95 | |||
| 96 | const removeCurrentPrompt = (requestId: string) => { | ||
| 97 | setQueue((currentQueue) => | ||
| 98 | currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, | ||
| 99 | ); | ||
| 100 | setPassword(""); | ||
| 101 | setResponseError(null); | ||
| 102 | }; | ||
| 103 | 106 | ||
| 104 | const respond = async (nextPassword: string | null) => { | 107 | const respond = async (nextPassword: string | null) => { |
| 105 | if (!currentRequest || isRespondingRef.current) { | 108 | if (isRespondingRef.current) { |
| 106 | return; | 109 | return; |
| 107 | } | 110 | } |
| 108 | 111 | ||
| 109 | const requestId = currentRequest.requestId; | 112 | const requestId = request.requestId; |
| 110 | if (nextPassword !== null && isExpired) { | 113 | if (nextPassword !== null && isExpired) { |
| 111 | setResponseError("This SSH password prompt expired. Try connecting again."); | 114 | setResponseError("This SSH password prompt expired. Try connecting again."); |
| 112 | return; | 115 | return; |
| ... | @@ -117,10 +120,10 @@ export function SshPasswordPromptDialog() { | ... | @@ -117,10 +120,10 @@ export function SshPasswordPromptDialog() { |
| 117 | setResponseError(null); | 120 | setResponseError(null); |
| 118 | try { | 121 | try { |
| 119 | await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword); | 122 | await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword); |
| 120 | removeCurrentPrompt(requestId); | 123 | onRemove(requestId); |
| 121 | } catch (error) { | 124 | } catch (error) { |
| 122 | if (nextPassword === null) { | 125 | if (nextPassword === null) { |
| 123 | removeCurrentPrompt(requestId); | 126 | onRemove(requestId); |
| 124 | } else { | 127 | } else { |
| 125 | setResponseError(getPromptErrorMessage(error)); | 128 | setResponseError(getPromptErrorMessage(error)); |
| 126 | } | 129 | } |
| ... | @@ -131,9 +134,7 @@ export function SshPasswordPromptDialog() { | ... | @@ -131,9 +134,7 @@ export function SshPasswordPromptDialog() { |
| 131 | }; | 134 | }; |
| 132 | 135 | ||
| 133 | const dismissExpiredPrompt = () => { | 136 | const dismissExpiredPrompt = () => { |
| 134 | if (currentRequest) { | 137 | onRemove(request.requestId); |
| 135 | removeCurrentPrompt(currentRequest.requestId); | ||
| 136 | } | ||
| 137 | }; | 138 | }; |
| 138 | 139 | ||
| 139 | const cancelPrompt = () => { | 140 | const cancelPrompt = () => { |
| ... | @@ -144,11 +145,11 @@ export function SshPasswordPromptDialog() { | ... | @@ -144,11 +145,11 @@ export function SshPasswordPromptDialog() { |
| 144 | void respond(null); | 145 | void respond(null); |
| 145 | }; | 146 | }; |
| 146 | 147 | ||
| 147 | const target = currentRequest ? describeSshTarget(currentRequest) : null; | 148 | const target = describeSshTarget(request); |
| 148 | 149 | ||
| 149 | return ( | 150 | return ( |
| 150 | <Dialog | 151 | <Dialog |
| 151 | open={currentRequest !== null} | 152 | open |
| 152 | onOpenChange={(open) => { | 153 | onOpenChange={(open) => { |
| 153 | if (!open) { | 154 | if (!open) { |
| 154 | cancelPrompt(); | 155 | cancelPrompt(); |
| ... | @@ -159,9 +160,8 @@ export function SshPasswordPromptDialog() { | ... | @@ -159,9 +160,8 @@ export function SshPasswordPromptDialog() { |
| 159 | <DialogHeader> | 160 | <DialogHeader> |
| 160 | <DialogTitle>SSH Password Required</DialogTitle> | 161 | <DialogTitle>SSH Password Required</DialogTitle> |
| 161 | <DialogDescription> | 162 | <DialogDescription> |
| 162 | T3 needs your SSH password to connect to{" "} | 163 | T3 needs your SSH password to connect to <code>{target}</code>. The password is passed |
| 163 | {target ? <code>{target}</code> : "the remote host"}. The password is passed to the | 164 | to the local SSH process for this connection attempt and is not saved by T3 Code. |
| 164 | local SSH process for this connection attempt and is not saved by T3 Code. | ||
| 165 | </DialogDescription> | 165 | </DialogDescription> |
| 166 | </DialogHeader> | 166 | </DialogHeader> |
| 167 | <DialogPanel className="space-y-3" scrollFade={false}> | 167 | <DialogPanel className="space-y-3" scrollFade={false}> |
| ... | @@ -175,7 +175,7 @@ export function SshPasswordPromptDialog() { | ... | @@ -175,7 +175,7 @@ export function SshPasswordPromptDialog() { |
| 175 | > | 175 | > |
| 176 | <div className="space-y-2"> | 176 | <div className="space-y-2"> |
| 177 | <div className="flex items-center justify-between gap-3"> | 177 | <div className="flex items-center justify-between gap-3"> |
| 178 | <p className="text-sm font-medium text-foreground">{currentRequest?.prompt}</p> | 178 | <p className="text-sm font-medium text-foreground">{request.prompt}</p> |
| 179 | {remainingLabel ? ( | 179 | {remainingLabel ? ( |
| 180 | <span | 180 | <span |
| 181 | className={ | 181 | className={ |
| ... | @@ -198,8 +198,8 @@ export function SshPasswordPromptDialog() { | ... | @@ -198,8 +198,8 @@ export function SshPasswordPromptDialog() { |
| 198 | onChange={(event) => setPassword(event.target.value)} | 198 | onChange={(event) => setPassword(event.target.value)} |
| 199 | /> | 199 | /> |
| 200 | </div> | 200 | </div> |
| 201 | {responseError ? ( | 201 | {visibleResponseError ? ( |
| 202 | <p className="text-sm text-destructive">{responseError}</p> | 202 | <p className="text-sm text-destructive">{visibleResponseError}</p> |
| 203 | ) : ( | 203 | ) : ( |
| 204 | <p className="text-sm text-muted-foreground"> | 204 | <p className="text-sm text-muted-foreground"> |
| 205 | Use SSH keys to avoid repeated password prompts on new SSH sessions. | 205 | Use SSH keys to avoid repeated password prompts on new SSH sessions. |
apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx deleted-122| ... | @@ -1,122 +0,0 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; | ||
| 4 | import { scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 5 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 6 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 7 | import { page } from "vite-plus/test/browser"; | ||
| 8 | import { render } from "vitest-browser-react"; | ||
| 9 | |||
| 10 | import { useComposerDraftStore } from "~/composerDraftStore"; | ||
| 11 | |||
| 12 | import { AnnotatableFileDiff } from "./AnnotatableFileDiff"; | ||
| 13 | |||
| 14 | function dispatchPointer(target: EventTarget, type: string, pointerId: number): void { | ||
| 15 | target.dispatchEvent( | ||
| 16 | new PointerEvent(type, { | ||
| 17 | bubbles: true, | ||
| 18 | cancelable: true, | ||
| 19 | composed: true, | ||
| 20 | pointerId, | ||
| 21 | pointerType: "mouse", | ||
| 22 | }), | ||
| 23 | ); | ||
| 24 | } | ||
| 25 | |||
| 26 | const threadRef = scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("thread-1")); | ||
| 27 | |||
| 28 | function TestDiff() { | ||
| 29 | const fileDiff = parsePatchFiles( | ||
| 30 | [ | ||
| 31 | "diff --git a/src/app.ts b/src/app.ts", | ||
| 32 | "--- a/src/app.ts", | ||
| 33 | "+++ b/src/app.ts", | ||
| 34 | "@@ -1,3 +1,3 @@", | ||
| 35 | " one", | ||
| 36 | "-two", | ||
| 37 | "+TWO", | ||
| 38 | " three", | ||
| 39 | ].join("\n"), | ||
| 40 | "annotatable-file-diff-test", | ||
| 41 | )[0]!.files[0]!; | ||
| 42 | |||
| 43 | return ( | ||
| 44 | <AnnotatableFileDiff | ||
| 45 | fileDiff={fileDiff} | ||
| 46 | filePath="src/app.ts" | ||
| 47 | sectionId="turn:2" | ||
| 48 | sectionTitle="Turn 2" | ||
| 49 | composerDraftTarget={threadRef} | ||
| 50 | renderHeaderPrefix={() => null} | ||
| 51 | options={{ | ||
| 52 | diffStyle: "unified", | ||
| 53 | lineDiffType: "none", | ||
| 54 | themeType: "light", | ||
| 55 | }} | ||
| 56 | /> | ||
| 57 | ); | ||
| 58 | } | ||
| 59 | |||
| 60 | async function getRenderedDiff() { | ||
| 61 | return vi.waitFor(() => { | ||
| 62 | const element = document.querySelector<HTMLElement>("diffs-container"); | ||
| 63 | expect(element?.shadowRoot).not.toBeNull(); | ||
| 64 | return element!; | ||
| 65 | }); | ||
| 66 | } | ||
| 67 | |||
| 68 | describe("annotatable Pierre file diff", () => { | ||
| 69 | afterEach(() => { | ||
| 70 | document.body.innerHTML = ""; | ||
| 71 | useComposerDraftStore.getState().setReviewComments(threadRef, []); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("creates a local annotation from the gutter and attaches it to the composer", async () => { | ||
| 75 | let screen = await render(<TestDiff />); | ||
| 76 | |||
| 77 | try { | ||
| 78 | const diff = await getRenderedDiff(); | ||
| 79 | const addedLineNumber = await vi.waitFor(() => { | ||
| 80 | const elements = Array.from( | ||
| 81 | diff.shadowRoot?.querySelectorAll<HTMLElement>('[data-column-number="2"]') ?? [], | ||
| 82 | ); | ||
| 83 | const element = elements.at(-1) ?? null; | ||
| 84 | expect(element).not.toBeNull(); | ||
| 85 | return element!; | ||
| 86 | }); | ||
| 87 | |||
| 88 | dispatchPointer(addedLineNumber, "pointerdown", 1); | ||
| 89 | dispatchPointer(addedLineNumber, "pointerup", 1); | ||
| 90 | |||
| 91 | const textarea = page.getByRole("textbox", { name: "Comment on lines +2" }); | ||
| 92 | await expect.element(textarea).toBeVisible(); | ||
| 93 | await textarea.fill("Use the compatible value."); | ||
| 94 | await page.getByRole("button", { name: "Comment" }).click(); | ||
| 95 | |||
| 96 | await vi.waitFor(() => { | ||
| 97 | expect( | ||
| 98 | useComposerDraftStore.getState().getComposerDraft(threadRef)?.reviewComments, | ||
| 99 | ).toEqual([ | ||
| 100 | expect.objectContaining({ | ||
| 101 | sectionId: "turn:2", | ||
| 102 | filePath: "src/app.ts", | ||
| 103 | rangeLabel: "+2", | ||
| 104 | text: "Use the compatible value.", | ||
| 105 | diff: "@@ -0,0 +2,1 @@\n+TWO", | ||
| 106 | }), | ||
| 107 | ]); | ||
| 108 | }); | ||
| 109 | expect(document.querySelector("[data-file-comment-annotation]")?.textContent).toContain( | ||
| 110 | "Use the compatible value.", | ||
| 111 | ); | ||
| 112 | |||
| 113 | await screen.unmount(); | ||
| 114 | screen = await render(<TestDiff />); | ||
| 115 | await expect | ||
| 116 | .element(page.getByText("Use the compatible value.", { exact: true })) | ||
| 117 | .toBeVisible(); | ||
| 118 | } finally { | ||
| 119 | await screen.unmount(); | ||
| 120 | } | ||
| 121 | }); | ||
| 122 | }); | ||
apps/web/src/components/files/FilePreviewPanel.browser.tsx deleted-168| ... | @@ -1,168 +0,0 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import type { LineAnnotation, SelectedLineRange } from "@pierre/diffs"; | ||
| 4 | import { Editor } from "@pierre/diffs/editor"; | ||
| 5 | import { EditorProvider, File } from "@pierre/diffs/react"; | ||
| 6 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 7 | import { page } from "vite-plus/test/browser"; | ||
| 8 | import { render } from "vitest-browser-react"; | ||
| 9 | import { useEffect, useMemo, useRef, useState } from "react"; | ||
| 10 | |||
| 11 | import { installFileEditorDismissal } from "./fileEditorDismissal"; | ||
| 12 | |||
| 13 | interface AnnotationMetadata { | ||
| 14 | label: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | function dispatchPointer(target: EventTarget, type: string, pointerId: number): void { | ||
| 18 | target.dispatchEvent( | ||
| 19 | new PointerEvent(type, { | ||
| 20 | bubbles: true, | ||
| 21 | cancelable: true, | ||
| 22 | composed: true, | ||
| 23 | pointerId, | ||
| 24 | pointerType: "mouse", | ||
| 25 | }), | ||
| 26 | ); | ||
| 27 | } | ||
| 28 | |||
| 29 | function EditableAnnotatedFile() { | ||
| 30 | const [selectedLines, setSelectedLines] = useState<SelectedLineRange | null>(null); | ||
| 31 | const [lineAnnotations, setLineAnnotations] = useState<LineAnnotation<AnnotationMetadata>[]>([]); | ||
| 32 | const rootRef = useRef<HTMLDivElement>(null); | ||
| 33 | const editor = useMemo(() => new Editor<AnnotationMetadata>(), []); | ||
| 34 | |||
| 35 | useEffect(() => () => editor.cleanUp(), [editor]); | ||
| 36 | useEffect(() => { | ||
| 37 | const root = rootRef.current; | ||
| 38 | if (!root) return; | ||
| 39 | return installFileEditorDismissal({ | ||
| 40 | root, | ||
| 41 | editor, | ||
| 42 | isBlocked: () => false, | ||
| 43 | onDismiss: () => setSelectedLines(null), | ||
| 44 | }); | ||
| 45 | }, [editor]); | ||
| 46 | |||
| 47 | return ( | ||
| 48 | <> | ||
| 49 | <div ref={rootRef}> | ||
| 50 | <EditorProvider editor={editor}> | ||
| 51 | <File<AnnotationMetadata> | ||
| 52 | file={{ name: "example.ts", contents: "one\ntwo\nthree\n" }} | ||
| 53 | options={{ | ||
| 54 | disableFileHeader: true, | ||
| 55 | enableGutterUtility: true, | ||
| 56 | enableLineSelection: true, | ||
| 57 | onGutterUtilityClick: setSelectedLines, | ||
| 58 | onLineSelectionChange: setSelectedLines, | ||
| 59 | onLineSelectionEnd: (range) => { | ||
| 60 | setSelectedLines(range); | ||
| 61 | if (range) { | ||
| 62 | setLineAnnotations([ | ||
| 63 | { | ||
| 64 | lineNumber: Math.max(range.start, range.end), | ||
| 65 | metadata: { label: `${range.start}:${range.end}` }, | ||
| 66 | }, | ||
| 67 | ]); | ||
| 68 | } | ||
| 69 | }, | ||
| 70 | }} | ||
| 71 | selectedLines={selectedLines} | ||
| 72 | lineAnnotations={lineAnnotations} | ||
| 73 | renderAnnotation={(annotation) => ( | ||
| 74 | <div data-test-file-annotation contentEditable={false}> | ||
| 75 | {annotation.metadata.label} | ||
| 76 | </div> | ||
| 77 | )} | ||
| 78 | disableWorkerPool | ||
| 79 | contentEditable | ||
| 80 | /> | ||
| 81 | </EditorProvider> | ||
| 82 | </div> | ||
| 83 | <button type="button">Outside file</button> | ||
| 84 | </> | ||
| 85 | ); | ||
| 86 | } | ||
| 87 | |||
| 88 | async function getEditableFile() { | ||
| 89 | const file = await vi.waitFor(() => { | ||
| 90 | const element = document.querySelector<HTMLElement>("diffs-container"); | ||
| 91 | expect(element?.shadowRoot).not.toBeNull(); | ||
| 92 | return element!; | ||
| 93 | }); | ||
| 94 | const content = await vi.waitFor(() => { | ||
| 95 | const element = file?.shadowRoot?.querySelector<HTMLElement>("[data-content]") ?? null; | ||
| 96 | expect(element).not.toBeNull(); | ||
| 97 | return element!; | ||
| 98 | }); | ||
| 99 | return { file, content }; | ||
| 100 | } | ||
| 101 | |||
| 102 | describe("editable Pierre file annotations", () => { | ||
| 103 | afterEach(() => { | ||
| 104 | document.body.innerHTML = ""; | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("keeps gutter selection and annotations enabled while the file is editable", async () => { | ||
| 108 | const screen = await render(<EditableAnnotatedFile />); | ||
| 109 | |||
| 110 | try { | ||
| 111 | const { file, content } = await getEditableFile(); | ||
| 112 | const secondLineNumber = await vi.waitFor(() => { | ||
| 113 | const element = | ||
| 114 | file?.shadowRoot?.querySelector<HTMLElement>('[data-column-number="2"]') ?? null; | ||
| 115 | expect(element).not.toBeNull(); | ||
| 116 | return element; | ||
| 117 | }); | ||
| 118 | await vi.waitFor(() => { | ||
| 119 | expect( | ||
| 120 | file?.shadowRoot?.querySelector("pre")?.hasAttribute("data-interactive-line-numbers"), | ||
| 121 | ).toBe(true); | ||
| 122 | }); | ||
| 123 | |||
| 124 | dispatchPointer(secondLineNumber!, "pointerdown", 1); | ||
| 125 | dispatchPointer(secondLineNumber!, "pointerup", 1); | ||
| 126 | |||
| 127 | await vi.waitFor(() => { | ||
| 128 | expect(document.querySelector("[data-test-file-annotation]")?.textContent).toBe("2:2"); | ||
| 129 | }); | ||
| 130 | |||
| 131 | expect(content.contentEditable).toBe("true"); | ||
| 132 | expect(content.getAttribute("role")).toBe("textbox"); | ||
| 133 | } finally { | ||
| 134 | await screen.unmount(); | ||
| 135 | } | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("dismisses editor focus and selection with outside click or Escape", async () => { | ||
| 139 | const screen = await render(<EditableAnnotatedFile />); | ||
| 140 | |||
| 141 | try { | ||
| 142 | const { file, content } = await getEditableFile(); | ||
| 143 | content.focus(); | ||
| 144 | expect(file?.shadowRoot?.activeElement).toBe(content); | ||
| 145 | |||
| 146 | await page.getByRole("button", { name: "Outside file" }).click(); | ||
| 147 | await vi.waitFor(() => { | ||
| 148 | expect(file?.shadowRoot?.activeElement).not.toBe(content); | ||
| 149 | }); | ||
| 150 | |||
| 151 | content.focus(); | ||
| 152 | expect(file?.shadowRoot?.activeElement).toBe(content); | ||
| 153 | content.dispatchEvent( | ||
| 154 | new KeyboardEvent("keydown", { | ||
| 155 | key: "Escape", | ||
| 156 | bubbles: true, | ||
| 157 | cancelable: true, | ||
| 158 | composed: true, | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | await vi.waitFor(() => { | ||
| 162 | expect(file?.shadowRoot?.activeElement).not.toBe(content); | ||
| 163 | }); | ||
| 164 | } finally { | ||
| 165 | await screen.unmount(); | ||
| 166 | } | ||
| 167 | }); | ||
| 168 | }); | ||
apps/web/src/components/files/FilePreviewPanel.tsx+41-15| ... | @@ -7,14 +7,16 @@ import type { | ... | @@ -7,14 +7,16 @@ import type { |
| 7 | import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; | 7 | import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; |
| 8 | import { Editor } from "@pierre/diffs/editor"; | 8 | import { Editor } from "@pierre/diffs/editor"; |
| 9 | import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; | 9 | import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; |
| 10 | import { | ||
| 11 | isAtomCommandInterrupted, | ||
| 12 | squashAtomCommandFailure, | ||
| 13 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 10 | import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; | 14 | import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; |
| 11 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 15 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 12 | 16 | ||
| 13 | import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; | 17 | import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; |
| 14 | import ChatMarkdown from "~/components/ChatMarkdown"; | 18 | import ChatMarkdown from "~/components/ChatMarkdown"; |
| 15 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; | 19 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 16 | import { ensureEnvironmentApi } from "~/environmentApi"; | ||
| 17 | import { usePrimaryEnvironmentId } from "~/environments/primary/context"; | ||
| 18 | import { useTheme } from "~/hooks/useTheme"; | 20 | import { useTheme } from "~/hooks/useTheme"; |
| 19 | import { resolveDiffThemeName } from "~/lib/diffRendering"; | 21 | import { resolveDiffThemeName } from "~/lib/diffRendering"; |
| 20 | import { cn } from "~/lib/utils"; | 22 | import { cn } from "~/lib/utils"; |
| ... | @@ -26,6 +28,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | ... | @@ -26,6 +28,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 26 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | 28 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 27 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 29 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 28 | import { buildFileReviewComment } from "~/reviewCommentContext"; | 30 | import { buildFileReviewComment } from "~/reviewCommentContext"; |
| 31 | import { assetEnvironment } from "~/state/assets"; | ||
| 32 | import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; | ||
| 33 | import { previewEnvironment } from "~/state/preview"; | ||
| 34 | import { projectEnvironment } from "~/state/projects"; | ||
| 35 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 36 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | ||
| 29 | 37 | ||
| 30 | import FileBrowserPanel from "./FileBrowserPanel"; | 38 | import FileBrowserPanel from "./FileBrowserPanel"; |
| 31 | import { | 39 | import { |
| ... | @@ -256,23 +264,22 @@ function useFileSaveCoordinator({ | ... | @@ -256,23 +264,22 @@ function useFileSaveCoordinator({ |
| 256 | EditableFileSurfaceProps, | 264 | EditableFileSurfaceProps, |
| 257 | "environmentId" | "cwd" | "relativePath" | "onPendingChange" | 265 | "environmentId" | "cwd" | "relativePath" | "onPendingChange" |
| 258 | >): FileSaveCoordinator { | 266 | >): FileSaveCoordinator { |
| 267 | const writeFile = useAtomCommand(projectEnvironment.writeFile); | ||
| 259 | const coordinator = useMemo( | 268 | const coordinator = useMemo( |
| 260 | () => | 269 | () => |
| 261 | new FileSaveCoordinator({ | 270 | new FileSaveCoordinator({ |
| 262 | debounceMs: FILE_SAVE_DEBOUNCE_MS, | 271 | debounceMs: FILE_SAVE_DEBOUNCE_MS, |
| 263 | onPendingChange: (pending) => onPendingChange(relativePath, pending), | 272 | onPendingChange: (pending) => onPendingChange(relativePath, pending), |
| 264 | persist: async (nextContents) => { | 273 | persist: (nextContents) => |
| 265 | await ensureEnvironmentApi(environmentId).projects.writeFile({ | 274 | writeFile({ |
| 266 | cwd, | 275 | environmentId, |
| 267 | relativePath, | 276 | input: { cwd, relativePath, contents: nextContents }, |
| 268 | contents: nextContents, | 277 | }), |
| 269 | }); | ||
| 270 | }, | ||
| 271 | onConfirmed: (confirmedContents) => { | 278 | onConfirmed: (confirmedContents) => { |
| 272 | confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); | 279 | confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); |
| 273 | }, | 280 | }, |
| 274 | }), | 281 | }), |
| 275 | [cwd, environmentId, onPendingChange, relativePath], | 282 | [cwd, environmentId, onPendingChange, relativePath, writeFile], |
| 276 | ); | 283 | ); |
| 277 | 284 | ||
| 278 | useEffect(() => () => coordinator.dispose(), [coordinator]); | 285 | useEffect(() => () => coordinator.dispose(), [coordinator]); |
| ... | @@ -604,6 +611,13 @@ export default function FilePreviewPanel({ | ... | @@ -604,6 +611,13 @@ export default function FilePreviewPanel({ |
| 604 | }: FilePreviewPanelProps) { | 611 | }: FilePreviewPanelProps) { |
| 605 | const { resolvedTheme } = useTheme(); | 612 | const { resolvedTheme } = useTheme(); |
| 606 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 613 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 614 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); | ||
| 615 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | ||
| 616 | reportFailure: false, | ||
| 617 | }); | ||
| 618 | const openPreview = useAtomCommand(previewEnvironment.open, { | ||
| 619 | reportFailure: false, | ||
| 620 | }); | ||
| 607 | const file = useProjectFileQuery(environmentId, cwd, relativePath); | 621 | const file = useProjectFileQuery(environmentId, cwd, relativePath); |
| 608 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); | 622 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); |
| 609 | const [markdownView, setMarkdownView] = useState<{ | 623 | const [markdownView, setMarkdownView] = useState<{ |
| ... | @@ -642,9 +656,20 @@ export default function FilePreviewPanel({ | ... | @@ -642,9 +656,20 @@ export default function FilePreviewPanel({ |
| 642 | }); | 656 | }); |
| 643 | }; | 657 | }; |
| 644 | 658 | ||
| 645 | const handleOpenInBrowser = () => { | 659 | const handleOpenInBrowser = useCallback(() => { |
| 646 | if (!absolutePath) return; | 660 | if (!absolutePath || !environmentHttpBaseUrl) return; |
| 647 | void openFileInPreview(threadRef, absolutePath).catch((error) => { | 661 | void (async () => { |
| 662 | const result = await openFileInPreview({ | ||
| 663 | threadRef, | ||
| 664 | filePath: absolutePath, | ||
| 665 | httpBaseUrl: environmentHttpBaseUrl, | ||
| 666 | createAssetUrl, | ||
| 667 | openPreview, | ||
| 668 | }); | ||
| 669 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | ||
| 670 | return; | ||
| 671 | } | ||
| 672 | const error = squashAtomCommandFailure(result); | ||
| 648 | toastManager.add( | 673 | toastManager.add( |
| 649 | stackedThreadToast({ | 674 | stackedThreadToast({ |
| 650 | type: "error", | 675 | type: "error", |
| ... | @@ -652,8 +677,8 @@ export default function FilePreviewPanel({ | ... | @@ -652,8 +677,8 @@ export default function FilePreviewPanel({ |
| 652 | description: error instanceof Error ? error.message : "An error occurred.", | 677 | description: error instanceof Error ? error.message : "An error occurred.", |
| 653 | }), | 678 | }), |
| 654 | ); | 679 | ); |
| 655 | }); | 680 | })(); |
| 656 | }; | 681 | }, [absolutePath, createAssetUrl, environmentHttpBaseUrl, openPreview, threadRef]); |
| 657 | 682 | ||
| 658 | return ( | 683 | return ( |
| 659 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"> | 684 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"> |
| ... | @@ -693,6 +718,7 @@ export default function FilePreviewPanel({ | ... | @@ -693,6 +718,7 @@ export default function FilePreviewPanel({ |
| 693 | </ScrollArea> | 718 | </ScrollArea> |
| 694 | {absolutePath && environmentId === primaryEnvironmentId ? ( | 719 | {absolutePath && environmentId === primaryEnvironmentId ? ( |
| 695 | <OpenInPicker | 720 | <OpenInPicker |
| 721 | environmentId={environmentId} | ||
| 696 | keybindings={keybindings} | 722 | keybindings={keybindings} |
| 697 | availableEditors={availableEditors} | 723 | availableEditors={availableEditors} |
| 698 | openInCwd={absolutePath} | 724 | openInCwd={absolutePath} |
apps/web/src/components/files/fileSaveCoordinator.test.ts+14-7| ... | @@ -1,10 +1,13 @@ | ... | @@ -1,10 +1,13 @@ |
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 2 | 5 | ||
| 3 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; | 6 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; |
| 4 | 7 | ||
| 5 | function deferred() { | 8 | function deferred() { |
| 6 | let resolve!: () => void; | 9 | let resolve!: (result: AtomCommandResult<void, never>) => void; |
| 7 | const promise = new Promise<void>((resolvePromise) => { | 10 | const promise = new Promise<AtomCommandResult<void, never>>((resolvePromise) => { |
| 8 | resolve = resolvePromise; | 11 | resolve = resolvePromise; |
| 9 | }); | 12 | }); |
| 10 | return { promise, resolve }; | 13 | return { promise, resolve }; |
| ... | @@ -17,7 +20,9 @@ describe("FileSaveCoordinator", () => { | ... | @@ -17,7 +20,9 @@ describe("FileSaveCoordinator", () => { |
| 17 | 20 | ||
| 18 | it("debounces edits and persists only the latest contents", async () => { | 21 | it("debounces edits and persists only the latest contents", async () => { |
| 19 | vi.useFakeTimers(); | 22 | vi.useFakeTimers(); |
| 20 | const persist = vi.fn<(contents: string) => Promise<void>>().mockResolvedValue(undefined); | 23 | const persist = vi |
| 24 | .fn<(contents: string) => Promise<AtomCommandResult<void, never>>>() | ||
| 25 | .mockResolvedValue(AsyncResult.success(undefined)); | ||
| 21 | const onPendingChange = vi.fn(); | 26 | const onPendingChange = vi.fn(); |
| 22 | const onConfirmed = vi.fn(); | 27 | const onConfirmed = vi.fn(); |
| 23 | const coordinator = new FileSaveCoordinator({ | 28 | const coordinator = new FileSaveCoordinator({ |
| ... | @@ -44,9 +49,9 @@ describe("FileSaveCoordinator", () => { | ... | @@ -44,9 +49,9 @@ describe("FileSaveCoordinator", () => { |
| 44 | vi.useFakeTimers(); | 49 | vi.useFakeTimers(); |
| 45 | const firstWrite = deferred(); | 50 | const firstWrite = deferred(); |
| 46 | const persist = vi | 51 | const persist = vi |
| 47 | .fn<(contents: string) => Promise<void>>() | 52 | .fn<(contents: string) => Promise<AtomCommandResult<void, never>>>() |
| 48 | .mockReturnValueOnce(firstWrite.promise) | 53 | .mockReturnValueOnce(firstWrite.promise) |
| 49 | .mockResolvedValueOnce(undefined); | 54 | .mockResolvedValueOnce(AsyncResult.success(undefined)); |
| 50 | const onPendingChange = vi.fn(); | 55 | const onPendingChange = vi.fn(); |
| 51 | const coordinator = new FileSaveCoordinator({ | 56 | const coordinator = new FileSaveCoordinator({ |
| 52 | debounceMs: 500, | 57 | debounceMs: 500, |
| ... | @@ -61,7 +66,7 @@ describe("FileSaveCoordinator", () => { | ... | @@ -61,7 +66,7 @@ describe("FileSaveCoordinator", () => { |
| 61 | await vi.advanceTimersByTimeAsync(500); | 66 | await vi.advanceTimersByTimeAsync(500); |
| 62 | expect(persist).toHaveBeenCalledTimes(1); | 67 | expect(persist).toHaveBeenCalledTimes(1); |
| 63 | 68 | ||
| 64 | firstWrite.resolve(); | 69 | firstWrite.resolve(AsyncResult.success(undefined)); |
| 65 | await vi.runAllTimersAsync(); | 70 | await vi.runAllTimersAsync(); |
| 66 | expect(persist).toHaveBeenCalledTimes(2); | 71 | expect(persist).toHaveBeenCalledTimes(2); |
| 67 | expect(persist).toHaveBeenLastCalledWith("latest"); | 72 | expect(persist).toHaveBeenLastCalledWith("latest"); |
| ... | @@ -73,7 +78,9 @@ describe("FileSaveCoordinator", () => { | ... | @@ -73,7 +78,9 @@ describe("FileSaveCoordinator", () => { |
| 73 | const onPendingChange = vi.fn(); | 78 | const onPendingChange = vi.fn(); |
| 74 | const coordinator = new FileSaveCoordinator({ | 79 | const coordinator = new FileSaveCoordinator({ |
| 75 | debounceMs: 500, | 80 | debounceMs: 500, |
| 76 | persist: vi.fn().mockRejectedValue(new Error("write failed")), | 81 | persist: vi |
| 82 | .fn() | ||
| 83 | .mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("write failed")))), | ||
| 77 | onPendingChange, | 84 | onPendingChange, |
| 78 | onConfirmed: vi.fn(), | 85 | onConfirmed: vi.fn(), |
| 79 | }); | 86 | }); |
apps/web/src/components/files/fileSaveCoordinator.ts+10-9| ... | @@ -1,11 +1,13 @@ | ... | @@ -1,11 +1,13 @@ |
| 1 | export interface FileSaveCoordinatorOptions { | 1 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 2 | |||
| 3 | export interface FileSaveCoordinatorOptions<A, E> { | ||
| 2 | readonly debounceMs: number; | 4 | readonly debounceMs: number; |
| 3 | readonly persist: (contents: string) => Promise<void>; | 5 | readonly persist: (contents: string) => Promise<AtomCommandResult<A, E>>; |
| 4 | readonly onPendingChange: (pending: boolean) => void; | 6 | readonly onPendingChange: (pending: boolean) => void; |
| 5 | readonly onConfirmed: (contents: string) => void; | 7 | readonly onConfirmed: (contents: string) => void; |
| 6 | } | 8 | } |
| 7 | 9 | ||
| 8 | export class FileSaveCoordinator { | 10 | export class FileSaveCoordinator<A = unknown, E = unknown> { |
| 9 | private timer: ReturnType<typeof setTimeout> | null = null; | 11 | private timer: ReturnType<typeof setTimeout> | null = null; |
| 10 | private latestContents = ""; | 12 | private latestContents = ""; |
| 11 | private latestRevision = 0; | 13 | private latestRevision = 0; |
| ... | @@ -13,7 +15,7 @@ export class FileSaveCoordinator { | ... | @@ -13,7 +15,7 @@ export class FileSaveCoordinator { |
| 13 | private saving = false; | 15 | private saving = false; |
| 14 | private disposed = false; | 16 | private disposed = false; |
| 15 | 17 | ||
| 16 | constructor(private readonly options: FileSaveCoordinatorOptions) {} | 18 | constructor(private readonly options: FileSaveCoordinatorOptions<A, E>) {} |
| 17 | 19 | ||
| 18 | change(contents: string): void { | 20 | change(contents: string): void { |
| 19 | this.latestContents = contents; | 21 | this.latestContents = contents; |
| ... | @@ -49,12 +51,11 @@ export class FileSaveCoordinator { | ... | @@ -49,12 +51,11 @@ export class FileSaveCoordinator { |
| 49 | this.saving = true; | 51 | this.saving = true; |
| 50 | const contents = this.latestContents; | 52 | const contents = this.latestContents; |
| 51 | const revision = this.latestRevision; | 53 | const revision = this.latestRevision; |
| 52 | let succeeded = false; | 54 | const result = await this.options.persist(contents); |
| 53 | try { | 55 | const succeeded = result._tag === "Success"; |
| 54 | await this.options.persist(contents); | 56 | if (succeeded) { |
| 55 | succeeded = true; | ||
| 56 | this.options.onConfirmed(contents); | 57 | this.options.onConfirmed(contents); |
| 57 | } catch {} | 58 | } |
| 58 | 59 | ||
| 59 | this.saving = false; | 60 | this.saving = false; |
| 60 | if (revision === this.latestRevision) { | 61 | if (revision === this.latestRevision) { |
apps/web/src/components/files/projectFilesQueryState.test.ts+5-88| ... | @@ -1,24 +1,10 @@ | ... | @@ -1,24 +1,10 @@ |
| 1 | import type { | 1 | import type { ProjectReadFileResult } from "@t3tools/contracts"; |
| 2 | EnvironmentApi, | ||
| 3 | ProjectListEntriesResult, | ||
| 4 | ProjectReadFileResult, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { EnvironmentId } from "@t3tools/contracts"; | 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 9 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | 4 | ||
| 11 | import { | 5 | import { |
| 12 | __resetEnvironmentApiOverridesForTests, | 6 | clearProjectFileQueryData, |
| 13 | __setEnvironmentApiOverrideForTests, | ||
| 14 | } from "~/environmentApi"; | ||
| 15 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | __resetProjectFileQueryDataForTests, | ||
| 19 | confirmProjectFileQueryData, | 7 | confirmProjectFileQueryData, |
| 20 | getProjectEntriesQueryAtom, | ||
| 21 | getProjectFileQueryAtom, | ||
| 22 | getOptimisticProjectFileQueryData, | 8 | getOptimisticProjectFileQueryData, |
| 23 | resolveProjectFileQueryData, | 9 | resolveProjectFileQueryData, |
| 24 | setProjectFileQueryData, | 10 | setProjectFileQueryData, |
| ... | @@ -26,64 +12,13 @@ import { | ... | @@ -26,64 +12,13 @@ import { |
| 26 | 12 | ||
| 27 | const environmentId = EnvironmentId.make("environment-project-files-query-test"); | 13 | const environmentId = EnvironmentId.make("environment-project-files-query-test"); |
| 28 | 14 | ||
| 29 | function deferred<A>() { | ||
| 30 | let resolve!: (value: A) => void; | ||
| 31 | const promise = new Promise<A>((resolvePromise) => { | ||
| 32 | resolve = resolvePromise; | ||
| 33 | }); | ||
| 34 | return { promise, resolve }; | ||
| 35 | } | ||
| 36 | |||
| 37 | describe("project files queries", () => { | 15 | describe("project files queries", () => { |
| 38 | afterEach(() => { | 16 | afterEach(() => { |
| 39 | __resetProjectFileQueryDataForTests(); | 17 | clearProjectFileQueryData(environmentId, "/repo", "convex.json"); |
| 40 | __resetEnvironmentApiOverridesForTests(); | ||
| 41 | vi.unstubAllGlobals(); | 18 | vi.unstubAllGlobals(); |
| 42 | }); | 19 | }); |
| 43 | 20 | ||
| 44 | it("retains cached entries while explicitly revalidating", async () => { | 21 | it("keeps the latest optimistic draft when an older write finishes", () => { |
| 45 | vi.stubGlobal("window", {}); | ||
| 46 | const first = { | ||
| 47 | entries: [{ path: "README.md", kind: "file" }], | ||
| 48 | truncated: false, | ||
| 49 | } satisfies ProjectListEntriesResult; | ||
| 50 | const second = { | ||
| 51 | entries: [ | ||
| 52 | { path: "README.md", kind: "file" }, | ||
| 53 | { path: "src", kind: "directory" }, | ||
| 54 | ], | ||
| 55 | truncated: false, | ||
| 56 | } satisfies ProjectListEntriesResult; | ||
| 57 | const revalidation = deferred<ProjectListEntriesResult>(); | ||
| 58 | const listEntries = vi | ||
| 59 | .fn<EnvironmentApi["projects"]["listEntries"]>() | ||
| 60 | .mockResolvedValueOnce(first) | ||
| 61 | .mockReturnValueOnce(revalidation.promise); | ||
| 62 | __setEnvironmentApiOverrideForTests(environmentId, { | ||
| 63 | projects: { listEntries }, | ||
| 64 | } as unknown as EnvironmentApi); | ||
| 65 | const registry = AtomRegistry.make(); | ||
| 66 | const atom = getProjectEntriesQueryAtom(environmentId, "/repo"); | ||
| 67 | |||
| 68 | registry.get(atom); | ||
| 69 | await vi.waitFor(() => { | ||
| 70 | expect(Option.getOrNull(AsyncResult.value(registry.get(atom)))).toEqual(first); | ||
| 71 | }); | ||
| 72 | |||
| 73 | registry.refresh(atom); | ||
| 74 | await vi.waitFor(() => expect(listEntries).toHaveBeenCalledTimes(2)); | ||
| 75 | const refreshing = registry.get(atom); | ||
| 76 | expect(refreshing.waiting).toBe(true); | ||
| 77 | expect(Option.getOrNull(AsyncResult.value(refreshing))).toEqual(first); | ||
| 78 | |||
| 79 | revalidation.resolve(second); | ||
| 80 | await vi.waitFor(() => { | ||
| 81 | expect(Option.getOrNull(AsyncResult.value(registry.get(atom)))).toEqual(second); | ||
| 82 | }); | ||
| 83 | registry.dispose(); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("keeps the latest optimistic draft when an older write finishes", async () => { | ||
| 87 | vi.stubGlobal("window", {}); | 22 | vi.stubGlobal("window", {}); |
| 88 | const initial = { | 23 | const initial = { |
| 89 | relativePath: "convex.json", | 24 | relativePath: "convex.json", |
| ... | @@ -91,17 +26,6 @@ describe("project files queries", () => { | ... | @@ -91,17 +26,6 @@ describe("project files queries", () => { |
| 91 | byteLength: 20, | 26 | byteLength: 20, |
| 92 | truncated: false, | 27 | truncated: false, |
| 93 | } satisfies ProjectReadFileResult; | 28 | } satisfies ProjectReadFileResult; |
| 94 | const readFile = vi.fn<EnvironmentApi["projects"]["readFile"]>().mockResolvedValue(initial); | ||
| 95 | __setEnvironmentApiOverrideForTests(environmentId, { | ||
| 96 | projects: { readFile }, | ||
| 97 | } as unknown as EnvironmentApi); | ||
| 98 | const atom = getProjectFileQueryAtom(environmentId, "/repo", "convex.json"); | ||
| 99 | |||
| 100 | appAtomRegistry.get(atom); | ||
| 101 | await vi.waitFor(() => { | ||
| 102 | expect(Option.getOrNull(AsyncResult.value(appAtomRegistry.get(atom)))).toEqual(initial); | ||
| 103 | }); | ||
| 104 | |||
| 105 | setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}'); | 29 | setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}'); |
| 106 | setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"22"}'); | 30 | setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"22"}'); |
| 107 | 31 | ||
| ... | @@ -113,14 +37,7 @@ describe("project files queries", () => { | ... | @@ -113,14 +37,7 @@ describe("project files queries", () => { |
| 113 | confirmProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}'), | 37 | confirmProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}'), |
| 114 | ).toBe(false); | 38 | ).toBe(false); |
| 115 | 39 | ||
| 116 | expect( | 40 | expect(resolveProjectFileQueryData(environmentId, "/repo", "convex.json", initial)).toEqual({ |
| 117 | resolveProjectFileQueryData( | ||
| 118 | environmentId, | ||
| 119 | "/repo", | ||
| 120 | "convex.json", | ||
| 121 | Option.getOrNull(AsyncResult.value(appAtomRegistry.get(atom))), | ||
| 122 | ), | ||
| 123 | ).toEqual({ | ||
| 124 | relativePath: "convex.json", | 41 | relativePath: "convex.json", |
| 125 | contents: '{"nodeVersion":"22"}', | 42 | contents: '{"nodeVersion":"22"}', |
| 126 | byteLength: 20, | 43 | byteLength: 20, |
apps/web/src/components/files/projectFilesQueryState.ts+47-104| ... | @@ -1,89 +1,23 @@ | ... | @@ -1,89 +1,23 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 2 | import type { | 2 | import type { |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | ProjectListEntriesResult, | 4 | ProjectListEntriesResult, |
| 5 | ProjectReadFileResult, | 5 | ProjectReadFileResult, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import * as Cause from "effect/Cause"; | 7 | import * as Cause from "effect/Cause"; |
| 8 | import * as Data from "effect/Data"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 11 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 9 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 12 | import { useCallback, useEffect } from "react"; | 10 | import { useCallback } from "react"; |
| 13 | 11 | ||
| 14 | import { ensureEnvironmentApi } from "~/environmentApi"; | ||
| 15 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | 12 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 13 | import { projectEnvironment } from "~/state/projects"; | ||
| 14 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; | ||
| 16 | 15 | ||
| 17 | const PROJECT_QUERY_STALE_TIME_MS = 30_000; | ||
| 18 | const PROJECT_QUERY_IDLE_TTL_MS = 5 * 60_000; | ||
| 19 | const EMPTY_PROJECT_FILE_PATH = ""; | 16 | const EMPTY_PROJECT_FILE_PATH = ""; |
| 20 | interface OptimisticProjectFile { | 17 | function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { |
| 21 | readonly data: ProjectReadFileResult; | 18 | return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); |
| 22 | readonly confirmed: boolean; | ||
| 23 | } | 19 | } |
| 24 | 20 | ||
| 25 | const optimisticProjectFiles = new Map<string, OptimisticProjectFile>(); | ||
| 26 | |||
| 27 | class ProjectQueryError extends Data.TaggedError("ProjectQueryError")<{ | ||
| 28 | readonly message: string; | ||
| 29 | readonly cause?: unknown; | ||
| 30 | }> {} | ||
| 31 | |||
| 32 | function queryError(message: string, cause: unknown): ProjectQueryError { | ||
| 33 | return new ProjectQueryError({ message, cause }); | ||
| 34 | } | ||
| 35 | |||
| 36 | function entriesKey(environmentId: EnvironmentId, cwd: string): string { | ||
| 37 | return [environmentId, cwd].map(encodeURIComponent).join("|"); | ||
| 38 | } | ||
| 39 | |||
| 40 | function fileKey(environmentId: EnvironmentId, cwd: string, relativePath: string): string { | ||
| 41 | return [environmentId, cwd, relativePath].map(encodeURIComponent).join("|"); | ||
| 42 | } | ||
| 43 | |||
| 44 | function keyParts(key: string): string[] { | ||
| 45 | return key.split("|").map(decodeURIComponent); | ||
| 46 | } | ||
| 47 | |||
| 48 | const projectEntriesQueryAtom = Atom.family((key: string) => | ||
| 49 | Atom.make( | ||
| 50 | Effect.tryPromise({ | ||
| 51 | try: () => { | ||
| 52 | const [environmentId, cwd] = keyParts(key) as [EnvironmentId, string]; | ||
| 53 | return ensureEnvironmentApi(environmentId).projects.listEntries({ cwd }); | ||
| 54 | }, | ||
| 55 | catch: (cause) => queryError("Could not load workspace files.", cause), | ||
| 56 | }), | ||
| 57 | ).pipe( | ||
| 58 | Atom.swr({ | ||
| 59 | staleTime: PROJECT_QUERY_STALE_TIME_MS, | ||
| 60 | revalidateOnMount: true, | ||
| 61 | }), | ||
| 62 | Atom.setIdleTTL(PROJECT_QUERY_IDLE_TTL_MS), | ||
| 63 | Atom.withLabel(`projects:entries:${key}`), | ||
| 64 | ), | ||
| 65 | ); | ||
| 66 | |||
| 67 | const projectFileQueryAtom = Atom.family((key: string) => | ||
| 68 | Atom.make( | ||
| 69 | Effect.tryPromise({ | ||
| 70 | try: () => { | ||
| 71 | const [environmentId, cwd, relativePath] = keyParts(key) as [EnvironmentId, string, string]; | ||
| 72 | if (relativePath === EMPTY_PROJECT_FILE_PATH) return Promise.resolve(null); | ||
| 73 | return ensureEnvironmentApi(environmentId).projects.readFile({ cwd, relativePath }); | ||
| 74 | }, | ||
| 75 | catch: (cause) => queryError("Could not read workspace file.", cause), | ||
| 76 | }), | ||
| 77 | ).pipe( | ||
| 78 | Atom.swr({ | ||
| 79 | staleTime: PROJECT_QUERY_STALE_TIME_MS, | ||
| 80 | revalidateOnMount: true, | ||
| 81 | }), | ||
| 82 | Atom.setIdleTTL(PROJECT_QUERY_IDLE_TTL_MS), | ||
| 83 | Atom.withLabel(`projects:file:${key}`), | ||
| 84 | ), | ||
| 85 | ); | ||
| 86 | |||
| 87 | interface ProjectQueryState<A> { | 21 | interface ProjectQueryState<A> { |
| 88 | readonly data: A | null; | 22 | readonly data: A | null; |
| 89 | readonly error: string | null; | 23 | readonly error: string | null; |
| ... | @@ -92,7 +26,7 @@ interface ProjectQueryState<A> { | ... | @@ -92,7 +26,7 @@ interface ProjectQueryState<A> { |
| 92 | } | 26 | } |
| 93 | 27 | ||
| 94 | export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { | 28 | export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { |
| 95 | return projectEntriesQueryAtom(entriesKey(environmentId, cwd)); | 29 | return projectEnvironment.listEntries({ environmentId, input: { cwd } }); |
| 96 | } | 30 | } |
| 97 | 31 | ||
| 98 | export function getProjectFileQueryAtom( | 32 | export function getProjectFileQueryAtom( |
| ... | @@ -100,7 +34,10 @@ export function getProjectFileQueryAtom( | ... | @@ -100,7 +34,10 @@ export function getProjectFileQueryAtom( |
| 100 | cwd: string, | 34 | cwd: string, |
| 101 | relativePath: string | null, | 35 | relativePath: string | null, |
| 102 | ) { | 36 | ) { |
| 103 | return projectFileQueryAtom(fileKey(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH)); | 37 | return projectEnvironment.readFile({ |
| 38 | environmentId, | ||
| 39 | input: { cwd, relativePath: relativePath ?? EMPTY_PROJECT_FILE_PATH }, | ||
| 40 | }); | ||
| 104 | } | 41 | } |
| 105 | 42 | ||
| 106 | export function setProjectFileQueryData( | 43 | export function setProjectFileQueryData( |
| ... | @@ -109,9 +46,8 @@ export function setProjectFileQueryData( | ... | @@ -109,9 +46,8 @@ export function setProjectFileQueryData( |
| 109 | relativePath: string, | 46 | relativePath: string, |
| 110 | contents: string, | 47 | contents: string, |
| 111 | ): void { | 48 | ): void { |
| 112 | const key = fileKey(environmentId, cwd, relativePath); | 49 | appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), { |
| 113 | optimisticProjectFiles.set(key, { | 50 | confirmedAgainst: undefined, |
| 114 | confirmed: false, | ||
| 115 | data: { | 51 | data: { |
| 116 | relativePath, | 52 | relativePath, |
| 117 | contents, | 53 | contents, |
| ... | @@ -126,7 +62,7 @@ export function getOptimisticProjectFileQueryData( | ... | @@ -126,7 +62,7 @@ export function getOptimisticProjectFileQueryData( |
| 126 | cwd: string, | 62 | cwd: string, |
| 127 | relativePath: string, | 63 | relativePath: string, |
| 128 | ): ProjectReadFileResult | null { | 64 | ): ProjectReadFileResult | null { |
| 129 | return optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath))?.data ?? null; | 65 | return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? null; |
| 130 | } | 66 | } |
| 131 | 67 | ||
| 132 | export function confirmProjectFileQueryData( | 68 | export function confirmProjectFileQueryData( |
| ... | @@ -135,12 +71,25 @@ export function confirmProjectFileQueryData( | ... | @@ -135,12 +71,25 @@ export function confirmProjectFileQueryData( |
| 135 | relativePath: string, | 71 | relativePath: string, |
| 136 | contents: string, | 72 | contents: string, |
| 137 | ): boolean { | 73 | ): boolean { |
| 138 | const key = fileKey(environmentId, cwd, relativePath); | 74 | const atom = optimisticFileAtom(environmentId, cwd, relativePath); |
| 139 | const optimisticFile = optimisticProjectFiles.get(key); | 75 | const optimisticFile = appAtomRegistry.get(atom); |
| 140 | if (optimisticFile?.data.contents !== contents) return false; | 76 | if (optimisticFile?.data.contents !== contents) return false; |
| 141 | 77 | ||
| 142 | optimisticProjectFiles.set(key, { ...optimisticFile, confirmed: true }); | 78 | const queryAtom = getProjectFileQueryAtom(environmentId, cwd, relativePath); |
| 143 | appAtomRegistry.refresh(getProjectFileQueryAtom(environmentId, cwd, relativePath)); | 79 | const confirmed = { |
| 80 | ...optimisticFile, | ||
| 81 | confirmedAgainst: appAtomRegistry.get(queryAtom), | ||
| 82 | }; | ||
| 83 | appAtomRegistry.set(atom, confirmed); | ||
| 84 | appAtomRegistry.refresh(queryAtom); | ||
| 85 | void executeAtomQuery(appAtomRegistry, queryAtom, { | ||
| 86 | reportDefect: false, | ||
| 87 | reportFailure: false, | ||
| 88 | }).then((result) => { | ||
| 89 | if (result._tag === "Success" && appAtomRegistry.get(atom) === confirmed) { | ||
| 90 | appAtomRegistry.set(atom, null); | ||
| 91 | } | ||
| 92 | }); | ||
| 144 | return true; | 93 | return true; |
| 145 | } | 94 | } |
| 146 | 95 | ||
| ... | @@ -151,11 +100,15 @@ export function resolveProjectFileQueryData( | ... | @@ -151,11 +100,15 @@ export function resolveProjectFileQueryData( |
| 151 | data: ProjectReadFileResult | null, | 100 | data: ProjectReadFileResult | null, |
| 152 | ): ProjectReadFileResult | null { | 101 | ): ProjectReadFileResult | null { |
| 153 | if (relativePath === null) return data; | 102 | if (relativePath === null) return data; |
| 154 | return optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath))?.data ?? data; | 103 | return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? data; |
| 155 | } | 104 | } |
| 156 | 105 | ||
| 157 | export function __resetProjectFileQueryDataForTests(): void { | 106 | export function clearProjectFileQueryData( |
| 158 | optimisticProjectFiles.clear(); | 107 | environmentId: EnvironmentId, |
| 108 | cwd: string, | ||
| 109 | relativePath: string, | ||
| 110 | ): void { | ||
| 111 | appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), null); | ||
| 159 | } | 112 | } |
| 160 | 113 | ||
| 161 | function errorMessage<A>(result: AsyncResult.AsyncResult<A, unknown>): string | null { | 114 | function errorMessage<A>(result: AsyncResult.AsyncResult<A, unknown>): string | null { |
| ... | @@ -170,7 +123,8 @@ export function useProjectEntriesQuery( | ... | @@ -170,7 +123,8 @@ export function useProjectEntriesQuery( |
| 170 | ): ProjectQueryState<ProjectListEntriesResult> { | 123 | ): ProjectQueryState<ProjectListEntriesResult> { |
| 171 | const atom = getProjectEntriesQueryAtom(environmentId, cwd); | 124 | const atom = getProjectEntriesQueryAtom(environmentId, cwd); |
| 172 | const result = useAtomValue(atom); | 125 | const result = useAtomValue(atom); |
| 173 | const refresh = useCallback(() => appAtomRegistry.refresh(atom), [atom]); | 126 | const refreshAtom = useAtomRefresh(atom); |
| 127 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); | ||
| 174 | return { | 128 | return { |
| 175 | data: Option.getOrNull(AsyncResult.value(result)), | 129 | data: Option.getOrNull(AsyncResult.value(result)), |
| 176 | error: errorMessage(result), | 130 | error: errorMessage(result), |
| ... | @@ -186,24 +140,13 @@ export function useProjectFileQuery( | ... | @@ -186,24 +140,13 @@ export function useProjectFileQuery( |
| 186 | ): ProjectQueryState<ProjectReadFileResult> { | 140 | ): ProjectQueryState<ProjectReadFileResult> { |
| 187 | const atom = getProjectFileQueryAtom(environmentId, cwd, relativePath); | 141 | const atom = getProjectFileQueryAtom(environmentId, cwd, relativePath); |
| 188 | const result = useAtomValue(atom); | 142 | const result = useAtomValue(atom); |
| 189 | const refresh = useCallback(() => appAtomRegistry.refresh(atom), [atom]); | 143 | const refreshAtom = useAtomRefresh(atom); |
| 144 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); | ||
| 190 | const data = Option.getOrNull(AsyncResult.value(result)); | 145 | const data = Option.getOrNull(AsyncResult.value(result)); |
| 191 | const optimisticFile = | 146 | const optimisticResult = useAtomValue( |
| 192 | relativePath === null | 147 | optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH), |
| 193 | ? undefined | 148 | ); |
| 194 | : optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath)); | 149 | const optimisticFile = relativePath === null ? null : optimisticResult; |
| 195 | |||
| 196 | useEffect(() => { | ||
| 197 | if ( | ||
| 198 | relativePath === null || | ||
| 199 | optimisticFile === undefined || | ||
| 200 | !optimisticFile.confirmed || | ||
| 201 | data?.contents !== optimisticFile.data.contents | ||
| 202 | ) { | ||
| 203 | return; | ||
| 204 | } | ||
| 205 | optimisticProjectFiles.delete(fileKey(environmentId, cwd, relativePath)); | ||
| 206 | }, [cwd, data?.contents, environmentId, optimisticFile, relativePath]); | ||
| 207 | 150 | ||
| 208 | return { | 151 | return { |
| 209 | data: optimisticFile?.data ?? data, | 152 | data: optimisticFile?.data ?? data, |
apps/web/src/components/preview/AgentBrowserCursor.tsx+22-6| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import type { DesktopPreviewPointerEvent } from "@t3tools/contracts"; | ||
| 3 | import { MousePointer2 } from "lucide-react"; | 4 | import { MousePointer2 } from "lucide-react"; |
| 4 | import { useEffect, useState } from "react"; | 5 | import { useEffect, useState } from "react"; |
| 5 | 6 | ||
| ... | @@ -16,16 +17,31 @@ export function AgentBrowserCursor(props: { | ... | @@ -16,16 +17,31 @@ export function AgentBrowserCursor(props: { |
| 16 | }) { | 17 | }) { |
| 17 | const { tabId, zoomFactor, controller } = props; | 18 | const { tabId, zoomFactor, controller } = props; |
| 18 | const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null); | 19 | const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null); |
| 19 | const [active, setActive] = useState(false); | 20 | |
| 21 | if (!event) return null; | ||
| 22 | |||
| 23 | return ( | ||
| 24 | <AgentBrowserCursorEvent | ||
| 25 | key={event.sequence} | ||
| 26 | event={event} | ||
| 27 | zoomFactor={zoomFactor} | ||
| 28 | controller={controller} | ||
| 29 | /> | ||
| 30 | ); | ||
| 31 | } | ||
| 32 | |||
| 33 | function AgentBrowserCursorEvent(props: { | ||
| 34 | readonly event: DesktopPreviewPointerEvent; | ||
| 35 | readonly zoomFactor: number; | ||
| 36 | readonly controller: BrowserController; | ||
| 37 | }) { | ||
| 38 | const { event, zoomFactor, controller } = props; | ||
| 39 | const [active, setActive] = useState(true); | ||
| 20 | 40 | ||
| 21 | useEffect(() => { | 41 | useEffect(() => { |
| 22 | if (!event) return; | ||
| 23 | setActive(true); | ||
| 24 | const timeout = window.setTimeout(() => setActive(false), CURSOR_ACTIVE_MS); | 42 | const timeout = window.setTimeout(() => setActive(false), CURSOR_ACTIVE_MS); |
| 25 | return () => window.clearTimeout(timeout); | 43 | return () => window.clearTimeout(timeout); |
| 26 | }, [event]); | 44 | }, []); |
| 27 | |||
| 28 | if (!event) return null; | ||
| 29 | 45 | ||
| 30 | return ( | 46 | return ( |
| 31 | <div | 47 | <div |
apps/web/src/components/preview/PreviewAutomationOwner.test.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { observeAutomationOwnerConnectedGeneration } from "./PreviewAutomationOwner"; | ||
| 4 | |||
| 5 | describe("observeAutomationOwnerConnectedGeneration", () => { | ||
| 6 | it("re-reports ownership only after a later transport generation connects", () => { | ||
| 7 | const initial = observeAutomationOwnerConnectedGeneration(null, 1); | ||
| 8 | expect(initial).toEqual({ | ||
| 9 | nextGeneration: 1, | ||
| 10 | shouldReport: false, | ||
| 11 | }); | ||
| 12 | |||
| 13 | const disconnected = observeAutomationOwnerConnectedGeneration(initial.nextGeneration, null); | ||
| 14 | expect(disconnected).toEqual({ | ||
| 15 | nextGeneration: 1, | ||
| 16 | shouldReport: false, | ||
| 17 | }); | ||
| 18 | |||
| 19 | expect(observeAutomationOwnerConnectedGeneration(disconnected.nextGeneration, 2)).toEqual({ | ||
| 20 | nextGeneration: 2, | ||
| 21 | shouldReport: true, | ||
| 22 | }); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("does not re-report for repeated connected state from the same generation", () => { | ||
| 26 | expect(observeAutomationOwnerConnectedGeneration(3, 3)).toEqual({ | ||
| 27 | nextGeneration: 3, | ||
| 28 | shouldReport: false, | ||
| 29 | }); | ||
| 30 | }); | ||
| 31 | }); | ||
apps/web/src/components/preview/PreviewAutomationOwner.tsx+134-75| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { scopedThreadKey } from "@t3tools/client-runtime"; | 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 4 | import type { | 4 | import type { |
| 5 | PreviewAutomationNavigateInput, | 5 | PreviewAutomationNavigateInput, |
| 6 | PreviewAutomationOpenInput, | 6 | PreviewAutomationOpenInput, |
| ... | @@ -11,25 +11,47 @@ import type { | ... | @@ -11,25 +11,47 @@ import type { |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { useCallback, useEffect, useId, useRef } from "react"; | 12 | import { useCallback, useEffect, useId, useRef } from "react"; |
| 13 | 13 | ||
| 14 | import { ensureEnvironmentApi } from "~/environmentApi"; | 14 | import { |
| 15 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; | 15 | applyPreviewServerSnapshot, |
| 16 | readThreadPreviewState, | ||
| 17 | subscribeThreadPreviewState, | ||
| 18 | } from "~/previewStateStore"; | ||
| 16 | import { useRightPanelStore } from "~/rightPanelStore"; | 19 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 17 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; | 20 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; |
| 18 | import { | 21 | import { startBrowserRecording, stopBrowserRecording } from "~/browser/browserRecording"; |
| 19 | startBrowserRecording, | 22 | import { previewEnvironment } from "~/state/preview"; |
| 20 | stopBrowserRecording, | 23 | import { useEnvironmentQuery } from "~/state/query"; |
| 21 | useBrowserRecordingStore, | 24 | import { useEnvironmentConnectionState } from "~/state/environments"; |
| 22 | } from "~/browser/browserRecording"; | 25 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 23 | 26 | ||
| 24 | import { previewBridge } from "./previewBridge"; | 27 | import { previewBridge } from "./previewBridge"; |
| 25 | 28 | ||
| 29 | export function observeAutomationOwnerConnectedGeneration( | ||
| 30 | previousGeneration: number | null, | ||
| 31 | connectedGeneration: number | null, | ||
| 32 | ): { | ||
| 33 | readonly nextGeneration: number | null; | ||
| 34 | readonly shouldReport: boolean; | ||
| 35 | } { | ||
| 36 | if (connectedGeneration === null) { | ||
| 37 | return { | ||
| 38 | nextGeneration: previousGeneration, | ||
| 39 | shouldReport: false, | ||
| 40 | }; | ||
| 41 | } | ||
| 42 | return { | ||
| 43 | nextGeneration: connectedGeneration, | ||
| 44 | shouldReport: previousGeneration !== null && previousGeneration !== connectedGeneration, | ||
| 45 | }; | ||
| 46 | } | ||
| 47 | |||
| 26 | const waitForDesktopOverlay = async ( | 48 | const waitForDesktopOverlay = async ( |
| 27 | threadRef: ScopedThreadRef, | 49 | threadRef: ScopedThreadRef, |
| 28 | timeoutMs: number, | 50 | timeoutMs: number, |
| 29 | ): Promise<void> => { | 51 | ): Promise<void> => { |
| 30 | const deadline = Date.now() + timeoutMs; | 52 | const deadline = Date.now() + timeoutMs; |
| 31 | while (Date.now() <= deadline) { | 53 | while (Date.now() <= deadline) { |
| 32 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); | 54 | const state = readThreadPreviewState(threadRef); |
| 33 | const tabId = state.snapshot?.tabId; | 55 | const tabId = state.snapshot?.tabId; |
| 34 | if (tabId && state.desktopOverlay && previewBridge) { | 56 | if (tabId && state.desktopOverlay && previewBridge) { |
| 35 | const status = await previewBridge.automation.status(tabId); | 57 | const status = await previewBridge.automation.status(tabId); |
| ... | @@ -70,7 +92,7 @@ const currentStatus = async ( | ... | @@ -70,7 +92,7 @@ const currentStatus = async ( |
| 70 | threadRef: ScopedThreadRef, | 92 | threadRef: ScopedThreadRef, |
| 71 | visible: boolean, | 93 | visible: boolean, |
| 72 | ): Promise<PreviewAutomationStatus> => { | 94 | ): Promise<PreviewAutomationStatus> => { |
| 73 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); | 95 | const state = readThreadPreviewState(threadRef); |
| 74 | const tabId = state.snapshot?.tabId ?? null; | 96 | const tabId = state.snapshot?.tabId ?? null; |
| 75 | if (tabId && previewBridge && state.desktopOverlay) { | 97 | if (tabId && previewBridge && state.desktopOverlay) { |
| 76 | const status = await previewBridge.automation.status(tabId); | 98 | const status = await previewBridge.automation.status(tabId); |
| ... | @@ -113,7 +135,32 @@ export function PreviewAutomationOwner(props: { | ... | @@ -113,7 +135,32 @@ export function PreviewAutomationOwner(props: { |
| 113 | }) { | 135 | }) { |
| 114 | const { threadRef, visible } = props; | 136 | const { threadRef, visible } = props; |
| 115 | const automationClientId = useId(); | 137 | const automationClientId = useId(); |
| 138 | const automationRequests = useEnvironmentQuery( | ||
| 139 | previewEnvironment.automationRequests({ | ||
| 140 | environmentId: threadRef.environmentId, | ||
| 141 | input: { clientId: automationClientId }, | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | const connectionState = useEnvironmentConnectionState(threadRef.environmentId).data; | ||
| 145 | const connectedGeneration = | ||
| 146 | connectionState?.phase === "connected" ? connectionState.generation : null; | ||
| 147 | const open = useAtomCommand(previewEnvironment.open, { | ||
| 148 | reportFailure: false, | ||
| 149 | }); | ||
| 150 | const respondToAutomation = useAtomCommand( | ||
| 151 | previewEnvironment.respondToAutomation, | ||
| 152 | "preview automation response", | ||
| 153 | ); | ||
| 154 | const reportAutomationOwner = useAtomCommand( | ||
| 155 | previewEnvironment.reportAutomationOwner, | ||
| 156 | "preview automation owner report", | ||
| 157 | ); | ||
| 158 | const clearAutomationOwner = useAtomCommand( | ||
| 159 | previewEnvironment.clearAutomationOwner, | ||
| 160 | "preview automation owner clear", | ||
| 161 | ); | ||
| 116 | const ownerStateRef = useRef({ threadRef, visible }); | 162 | const ownerStateRef = useRef({ threadRef, visible }); |
| 163 | const connectedGenerationRef = useRef<number | null>(null); | ||
| 117 | const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>( | 164 | const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>( |
| 118 | async () => undefined, | 165 | async () => undefined, |
| 119 | ); | 166 | ); |
| ... | @@ -128,11 +175,7 @@ export function PreviewAutomationOwner(props: { | ... | @@ -128,11 +175,7 @@ export function PreviewAutomationOwner(props: { |
| 128 | error.name = "PreviewAutomationUnavailableError"; | 175 | error.name = "PreviewAutomationUnavailableError"; |
| 129 | throw error; | 176 | throw error; |
| 130 | } | 177 | } |
| 131 | const api = ensureEnvironmentApi(threadRef.environmentId); | 178 | const state = readThreadPreviewState(threadRef); |
| 132 | const state = selectThreadPreviewState( | ||
| 133 | usePreviewStateStore.getState().byThreadKey, | ||
| 134 | threadRef, | ||
| 135 | ); | ||
| 136 | const tabId = request.tabId ?? state.snapshot?.tabId ?? null; | 179 | const tabId = request.tabId ?? state.snapshot?.tabId ?? null; |
| 137 | switch (request.operation) { | 180 | switch (request.operation) { |
| 138 | case "status": | 181 | case "status": |
| ... | @@ -142,11 +185,18 @@ export function PreviewAutomationOwner(props: { | ... | @@ -142,11 +185,18 @@ export function PreviewAutomationOwner(props: { |
| 142 | let activeTabId = | 185 | let activeTabId = |
| 143 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; | 186 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; |
| 144 | if (!activeTabId) { | 187 | if (!activeTabId) { |
| 145 | const snapshot = await api.preview.open({ | 188 | const result = await open({ |
| 146 | threadId: threadRef.threadId, | 189 | environmentId: threadRef.environmentId, |
| 147 | ...(input.url ? { url: input.url } : {}), | 190 | input: { |
| 191 | threadId: threadRef.threadId, | ||
| 192 | ...(input.url ? { url: input.url } : {}), | ||
| 193 | }, | ||
| 148 | }); | 194 | }); |
| 149 | usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); | 195 | if (result._tag === "Failure") { |
| 196 | throw squashAtomCommandFailure(result); | ||
| 197 | } | ||
| 198 | const snapshot = result.value; | ||
| 199 | applyPreviewServerSnapshot(threadRef, snapshot); | ||
| 150 | activeTabId = snapshot.tabId; | 200 | activeTabId = snapshot.tabId; |
| 151 | } else if (input.url && previewBridge) { | 201 | } else if (input.url && previewBridge) { |
| 152 | await previewBridge.navigate(activeTabId, input.url); | 202 | await previewBridge.navigate(activeTabId, input.url); |
| ... | @@ -213,11 +263,11 @@ export function PreviewAutomationOwner(props: { | ... | @@ -213,11 +263,11 @@ export function PreviewAutomationOwner(props: { |
| 213 | ); | 263 | ); |
| 214 | case "recordingStart": { | 264 | case "recordingStart": { |
| 215 | if (!tabId) throw new Error("Preview tab is not initialized."); | 265 | if (!tabId) throw new Error("Preview tab is not initialized."); |
| 216 | await startBrowserRecording(tabId); | 266 | const startedAt = await startBrowserRecording(tabId); |
| 217 | return { | 267 | return { |
| 218 | tabId, | 268 | tabId, |
| 219 | recording: true, | 269 | recording: true, |
| 220 | startedAt: useBrowserRecordingStore.getState().startedAt, | 270 | startedAt, |
| 221 | }; | 271 | }; |
| 222 | } | 272 | } |
| 223 | case "recordingStop": { | 273 | case "recordingStop": { |
| ... | @@ -228,84 +278,93 @@ export function PreviewAutomationOwner(props: { | ... | @@ -228,84 +278,93 @@ export function PreviewAutomationOwner(props: { |
| 228 | } | 278 | } |
| 229 | } | 279 | } |
| 230 | }, | 280 | }, |
| 231 | [threadRef, visible], | 281 | [open, threadRef, visible], |
| 232 | ); | 282 | ); |
| 233 | useEffect(() => { | 283 | useEffect(() => { |
| 234 | handlerRef.current = handleRequest; | 284 | handlerRef.current = handleRequest; |
| 235 | }, [handleRequest]); | 285 | }, [handleRequest]); |
| 236 | 286 | ||
| 237 | useEffect(() => { | 287 | useEffect(() => { |
| 238 | const api = ensureEnvironmentApi(threadRef.environmentId); | 288 | const request = automationRequests.data; |
| 239 | return api.preview.automation.connect( | 289 | if (!request) return; |
| 240 | { clientId: automationClientId }, | 290 | void handlerRef.current(request).then( |
| 241 | (request) => { | 291 | (result) => |
| 242 | void handlerRef.current(request).then( | 292 | respondToAutomation({ |
| 243 | (result) => | 293 | environmentId: threadRef.environmentId, |
| 244 | api.preview.automation.respond({ | 294 | input: { |
| 245 | requestId: request.requestId, | 295 | requestId: request.requestId, |
| 246 | ok: true, | 296 | ok: true, |
| 247 | ...(result === undefined ? {} : { result }), | 297 | ...(result === undefined ? {} : { result }), |
| 248 | }), | 298 | }, |
| 249 | (error) => | 299 | }), |
| 250 | api.preview.automation.respond({ | 300 | (error) => |
| 251 | requestId: request.requestId, | 301 | respondToAutomation({ |
| 252 | ok: false, | 302 | environmentId: threadRef.environmentId, |
| 253 | error: serializeError(error), | 303 | input: { |
| 254 | }), | 304 | requestId: request.requestId, |
| 255 | ); | 305 | ok: false, |
| 256 | }, | 306 | error: serializeError(error), |
| 257 | { | 307 | }, |
| 258 | onResubscribe: () => { | 308 | }), |
| 259 | const ownerState = ownerStateRef.current; | ||
| 260 | const state = selectThreadPreviewState( | ||
| 261 | usePreviewStateStore.getState().byThreadKey, | ||
| 262 | ownerState.threadRef, | ||
| 263 | ); | ||
| 264 | void api.preview.automation.reportOwner({ | ||
| 265 | clientId: automationClientId, | ||
| 266 | environmentId: ownerState.threadRef.environmentId, | ||
| 267 | threadId: ownerState.threadRef.threadId, | ||
| 268 | tabId: state.snapshot?.tabId ?? null, | ||
| 269 | visible: ownerState.visible, | ||
| 270 | supportsAutomation: Boolean(previewBridge?.automation), | ||
| 271 | focusedAt: new Date().toISOString(), | ||
| 272 | }); | ||
| 273 | }, | ||
| 274 | }, | ||
| 275 | ); | 309 | ); |
| 276 | }, [automationClientId, threadRef.environmentId]); | 310 | }, [automationRequests.data, respondToAutomation, threadRef.environmentId]); |
| 277 | 311 | ||
| 278 | useEffect(() => { | 312 | useEffect(() => { |
| 279 | const api = ensureEnvironmentApi(threadRef.environmentId); | 313 | const observation = observeAutomationOwnerConnectedGeneration( |
| 280 | const report = () => { | 314 | connectedGenerationRef.current, |
| 281 | const state = selectThreadPreviewState( | 315 | connectedGeneration, |
| 282 | usePreviewStateStore.getState().byThreadKey, | 316 | ); |
| 283 | threadRef, | 317 | connectedGenerationRef.current = observation.nextGeneration; |
| 284 | ); | 318 | if (!observation.shouldReport) return; |
| 285 | void api.preview.automation.reportOwner({ | 319 | |
| 320 | const ownerState = ownerStateRef.current; | ||
| 321 | const state = readThreadPreviewState(ownerState.threadRef); | ||
| 322 | void reportAutomationOwner({ | ||
| 323 | environmentId: ownerState.threadRef.environmentId, | ||
| 324 | input: { | ||
| 286 | clientId: automationClientId, | 325 | clientId: automationClientId, |
| 287 | environmentId: threadRef.environmentId, | 326 | environmentId: ownerState.threadRef.environmentId, |
| 288 | threadId: threadRef.threadId, | 327 | threadId: ownerState.threadRef.threadId, |
| 289 | tabId: state.snapshot?.tabId ?? null, | 328 | tabId: state.snapshot?.tabId ?? null, |
| 290 | visible, | 329 | visible: ownerState.visible, |
| 291 | supportsAutomation: Boolean(previewBridge?.automation), | 330 | supportsAutomation: Boolean(previewBridge?.automation), |
| 292 | focusedAt: new Date().toISOString(), | 331 | focusedAt: new Date().toISOString(), |
| 332 | }, | ||
| 333 | }); | ||
| 334 | }, [automationClientId, connectedGeneration, reportAutomationOwner]); | ||
| 335 | |||
| 336 | useEffect(() => { | ||
| 337 | const report = () => { | ||
| 338 | const state = readThreadPreviewState(threadRef); | ||
| 339 | void reportAutomationOwner({ | ||
| 340 | environmentId: threadRef.environmentId, | ||
| 341 | input: { | ||
| 342 | clientId: automationClientId, | ||
| 343 | environmentId: threadRef.environmentId, | ||
| 344 | threadId: threadRef.threadId, | ||
| 345 | tabId: state.snapshot?.tabId ?? null, | ||
| 346 | visible, | ||
| 347 | supportsAutomation: Boolean(previewBridge?.automation), | ||
| 348 | focusedAt: new Date().toISOString(), | ||
| 349 | }, | ||
| 293 | }); | 350 | }); |
| 294 | }; | 351 | }; |
| 295 | report(); | 352 | report(); |
| 296 | window.addEventListener("focus", report); | 353 | window.addEventListener("focus", report); |
| 297 | const unsubscribe = usePreviewStateStore.subscribe((state, previous) => { | 354 | const unsubscribe = subscribeThreadPreviewState(threadRef, (state, previous) => { |
| 298 | const key = scopedThreadKey(threadRef); | 355 | if (state.snapshot?.tabId !== previous.snapshot?.tabId) { |
| 299 | if (state.byThreadKey[key]?.snapshot?.tabId !== previous.byThreadKey[key]?.snapshot?.tabId) { | ||
| 300 | report(); | 356 | report(); |
| 301 | } | 357 | } |
| 302 | }); | 358 | }); |
| 303 | return () => { | 359 | return () => { |
| 304 | window.removeEventListener("focus", report); | 360 | window.removeEventListener("focus", report); |
| 305 | unsubscribe(); | 361 | unsubscribe(); |
| 306 | void api.preview.automation.clearOwner({ clientId: automationClientId }); | 362 | void clearAutomationOwner({ |
| 363 | environmentId: threadRef.environmentId, | ||
| 364 | input: { clientId: automationClientId }, | ||
| 365 | }); | ||
| 307 | }; | 366 | }; |
| 308 | }, [automationClientId, threadRef, visible]); | 367 | }, [automationClientId, clearAutomationOwner, reportAutomationOwner, threadRef, visible]); |
| 309 | 368 | ||
| 310 | return null; | 369 | return null; |
| 311 | } | 370 | } |
apps/web/src/components/preview/PreviewChromeRow.browser.tsx deleted-85| ... | @@ -1,85 +0,0 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import { page } from "vite-plus/test/browser"; | ||
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | import { render } from "vitest-browser-react"; | ||
| 6 | |||
| 7 | import { PreviewChromeRow } from "./PreviewChromeRow"; | ||
| 8 | |||
| 9 | const defaultProps = { | ||
| 10 | url: "https://example.com/", | ||
| 11 | loading: false, | ||
| 12 | loadProgress: 0, | ||
| 13 | canGoBack: false, | ||
| 14 | canGoForward: false, | ||
| 15 | refreshDisabled: false, | ||
| 16 | onBack: vi.fn(), | ||
| 17 | onForward: vi.fn(), | ||
| 18 | onRefresh: vi.fn(), | ||
| 19 | onSubmit: vi.fn(), | ||
| 20 | }; | ||
| 21 | |||
| 22 | describe("PreviewChromeRow", () => { | ||
| 23 | it("uses the shared compact surface subheader treatment", async () => { | ||
| 24 | const screen = await render(<PreviewChromeRow {...defaultProps} />); | ||
| 25 | const subheader = screen.container.querySelector<HTMLElement>("[data-surface-subheader]"); | ||
| 26 | |||
| 27 | expect(subheader).not.toBeNull(); | ||
| 28 | expect(subheader?.getBoundingClientRect().height).toBe(40); | ||
| 29 | expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px"); | ||
| 30 | expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px"); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("only focuses the URL input after an explicit focus request", async () => { | ||
| 34 | const previouslyFocused = document.createElement("button"); | ||
| 35 | document.body.append(previouslyFocused); | ||
| 36 | previouslyFocused.focus(); | ||
| 37 | |||
| 38 | const screen = await render(<PreviewChromeRow {...defaultProps} focusUrlNonce={undefined} />); | ||
| 39 | const input = page.getByRole("textbox").element() as HTMLInputElement; | ||
| 40 | |||
| 41 | expect(document.activeElement).toBe(previouslyFocused); | ||
| 42 | |||
| 43 | await screen.rerender(<PreviewChromeRow {...defaultProps} focusUrlNonce={1} />); | ||
| 44 | |||
| 45 | expect(document.activeElement).toBe(input); | ||
| 46 | expect(input.selectionStart).toBe(0); | ||
| 47 | expect(input.selectionEnd).toBe(input.value.length); | ||
| 48 | |||
| 49 | previouslyFocused.remove(); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("shows a friendly asset label until the URL input receives focus", async () => { | ||
| 53 | const fullUrl = "http://127.0.0.1:3773/api/assets/token/report.pdf"; | ||
| 54 | await render( | ||
| 55 | <PreviewChromeRow | ||
| 56 | {...defaultProps} | ||
| 57 | url={fullUrl} | ||
| 58 | displayUrl="Local environment · report.pdf" | ||
| 59 | />, | ||
| 60 | ); | ||
| 61 | const input = page.getByRole("textbox"); | ||
| 62 | |||
| 63 | await expect.element(input).toHaveValue("Local environment · report.pdf"); | ||
| 64 | |||
| 65 | await input.click(); | ||
| 66 | |||
| 67 | await expect.element(input).toHaveValue(fullUrl); | ||
| 68 | |||
| 69 | input.element().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); | ||
| 70 | |||
| 71 | await expect.element(input).toHaveValue("Local environment · report.pdf"); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("shows only the host for regular URLs until the input receives focus", async () => { | ||
| 75 | const fullUrl = "https://t3.chat/chat/18378834-f776-4507-ada7-6f79"; | ||
| 76 | await render(<PreviewChromeRow {...defaultProps} url={fullUrl} displayUrl="t3.chat" />); | ||
| 77 | const input = page.getByRole("textbox"); | ||
| 78 | |||
| 79 | await expect.element(input).toHaveValue("t3.chat"); | ||
| 80 | |||
| 81 | await input.click(); | ||
| 82 | |||
| 83 | await expect.element(input).toHaveValue(fullUrl); | ||
| 84 | }); | ||
| 85 | }); | ||
apps/web/src/components/preview/PreviewChromeRow.tsx+1-8| ... | @@ -87,12 +87,6 @@ export function PreviewChromeRow({ | ... | @@ -87,12 +87,6 @@ export function PreviewChromeRow({ |
| 87 | const [draft, setDraft] = useState(url); | 87 | const [draft, setDraft] = useState(url); |
| 88 | const [inputFocused, setInputFocused] = useState(false); | 88 | const [inputFocused, setInputFocused] = useState(false); |
| 89 | 89 | ||
| 90 | // Sync the input with external URL changes, but only when the user isn't | ||
| 91 | // actively typing (preserves in-progress edits during navigation events). | ||
| 92 | useEffect(() => { | ||
| 93 | setDraft((previous) => (document.activeElement === inputRef.current ? previous : url)); | ||
| 94 | }, [url]); | ||
| 95 | |||
| 96 | useEffect(() => { | 90 | useEffect(() => { |
| 97 | if (focusUrlNonce == null) return; | 91 | if (focusUrlNonce == null) return; |
| 98 | const node = inputRef.current; | 92 | const node = inputRef.current; |
| ... | @@ -171,7 +165,7 @@ export function PreviewChromeRow({ | ... | @@ -171,7 +165,7 @@ export function PreviewChromeRow({ |
| 171 | render={ | 165 | render={ |
| 172 | <InputGroupInput | 166 | <InputGroupInput |
| 173 | ref={inputRef} | 167 | ref={inputRef} |
| 174 | value={inputFocused ? draft : (displayUrl ?? draft)} | 168 | value={inputFocused ? draft : (displayUrl ?? url)} |
| 175 | className={cn( | 169 | className={cn( |
| 176 | onOpenInBrowser && | 170 | onOpenInBrowser && |
| 177 | !inputFocused && | 171 | !inputFocused && |
| ... | @@ -184,7 +178,6 @@ export function PreviewChromeRow({ | ... | @@ -184,7 +178,6 @@ export function PreviewChromeRow({ |
| 184 | queueMicrotask(() => inputRef.current?.select()); | 178 | queueMicrotask(() => inputRef.current?.select()); |
| 185 | }} | 179 | }} |
| 186 | onBlur={() => { | 180 | onBlur={() => { |
| 187 | setDraft(url); | ||
| 188 | setInputFocused(false); | 181 | setInputFocused(false); |
| 189 | }} | 182 | }} |
| 190 | onKeyDown={(event) => { | 183 | onKeyDown={(event) => { |
apps/web/src/components/preview/PreviewView.tsx+17-21| ... | @@ -1,16 +1,17 @@ | ... | @@ -1,16 +1,17 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { scopedThreadKey } from "@t3tools/client-runtime"; | 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { type ScopedThreadRef } from "@t3tools/contracts"; | 4 | import { type ScopedThreadRef } from "@t3tools/contracts"; |
| 5 | import { useCallback, useEffect, useRef, useState } from "react"; | 5 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | 6 | ||
| 7 | import { useComposerDraftStore } from "~/composerDraftStore"; | 7 | import { useComposerDraftStore } from "~/composerDraftStore"; |
| 8 | import { ensureEnvironmentApi } from "~/environmentApi"; | ||
| 9 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; | 8 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; |
| 10 | import { ensureLocalApi } from "~/localApi"; | 9 | import { ensureLocalApi } from "~/localApi"; |
| 11 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; | 10 | import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore"; |
| 12 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 11 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 13 | import { readEnvironmentConnection } from "~/environments/runtime"; | 12 | import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; |
| 13 | import { previewEnvironment } from "~/state/preview"; | ||
| 14 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 14 | 15 | ||
| 15 | import { previewBridge } from "./previewBridge"; | 16 | import { previewBridge } from "./previewBridge"; |
| 16 | import { subscribePreviewAction } from "./previewActionBus"; | 17 | import { subscribePreviewAction } from "./previewActionBus"; |
| ... | @@ -30,7 +31,7 @@ import { AgentBrowserCursor } from "./AgentBrowserCursor"; | ... | @@ -30,7 +31,7 @@ import { AgentBrowserCursor } from "./AgentBrowserCursor"; |
| 30 | import { | 31 | import { |
| 31 | startBrowserRecording, | 32 | startBrowserRecording, |
| 32 | stopBrowserRecording, | 33 | stopBrowserRecording, |
| 33 | useBrowserRecordingStore, | 34 | useActiveBrowserRecordingTabId, |
| 34 | } from "~/browser/browserRecording"; | 35 | } from "~/browser/browserRecording"; |
| 35 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | 36 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 36 | 37 | ||
| ... | @@ -50,16 +51,15 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); | ... | @@ -50,16 +51,15 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 50 | export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { | 51 | export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { |
| 51 | const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined); | 52 | const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined); |
| 52 | const [pickActive, setPickActive] = useState(false); | 53 | const [pickActive, setPickActive] = useState(false); |
| 53 | const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId); | 54 | const activeRecordingTabId = useActiveBrowserRecordingTabId(); |
| 54 | const pickActiveRef = useRef(false); | 55 | const pickActiveRef = useRef(false); |
| 55 | const isMountedRef = useRef(true); | 56 | const isMountedRef = useRef(true); |
| 56 | const previewState = usePreviewStateStore((state) => | 57 | const previewState = useThreadPreviewState(threadRef); |
| 57 | selectThreadPreviewState(state.byThreadKey, threadRef), | ||
| 58 | ); | ||
| 59 | const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); | ||
| 60 | const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); | ||
| 61 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); | 58 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); |
| 62 | const addImage = useComposerDraftStore((store) => store.addImage); | 59 | const addImage = useComposerDraftStore((store) => store.addImage); |
| 60 | const environment = useEnvironment(threadRef.environmentId); | ||
| 61 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); | ||
| 62 | const open = useAtomCommand(previewEnvironment.open); | ||
| 63 | 63 | ||
| 64 | usePreviewSession(threadRef); | 64 | usePreviewSession(threadRef); |
| 65 | 65 | ||
| ... | @@ -83,40 +83,36 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -83,40 +83,36 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 83 | const showEmptyState = shouldShowPreviewEmptyState(snapshot); | 83 | const showEmptyState = shouldShowPreviewEmptyState(snapshot); |
| 84 | const controller = desktopOverlay?.controller ?? "none"; | 84 | const controller = desktopOverlay?.controller ?? "none"; |
| 85 | const loadProgress = useLoadingProgress(loading); | 85 | const loadProgress = useLoadingProgress(loading); |
| 86 | const environmentConnection = readEnvironmentConnection(threadRef.environmentId); | ||
| 87 | const displayUrl = | 86 | const displayUrl = |
| 88 | url && environmentConnection | 87 | url && environment && environmentHttpBaseUrl |
| 89 | ? (formatPreviewUrl({ | 88 | ? (formatPreviewUrl({ |
| 90 | url, | 89 | url, |
| 91 | environmentLabel: environmentConnection.knownEnvironment.label, | 90 | environmentLabel: environment.label, |
| 92 | environmentHttpBaseUrl: environmentConnection.knownEnvironment.target.httpBaseUrl, | 91 | environmentHttpBaseUrl, |
| 93 | }) ?? undefined) | 92 | }) ?? undefined) |
| 94 | : undefined; | 93 | : undefined; |
| 95 | 94 | ||
| 96 | const handleSubmitUrl = useCallback( | 95 | const handleSubmitUrl = useCallback( |
| 97 | async (next: string) => { | 96 | async (next: string) => { |
| 98 | const api = ensureEnvironmentApi(threadRef.environmentId); | ||
| 99 | try { | 97 | try { |
| 100 | const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); | 98 | const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); |
| 101 | if (tabId && previewBridge) { | 99 | if (tabId && previewBridge) { |
| 102 | // Drive the webview imperatively; `usePreviewBridge` mirrors the | 100 | // Drive the webview imperatively; `usePreviewBridge` mirrors the |
| 103 | // resolved URL back to the server so other clients stay in sync. | 101 | // resolved URL back to the server so other clients stay in sync. |
| 104 | await previewBridge.navigate(tabId, resolvedUrl); | 102 | await previewBridge.navigate(tabId, resolvedUrl); |
| 105 | rememberUrl(threadRef, resolvedUrl); | 103 | rememberPreviewUrl(threadRef, resolvedUrl); |
| 106 | } else { | 104 | } else { |
| 107 | await openPreviewSession({ | 105 | await openPreviewSession({ |
| 108 | previewApi: api.preview, | 106 | openPreview: open, |
| 109 | threadRef, | 107 | threadRef, |
| 110 | url: resolvedUrl, | 108 | url: resolvedUrl, |
| 111 | applyServerSnapshot, | ||
| 112 | rememberUrl, | ||
| 113 | }); | 109 | }); |
| 114 | } | 110 | } |
| 115 | } catch { | 111 | } catch { |
| 116 | // Server-side `failed` event renders the unreachable view. | 112 | // Server-side `failed` event renders the unreachable view. |
| 117 | } | 113 | } |
| 118 | }, | 114 | }, |
| 119 | [applyServerSnapshot, rememberUrl, tabId, threadRef], | 115 | [open, tabId, threadRef], |
| 120 | ); | 116 | ); |
| 121 | 117 | ||
| 122 | const handleRefresh = useCallback(() => { | 118 | const handleRefresh = useCallback(() => { |
apps/web/src/components/preview/openDiscoveredPort.ts+13-11| ... | @@ -1,24 +1,26 @@ | ... | @@ -1,24 +1,26 @@ |
| 1 | import type { DiscoveredLocalServer, ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { DiscoveredLocalServer, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { | ||
| 3 | mapAtomCommandResult, | ||
| 4 | type AtomCommandResult, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | 6 | ||
| 3 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 7 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 4 | import { ensureEnvironmentApi } from "~/environmentApi"; | 8 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 5 | import { usePreviewStateStore } from "~/previewStateStore"; | ||
| 6 | import { useRightPanelStore } from "~/rightPanelStore"; | 9 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 7 | import { openPreviewSession } from "./openPreviewSession"; | 10 | import { openPreviewSession } from "./openPreviewSession"; |
| 8 | 11 | ||
| 9 | export async function openDiscoveredPort(input: { | 12 | export async function openDiscoveredPort<E>(input: { |
| 10 | readonly threadRef: ScopedThreadRef; | 13 | readonly threadRef: ScopedThreadRef; |
| 11 | readonly port: DiscoveredLocalServer; | 14 | readonly port: DiscoveredLocalServer; |
| 12 | }): Promise<void> { | 15 | readonly openPreview: OpenPreviewMutation<E>; |
| 13 | const api = ensureEnvironmentApi(input.threadRef.environmentId); | 16 | }): Promise<AtomCommandResult<void, E>> { |
| 14 | const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); | 17 | const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); |
| 15 | const previewState = usePreviewStateStore.getState(); | 18 | const result = await openPreviewSession({ |
| 16 | const snapshot = await openPreviewSession({ | 19 | openPreview: input.openPreview, |
| 17 | previewApi: api.preview, | ||
| 18 | threadRef: input.threadRef, | 20 | threadRef: input.threadRef, |
| 19 | url: resolvedUrl, | 21 | url: resolvedUrl, |
| 20 | applyServerSnapshot: previewState.applyServerSnapshot, | ||
| 21 | rememberUrl: previewState.rememberUrl, | ||
| 22 | }); | 22 | }); |
| 23 | useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); | 23 | return mapAtomCommandResult(result, (snapshot) => { |
| 24 | useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); | ||
| 25 | }); | ||
| 24 | } | 26 | } |
apps/web/src/components/preview/openPreviewSession.test.ts+26-10| ... | @@ -1,5 +1,9 @@ | ... | @@ -1,5 +1,9 @@ |
| 1 | import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | 2 | import * as Cause from "effect/Cause"; |
| 3 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 4 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; | ||
| 3 | 7 | ||
| 4 | import { openPreviewSession } from "./openPreviewSession"; | 8 | import { openPreviewSession } from "./openPreviewSession"; |
| 5 | 9 | ||
| ... | @@ -21,22 +25,34 @@ const snapshot: PreviewSessionSnapshot = { | ... | @@ -21,22 +25,34 @@ const snapshot: PreviewSessionSnapshot = { |
| 21 | updatedAt: "2026-06-11T23:00:00.000Z", | 25 | updatedAt: "2026-06-11T23:00:00.000Z", |
| 22 | }; | 26 | }; |
| 23 | 27 | ||
| 28 | beforeEach(resetPreviewStateForTests); | ||
| 29 | |||
| 24 | describe("openPreviewSession", () => { | 30 | describe("openPreviewSession", () => { |
| 25 | it("applies the RPC response without waiting for a preview event", async () => { | 31 | it("applies the RPC response without waiting for a preview event", async () => { |
| 26 | const open = vi.fn(async () => snapshot); | 32 | const open = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(snapshot)); |
| 27 | const applyServerSnapshot = vi.fn(); | ||
| 28 | const rememberUrl = vi.fn(); | ||
| 29 | 33 | ||
| 30 | await openPreviewSession({ | 34 | await openPreviewSession({ |
| 31 | previewApi: { open } as Pick<EnvironmentApi["preview"], "open">, | 35 | openPreview: ({ input }) => open(input), |
| 32 | threadRef, | 36 | threadRef, |
| 33 | url: "t3.chat", | 37 | url: "t3.chat", |
| 34 | applyServerSnapshot, | ||
| 35 | rememberUrl, | ||
| 36 | }); | 38 | }); |
| 37 | 39 | ||
| 38 | expect(open).toHaveBeenCalledWith({ threadId: "thread-1", url: "t3.chat" }); | 40 | expect(open).toHaveBeenCalledWith({ threadId: "thread-1", url: "t3.chat" }); |
| 39 | expect(applyServerSnapshot).toHaveBeenCalledWith(threadRef, snapshot); | 41 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); |
| 40 | expect(rememberUrl).toHaveBeenCalledWith(threadRef, "https://t3.chat/"); | 42 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]); |
| 43 | }); | ||
| 44 | |||
| 45 | it("returns failures without mutating preview state", async () => { | ||
| 46 | const failure = new Error("preview unavailable"); | ||
| 47 | |||
| 48 | const result = await openPreviewSession({ | ||
| 49 | openPreview: async () => AsyncResult.failure(Cause.fail(failure)), | ||
| 50 | threadRef, | ||
| 51 | url: "t3.chat", | ||
| 52 | }); | ||
| 53 | |||
| 54 | expect(result._tag).toBe("Failure"); | ||
| 55 | expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); | ||
| 56 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); | ||
| 41 | }); | 57 | }); |
| 42 | }); | 58 | }); |
apps/web/src/components/preview/openPreviewSession.ts+29-15| ... | @@ -1,26 +1,40 @@ | ... | @@ -1,26 +1,40 @@ |
| 1 | import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { |
| 2 | EnvironmentId, | ||
| 3 | PreviewOpenInput, | ||
| 4 | PreviewSessionSnapshot, | ||
| 5 | ScopedThreadRef, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | 8 | ||
| 3 | import type { PreviewStateStoreState } from "~/previewStateStore"; | 9 | import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; |
| 4 | 10 | ||
| 5 | interface OpenPreviewSessionInput { | 11 | interface OpenPreviewSessionInput<E> { |
| 6 | previewApi: Pick<EnvironmentApi["preview"], "open">; | 12 | openPreview: (input: { |
| 13 | readonly environmentId: EnvironmentId; | ||
| 14 | readonly input: PreviewOpenInput; | ||
| 15 | }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>; | ||
| 7 | threadRef: ScopedThreadRef; | 16 | threadRef: ScopedThreadRef; |
| 8 | url: string; | 17 | url: string; |
| 9 | applyServerSnapshot: PreviewStateStoreState["applyServerSnapshot"]; | ||
| 10 | rememberUrl: PreviewStateStoreState["rememberUrl"]; | ||
| 11 | } | 18 | } |
| 12 | 19 | ||
| 13 | export async function openPreviewSession( | 20 | export async function openPreviewSession<E>( |
| 14 | input: OpenPreviewSessionInput, | 21 | input: OpenPreviewSessionInput<E>, |
| 15 | ): Promise<PreviewSessionSnapshot> { | 22 | ): Promise<AtomCommandResult<PreviewSessionSnapshot, E>> { |
| 16 | const snapshot = await input.previewApi.open({ | 23 | const result = await input.openPreview({ |
| 17 | threadId: input.threadRef.threadId, | 24 | environmentId: input.threadRef.environmentId, |
| 18 | url: input.url, | 25 | input: { |
| 26 | threadId: input.threadRef.threadId, | ||
| 27 | url: input.url, | ||
| 28 | }, | ||
| 19 | }); | 29 | }); |
| 20 | input.applyServerSnapshot(input.threadRef, snapshot); | 30 | if (result._tag === "Failure") { |
| 21 | input.rememberUrl( | 31 | return result; |
| 32 | } | ||
| 33 | const snapshot = result.value; | ||
| 34 | applyPreviewServerSnapshot(input.threadRef, snapshot); | ||
| 35 | rememberPreviewUrl( | ||
| 22 | input.threadRef, | 36 | input.threadRef, |
| 23 | snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, | 37 | snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, |
| 24 | ); | 38 | ); |
| 25 | return snapshot; | 39 | return result; |
| 26 | } | 40 | } |
apps/web/src/components/preview/openTerminalLinkInPreview.ts+15-24| ... | @@ -1,31 +1,21 @@ | ... | @@ -1,31 +1,21 @@ |
| 1 | import type { EnvironmentApi, LocalApi, ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { isPreviewableUrl } from "@t3tools/shared/preview"; | 2 | import { isPreviewableUrl } from "@t3tools/shared/preview"; |
| 3 | 3 | ||
| 4 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | 4 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 5 | import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; | ||
| 5 | import { useRightPanelStore } from "~/rightPanelStore"; | 6 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 6 | 7 | ||
| 7 | interface OpenTerminalLinkInPreviewInput { | 8 | interface OpenTerminalLinkInPreviewInput<E> { |
| 8 | readonly url: string; | 9 | readonly url: string; |
| 9 | readonly position: { x: number; y: number }; | 10 | readonly position: { x: number; y: number }; |
| 10 | readonly threadRef: ScopedThreadRef; | 11 | readonly threadRef: ScopedThreadRef; |
| 11 | readonly api: EnvironmentApi; | 12 | readonly openPreview: OpenPreviewMutation<E>; |
| 12 | readonly localApi: LocalApi; | 13 | readonly localApi: LocalApi; |
| 13 | /** Called whenever the URL ultimately needs to open in the system browser. */ | ||
| 14 | readonly fallbackToBrowser: () => void; | 14 | readonly fallbackToBrowser: () => void; |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | /** | 17 | export async function openTerminalLinkInPreview<E>( |
| 18 | * Handles a terminal-link click that resolves to a URL. | 18 | input: OpenTerminalLinkInPreviewInput<E>, |
| 19 | * | ||
| 20 | * - For non-loopback / unsupported runtimes, defers to the system browser. | ||
| 21 | * - For previewable URLs in the desktop build, presents a context menu to | ||
| 22 | * choose between the in-app preview and the system browser. | ||
| 23 | * | ||
| 24 | * Failures fall back to the system browser so a stuck context-menu doesn't | ||
| 25 | * leave the user without a way to open the link. | ||
| 26 | */ | ||
| 27 | export async function openTerminalLinkInPreview( | ||
| 28 | input: OpenTerminalLinkInPreviewInput, | ||
| 29 | ): Promise<void> { | 19 | ): Promise<void> { |
| 30 | const supportsPreview = | 20 | const supportsPreview = |
| 31 | isPreviewableUrl(input.url) && | 21 | isPreviewableUrl(input.url) && |
| ... | @@ -52,15 +42,16 @@ export async function openTerminalLinkInPreview( | ... | @@ -52,15 +42,16 @@ export async function openTerminalLinkInPreview( |
| 52 | } | 42 | } |
| 53 | 43 | ||
| 54 | if (choice === "open-in-preview") { | 44 | if (choice === "open-in-preview") { |
| 55 | try { | 45 | const result = await input.openPreview({ |
| 56 | await input.api.preview.open({ | 46 | environmentId: input.threadRef.environmentId, |
| 57 | threadId: input.threadRef.threadId, | 47 | input: { threadId: input.threadRef.threadId, url: input.url }, |
| 58 | url: input.url, | 48 | }); |
| 59 | }); | 49 | if (result._tag === "Failure") { |
| 60 | useRightPanelStore.getState().open(input.threadRef, "preview"); | ||
| 61 | } catch { | ||
| 62 | input.fallbackToBrowser(); | 50 | input.fallbackToBrowser(); |
| 51 | return; | ||
| 63 | } | 52 | } |
| 53 | applyPreviewServerSnapshot(input.threadRef, result.value); | ||
| 54 | useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); | ||
| 64 | return; | 55 | return; |
| 65 | } | 56 | } |
| 66 | 57 |
apps/web/src/components/preview/previewSessionState.ts deleted-77| ... | @@ -1,77 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; | ||
| 3 | import type { PreviewListResult, ScopedThreadRef } from "@t3tools/contracts"; | ||
| 4 | import * as Cause from "effect/Cause"; | ||
| 5 | import * as Data from "effect/Data"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 9 | |||
| 10 | import { ensureEnvironmentApi } from "~/environmentApi"; | ||
| 11 | import { readPreviewStateRevision } from "~/previewStateStore"; | ||
| 12 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | ||
| 13 | |||
| 14 | const PREVIEW_SESSION_STALE_TIME_MS = 5_000; | ||
| 15 | const PREVIEW_SESSION_IDLE_TTL_MS = 5 * 60_000; | ||
| 16 | |||
| 17 | class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryError")<{ | ||
| 18 | readonly message: string; | ||
| 19 | readonly cause?: unknown; | ||
| 20 | }> {} | ||
| 21 | |||
| 22 | const previewSessionListAtom = Atom.family((threadKey: string) => | ||
| 23 | Atom.make( | ||
| 24 | Effect.tryPromise({ | ||
| 25 | try: async () => { | ||
| 26 | const threadRef = parseScopedThreadKey(threadKey); | ||
| 27 | if (!threadRef) { | ||
| 28 | throw new Error(`Invalid scoped thread key: ${threadKey}`); | ||
| 29 | } | ||
| 30 | const revision = readPreviewStateRevision(threadRef); | ||
| 31 | const result = await ensureEnvironmentApi(threadRef.environmentId).preview.list({ | ||
| 32 | threadId: threadRef.threadId, | ||
| 33 | }); | ||
| 34 | return { result, revision }; | ||
| 35 | }, | ||
| 36 | catch: (cause) => | ||
| 37 | new PreviewSessionQueryError({ | ||
| 38 | message: "Could not load preview sessions.", | ||
| 39 | cause, | ||
| 40 | }), | ||
| 41 | }), | ||
| 42 | ).pipe( | ||
| 43 | Atom.swr({ | ||
| 44 | staleTime: PREVIEW_SESSION_STALE_TIME_MS, | ||
| 45 | revalidateOnMount: true, | ||
| 46 | }), | ||
| 47 | Atom.setIdleTTL(PREVIEW_SESSION_IDLE_TTL_MS), | ||
| 48 | Atom.withLabel(`preview:sessions:${threadKey}`), | ||
| 49 | ), | ||
| 50 | ); | ||
| 51 | |||
| 52 | export interface PreviewSessionQueryState { | ||
| 53 | readonly data: { | ||
| 54 | readonly result: PreviewListResult; | ||
| 55 | readonly revision: number; | ||
| 56 | } | null; | ||
| 57 | readonly error: string | null; | ||
| 58 | readonly isPending: boolean; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function refreshPreviewSessionState(threadRef: ScopedThreadRef): void { | ||
| 62 | appAtomRegistry.refresh(previewSessionListAtom(scopedThreadKey(threadRef))); | ||
| 63 | } | ||
| 64 | |||
| 65 | export function usePreviewSessionState(threadRef: ScopedThreadRef): PreviewSessionQueryState { | ||
| 66 | const result = useAtomValue(previewSessionListAtom(scopedThreadKey(threadRef))); | ||
| 67 | let error: string | null = null; | ||
| 68 | if (result._tag === "Failure") { | ||
| 69 | const cause = Cause.squash(result.cause); | ||
| 70 | error = cause instanceof Error ? cause.message : "Could not load preview sessions."; | ||
| 71 | } | ||
| 72 | return { | ||
| 73 | data: Option.getOrNull(AsyncResult.value(result)), | ||
| 74 | error, | ||
| 75 | isPending: result.waiting, | ||
| 76 | }; | ||
| 77 | } | ||
apps/web/src/components/preview/usePreviewBridge.ts+10-7| ... | @@ -9,8 +9,9 @@ import type { | ... | @@ -9,8 +9,9 @@ import type { |
| 9 | import { useEffect, useRef } from "react"; | 9 | import { useEffect, useRef } from "react"; |
| 10 | 10 | ||
| 11 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; | 11 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; |
| 12 | import { ensureEnvironmentApi } from "~/environmentApi"; | 12 | import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore"; |
| 13 | import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; | 13 | import { previewEnvironment } from "~/state/preview"; |
| 14 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 14 | 15 | ||
| 15 | import { previewBridge } from "./previewBridge"; | 16 | import { previewBridge } from "./previewBridge"; |
| 16 | 17 | ||
| ... | @@ -20,8 +21,8 @@ import { previewBridge } from "./previewBridge"; | ... | @@ -20,8 +21,8 @@ import { previewBridge } from "./previewBridge"; |
| 20 | */ | 21 | */ |
| 21 | export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { | 22 | export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { |
| 22 | const { threadRef, tabId } = input; | 23 | const { threadRef, tabId } = input; |
| 23 | const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); | ||
| 24 | const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); | 24 | const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); |
| 25 | const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report"); | ||
| 25 | const bridge = previewBridge; | 26 | const bridge = previewBridge; |
| 26 | 27 | ||
| 27 | // One bridge subscription does both jobs (mirror state + forward to | 28 | // One bridge subscription does both jobs (mirror state + forward to |
| ... | @@ -31,7 +32,6 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str | ... | @@ -31,7 +32,6 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str |
| 31 | const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null); | 32 | const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null); |
| 32 | useEffect(() => { | 33 | useEffect(() => { |
| 33 | if (!bridge || typeof window === "undefined") return; | 34 | if (!bridge || typeof window === "undefined") return; |
| 34 | const api = ensureEnvironmentApi(threadRef.environmentId); | ||
| 35 | lastReportedUrl.current = null; | 35 | lastReportedUrl.current = null; |
| 36 | lastReportedKind.current = null; | 36 | lastReportedKind.current = null; |
| 37 | lastDesktopNavStatus.current = null; | 37 | lastDesktopNavStatus.current = null; |
| ... | @@ -41,7 +41,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str | ... | @@ -41,7 +41,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str |
| 41 | clearBrowserPointer(tabId); | 41 | clearBrowserPointer(tabId); |
| 42 | } | 42 | } |
| 43 | lastDesktopNavStatus.current = state.navStatus; | 43 | lastDesktopNavStatus.current = state.navStatus; |
| 44 | applyDesktopState(threadRef, tabId, projectDesktopState(state)); | 44 | applyPreviewDesktopState(threadRef, tabId, projectDesktopState(state)); |
| 45 | const reported = buildReportInput({ | 45 | const reported = buildReportInput({ |
| 46 | threadId: threadRef.threadId, | 46 | threadId: threadRef.threadId, |
| 47 | tabId, | 47 | tabId, |
| ... | @@ -52,10 +52,13 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str | ... | @@ -52,10 +52,13 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str |
| 52 | if (!reported) return; | 52 | if (!reported) return; |
| 53 | lastReportedUrl.current = reported.lastReportedUrl; | 53 | lastReportedUrl.current = reported.lastReportedUrl; |
| 54 | lastReportedKind.current = reported.lastReportedKind; | 54 | lastReportedKind.current = reported.lastReportedKind; |
| 55 | void api.preview.reportStatus(reported.input).catch(() => undefined); | 55 | void reportStatus({ |
| 56 | environmentId: threadRef.environmentId, | ||
| 57 | input: reported.input, | ||
| 58 | }); | ||
| 56 | }); | 59 | }); |
| 57 | return unsubscribe; | 60 | return unsubscribe; |
| 58 | }, [applyDesktopState, bridge, clearBrowserPointer, tabId, threadRef]); | 61 | }, [bridge, clearBrowserPointer, reportStatus, tabId, threadRef]); |
| 59 | } | 62 | } |
| 60 | 63 | ||
| 61 | function shouldClearBrowserPointer( | 64 | function shouldClearBrowserPointer( |
apps/web/src/components/preview/usePreviewSession.ts+95-94| ... | @@ -1,110 +1,111 @@ | ... | @@ -1,110 +1,111 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { scopedThreadKey } from "@t3tools/client-runtime"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; | ||
| 5 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | ||
| 4 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 6 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 5 | import { useEffect } from "react"; | 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 6 | 8 | ||
| 7 | import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi"; | 9 | import { |
| 8 | import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime"; | 10 | applyPreviewServerEvent, |
| 9 | import { readPreviewStateRevision, usePreviewStateStore } from "~/previewStateStore"; | 11 | applyPreviewServerSnapshot, |
| 12 | readThreadPreviewState, | ||
| 13 | } from "~/previewStateStore"; | ||
| 14 | import { previewEnvironment } from "~/state/preview"; | ||
| 10 | 15 | ||
| 11 | import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; | 16 | const previewSessionSyncAtom = Atom.family((threadKey: string) => { |
| 17 | const threadRef = parseScopedThreadKey(threadKey); | ||
| 18 | if (!threadRef) { | ||
| 19 | throw new Error(`Invalid scoped preview thread key: ${threadKey}`); | ||
| 20 | } | ||
| 12 | 21 | ||
| 13 | /** | 22 | const sessionsAtom = previewEnvironment.list({ |
| 14 | * Subscribes to the server's per-thread preview events and replays the | 23 | environmentId: threadRef.environmentId, |
| 15 | * latest snapshot on mount. | 24 | input: { threadId: threadRef.threadId }, |
| 16 | * | 25 | }); |
| 17 | * Reconnect-recovery: when the local renderer remembers a snapshot but the | 26 | const eventsAtom = previewEnvironment.events({ |
| 18 | * server has none (server restarted while we were alive), re-issue | 27 | environmentId: threadRef.environmentId, |
| 19 | * `preview.open` so subsequent events land on a real session. | 28 | input: {}, |
| 20 | */ | 29 | }); |
| 21 | export function usePreviewSession(threadRef: ScopedThreadRef): void { | ||
| 22 | const query = usePreviewSessionState(threadRef); | ||
| 23 | const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); | ||
| 24 | const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); | ||
| 25 | |||
| 26 | useEffect(() => { | ||
| 27 | // SWR retains stale data while revalidating. Do not project that stale | ||
| 28 | // snapshot back into the live store because it can resurrect a session | ||
| 29 | // that was just closed. | ||
| 30 | if ( | ||
| 31 | query.isPending || | ||
| 32 | !query.data || | ||
| 33 | query.data.revision !== readPreviewStateRevision(threadRef) | ||
| 34 | ) { | ||
| 35 | return; | ||
| 36 | } | ||
| 37 | const threadIdValue = threadRef.threadId; | ||
| 38 | let cancelled = false; | ||
| 39 | if (query.data.result.sessions.length > 0) { | ||
| 40 | for (const snapshot of query.data.result.sessions) { | ||
| 41 | applyServerSnapshot(threadRef, snapshot); | ||
| 42 | } | ||
| 43 | return; | ||
| 44 | } | ||
| 45 | |||
| 46 | // Server has no sessions — try to recover what the renderer remembers | ||
| 47 | // from before the disconnect. | ||
| 48 | const localSnapshot = | ||
| 49 | usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; | ||
| 50 | const recoverableUrl = | ||
| 51 | localSnapshot && localSnapshot.navStatus._tag !== "Idle" ? localSnapshot.navStatus.url : null; | ||
| 52 | if (!recoverableUrl) { | ||
| 53 | applyServerSnapshot(threadRef, null); | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | |||
| 57 | const api = ensureEnvironmentApi(threadRef.environmentId); | ||
| 58 | void api.preview | ||
| 59 | .open({ threadId: threadIdValue, url: recoverableUrl }) | ||
| 60 | .then((snapshot) => { | ||
| 61 | if (cancelled) return; | ||
| 62 | applyServerSnapshot(threadRef, snapshot); | ||
| 63 | refreshPreviewSessionState(threadRef); | ||
| 64 | }) | ||
| 65 | .catch(() => undefined); | ||
| 66 | |||
| 67 | return () => { | ||
| 68 | cancelled = true; | ||
| 69 | }; | ||
| 70 | }, [applyServerSnapshot, query.data, query.isPending, threadRef]); | ||
| 71 | 30 | ||
| 72 | useEffect(() => { | 31 | return Atom.make((get) => { |
| 73 | if (typeof window === "undefined") return; | 32 | let disposed = false; |
| 74 | let clientIdentity: object | null = null; | 33 | let recoveryId = 0; |
| 75 | let unsubscribeEvents: () => void = () => undefined; | 34 | let recoveringUrl: string | null = null; |
| 35 | let sessionsVersion = 0; | ||
| 36 | let eventsVersion = 0; | ||
| 76 | 37 | ||
| 77 | const attach = () => { | 38 | const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => { |
| 78 | const connection = readEnvironmentConnection(threadRef.environmentId); | 39 | if (!AsyncResult.isSuccess(result)) return; |
| 79 | const api = readEnvironmentApi(threadRef.environmentId); | 40 | if (result.value.sessions.length > 0) { |
| 80 | const nextIdentity = connection?.client ?? api ?? null; | 41 | recoveringUrl = null; |
| 81 | if (nextIdentity === clientIdentity) return; | 42 | recoveryId += 1; |
| 43 | for (const snapshot of result.value.sessions) { | ||
| 44 | applyPreviewServerSnapshot(threadRef, snapshot); | ||
| 45 | } | ||
| 46 | return; | ||
| 47 | } | ||
| 82 | 48 | ||
| 83 | unsubscribeEvents(); | 49 | const localSnapshot = readThreadPreviewState(threadRef).snapshot; |
| 84 | unsubscribeEvents = () => undefined; | 50 | const recoverableUrl = |
| 85 | clientIdentity = nextIdentity; | 51 | localSnapshot && localSnapshot.navStatus._tag !== "Idle" |
| 86 | if (!api) return; | 52 | ? localSnapshot.navStatus.url |
| 53 | : null; | ||
| 54 | if (!recoverableUrl) { | ||
| 55 | applyPreviewServerSnapshot(threadRef, null); | ||
| 56 | return; | ||
| 57 | } | ||
| 58 | if (recoveringUrl === recoverableUrl) return; | ||
| 87 | 59 | ||
| 88 | refreshPreviewSessionState(threadRef); | 60 | recoveringUrl = recoverableUrl; |
| 89 | unsubscribeEvents = api.preview.onEvent( | 61 | const currentRecoveryId = ++recoveryId; |
| 90 | (event) => { | 62 | void runAtomCommand( |
| 91 | if (event.threadId !== threadRef.threadId) return; | 63 | get.registry, |
| 92 | applyServerEvent(threadRef, event); | 64 | previewEnvironment.open, |
| 93 | if (event.type === "opened" || event.type === "closed") { | ||
| 94 | refreshPreviewSessionState(threadRef); | ||
| 95 | } | ||
| 96 | }, | ||
| 97 | { | 65 | { |
| 98 | onResubscribe: () => refreshPreviewSessionState(threadRef), | 66 | environmentId: threadRef.environmentId, |
| 67 | input: { threadId: threadRef.threadId, url: recoverableUrl }, | ||
| 99 | }, | 68 | }, |
| 100 | ); | 69 | { reportDefect: false, reportFailure: false }, |
| 70 | ).then((openResult) => { | ||
| 71 | if (disposed || currentRecoveryId !== recoveryId) return; | ||
| 72 | recoveringUrl = null; | ||
| 73 | if (openResult._tag === "Failure") return; | ||
| 74 | applyPreviewServerSnapshot(threadRef, openResult.value); | ||
| 75 | get.refresh(sessionsAtom); | ||
| 76 | }); | ||
| 101 | }; | 77 | }; |
| 102 | 78 | ||
| 103 | const unsubscribeConnections = subscribeEnvironmentConnections(attach); | 79 | const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => { |
| 104 | attach(); | 80 | if (!AsyncResult.isSuccess(result) || result.value.threadId !== threadRef.threadId) return; |
| 105 | return () => { | 81 | applyPreviewServerEvent(threadRef, result.value); |
| 106 | unsubscribeConnections(); | 82 | if (result.value.type === "opened" || result.value.type === "closed") { |
| 107 | unsubscribeEvents(); | 83 | get.refresh(sessionsAtom); |
| 84 | } | ||
| 108 | }; | 85 | }; |
| 109 | }, [applyServerEvent, threadRef]); | 86 | |
| 87 | get.addFinalizer(() => { | ||
| 88 | disposed = true; | ||
| 89 | recoveryId += 1; | ||
| 90 | }); | ||
| 91 | const initialSessions = get.once(sessionsAtom); | ||
| 92 | const initialEvent = get.once(eventsAtom); | ||
| 93 | get.subscribe(sessionsAtom, (result) => { | ||
| 94 | sessionsVersion += 1; | ||
| 95 | reconcileSessions(result); | ||
| 96 | }); | ||
| 97 | get.subscribe(eventsAtom, (result) => { | ||
| 98 | eventsVersion += 1; | ||
| 99 | applyLatestEvent(result); | ||
| 100 | }); | ||
| 101 | queueMicrotask(() => { | ||
| 102 | if (disposed) return; | ||
| 103 | if (sessionsVersion === 0) reconcileSessions(initialSessions); | ||
| 104 | if (eventsVersion === 0) applyLatestEvent(initialEvent); | ||
| 105 | }); | ||
| 106 | }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`preview:session-sync:${threadKey}`)); | ||
| 107 | }); | ||
| 108 | |||
| 109 | export function usePreviewSession(threadRef: ScopedThreadRef): void { | ||
| 110 | useAtomValue(previewSessionSyncAtom(scopedThreadKey(threadRef))); | ||
| 110 | } | 111 | } |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+5-27| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | ||
| 3 | import { CheckIcon } from "lucide-react"; | 3 | import { CheckIcon } from "lucide-react"; |
| 4 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | 4 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; |
| 5 | import { useCallback, useEffect, useMemo, useState } from "react"; | 5 | import { useCallback, useMemo, useState } from "react"; |
| 6 | import { | 6 | import { |
| 7 | ProviderInstanceId, | 7 | ProviderInstanceId, |
| 8 | ProviderDriverKind, | 8 | ProviderDriverKind, |
| ... | @@ -115,14 +115,13 @@ interface AddProviderInstanceDialogProps { | ... | @@ -115,14 +115,13 @@ interface AddProviderInstanceDialogProps { |
| 115 | 115 | ||
| 116 | export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { | 116 | export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { |
| 117 | const settings = useSettings(); | 117 | const settings = useSettings(); |
| 118 | const { updateSettings } = useUpdateSettings(); | 118 | const updateSettings = useUpdateSettings(); |
| 119 | 119 | ||
| 120 | const [wizardStep, setWizardStep] = useState(0); | 120 | const [wizardStep, setWizardStep] = useState(0); |
| 121 | const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND); | 121 | const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND); |
| 122 | const [label, setLabel] = useState(""); | 122 | const [label, setLabel] = useState(""); |
| 123 | const [accentColor, setAccentColor] = useState<string>(""); | 123 | const [accentColor, setAccentColor] = useState<string>(""); |
| 124 | const [instanceId, setInstanceId] = useState(""); | 124 | const [instanceIdOverride, setInstanceIdOverride] = useState<string | null>(null); |
| 125 | const [instanceIdDirty, setInstanceIdDirty] = useState(false); | ||
| 126 | // Driver-specific config drafts keyed by driver so toggling between drivers | 125 | // Driver-specific config drafts keyed by driver so toggling between drivers |
| 127 | // during the same dialog session does not lose in-progress input. | 126 | // during the same dialog session does not lose in-progress input. |
| 128 | const [configByDriver, setConfigByDriver] = useState<Record<string, Record<string, unknown>>>({}); | 127 | const [configByDriver, setConfigByDriver] = useState<Record<string, Record<string, unknown>>>({}); |
| ... | @@ -135,28 +134,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns | ... | @@ -135,28 +134,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 135 | [settings.providerInstances], | 134 | [settings.providerInstances], |
| 136 | ); | 135 | ); |
| 137 | 136 | ||
| 138 | // Reset the form every time the dialog opens so each creation starts | ||
| 139 | // from a clean slate. | ||
| 140 | useEffect(() => { | ||
| 141 | if (!open) return; | ||
| 142 | setDriver(DEFAULT_DRIVER_KIND); | ||
| 143 | setLabel(""); | ||
| 144 | setAccentColor(""); | ||
| 145 | setInstanceId(""); | ||
| 146 | setWizardStep(0); | ||
| 147 | setInstanceIdDirty(false); | ||
| 148 | setConfigByDriver({}); | ||
| 149 | setHasAttemptedSubmit(false); | ||
| 150 | }, [open]); | ||
| 151 | |||
| 152 | // Auto-derive the instance id from driver + label until the user types | ||
| 153 | // in the Instance ID field directly (after which they own its value). | ||
| 154 | useEffect(() => { | ||
| 155 | if (instanceIdDirty) return; | ||
| 156 | setInstanceId(deriveInstanceId(driver, label)); | ||
| 157 | }, [driver, label, instanceIdDirty]); | ||
| 158 | |||
| 159 | const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; | 137 | const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; |
| 138 | const instanceId = instanceIdOverride ?? deriveInstanceId(driver, label); | ||
| 160 | const driverSettingsFields = useMemo( | 139 | const driverSettingsFields = useMemo( |
| 161 | () => deriveProviderSettingsFields(driverOption), | 140 | () => deriveProviderSettingsFields(driverOption), |
| 162 | [driverOption], | 141 | [driverOption], |
| ... | @@ -379,8 +358,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns | ... | @@ -379,8 +358,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 379 | placeholder={`${driver}_work`} | 358 | placeholder={`${driver}_work`} |
| 380 | value={instanceId} | 359 | value={instanceId} |
| 381 | onChange={(event) => { | 360 | onChange={(event) => { |
| 382 | setInstanceIdDirty(true); | 361 | setInstanceIdOverride(event.target.value); |
| 383 | setInstanceId(event.target.value); | ||
| 384 | }} | 362 | }} |
| 385 | aria-invalid={showInstanceIdError} | 363 | aria-invalid={showInstanceIdError} |
| 386 | /> | 364 | /> |
apps/web/src/components/settings/ConnectionsSettings.tsx+545-701| ... | @@ -29,9 +29,20 @@ import { | ... | @@ -29,9 +29,20 @@ import { |
| 29 | type DesktopServerExposureState, | 29 | type DesktopServerExposureState, |
| 30 | type EnvironmentId, | 30 | type EnvironmentId, |
| 31 | } from "@t3tools/contracts"; | 31 | } from "@t3tools/contracts"; |
| 32 | import { WsRpcClient } from "@t3tools/client-runtime"; | 32 | import { |
| 33 | connectionStatusText, | ||
| 34 | RelayConnectionRegistration, | ||
| 35 | RelayConnectionTarget, | ||
| 36 | } from "@t3tools/client-runtime/connection"; | ||
| 37 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; | ||
| 38 | import { | ||
| 39 | isAtomCommandInterrupted, | ||
| 40 | settlePromise, | ||
| 41 | squashAtomCommandFailure, | ||
| 42 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 33 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | 43 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; |
| 34 | import * as DateTime from "effect/DateTime"; | 44 | import * as DateTime from "effect/DateTime"; |
| 45 | import * as Option from "effect/Option"; | ||
| 35 | 46 | ||
| 36 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 47 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 37 | import { cn } from "../../lib/utils"; | 48 | import { cn } from "../../lib/utils"; |
| ... | @@ -76,7 +87,6 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; | ... | @@ -76,7 +87,6 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 76 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 87 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 77 | import { Button } from "../ui/button"; | 88 | import { Button } from "../ui/button"; |
| 78 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | 89 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; |
| 79 | import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; | ||
| 80 | import { Group, GroupSeparator } from "../ui/group"; | 90 | import { Group, GroupSeparator } from "../ui/group"; |
| 81 | import { AnimatedHeight } from "../AnimatedHeight"; | 91 | import { AnimatedHeight } from "../AnimatedHeight"; |
| 82 | import { | 92 | import { |
| ... | @@ -97,42 +107,42 @@ import { | ... | @@ -97,42 +107,42 @@ import { |
| 97 | revokeServerClientSession, | 107 | revokeServerClientSession, |
| 98 | revokeServerPairingLink, | 108 | revokeServerPairingLink, |
| 99 | isLoopbackHostname, | 109 | isLoopbackHostname, |
| 100 | usePrimaryEnvironmentId, | ||
| 101 | usePrimarySessionState, | 110 | usePrimarySessionState, |
| 102 | type ServerClientSessionRecord, | 111 | type ServerClientSessionRecord, |
| 103 | type ServerPairingLinkRecord, | 112 | type ServerPairingLinkRecord, |
| 104 | } from "~/environments/primary"; | 113 | } from "~/environments/primary"; |
| 105 | import { | ||
| 106 | type SavedEnvironmentRecord, | ||
| 107 | type SavedEnvironmentRuntimeState, | ||
| 108 | useSavedEnvironmentRegistryStore, | ||
| 109 | useSavedEnvironmentRuntimeStore, | ||
| 110 | addSavedEnvironment, | ||
| 111 | addManagedRelayEnvironment, | ||
| 112 | connectDesktopSshEnvironment, | ||
| 113 | disconnectSavedEnvironment, | ||
| 114 | getPrimaryEnvironmentConnection, | ||
| 115 | reconnectSavedEnvironment, | ||
| 116 | removeSavedEnvironment, | ||
| 117 | } from "~/environments/runtime"; | ||
| 118 | import { useUiStateStore } from "~/uiStateStore"; | 114 | import { useUiStateStore } from "~/uiStateStore"; |
| 119 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; | 115 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; |
| 120 | import { useServerConfig } from "~/rpc/serverState"; | ||
| 121 | import { | ||
| 122 | connectManagedCloudEnvironment, | ||
| 123 | linkPrimaryEnvironmentToCloud, | ||
| 124 | unlinkPrimaryEnvironmentFromCloud, | ||
| 125 | updatePrimaryCloudPreferences, | ||
| 126 | } from "~/cloud/linkEnvironment"; | ||
| 127 | import { | ||
| 128 | refreshManagedRelayEnvironments, | ||
| 129 | useManagedRelayEnvironments, | ||
| 130 | } from "~/cloud/managedRelayState"; | ||
| 131 | import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; | 116 | import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; |
| 132 | import { webRuntime } from "~/lib/runtime"; | ||
| 133 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | 117 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 118 | import { | ||
| 119 | linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, | ||
| 120 | unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, | ||
| 121 | } from "~/cloud/linkEnvironmentAtoms"; | ||
| 122 | import { authEnvironment } from "~/state/auth"; | ||
| 123 | import { environmentCatalog } from "~/connection/catalog"; | ||
| 124 | import { | ||
| 125 | connectPairing as connectPairingAtom, | ||
| 126 | connectSshEnvironment as connectSshEnvironmentAtom, | ||
| 127 | } from "~/connection/onboarding"; | ||
| 128 | import { useEnvironmentQuery } from "~/state/query"; | ||
| 129 | import { | ||
| 130 | desktopNetworkAccessStateAtom, | ||
| 131 | refreshDesktopNetworkAccessState, | ||
| 132 | } from "~/state/desktopNetworkAccess"; | ||
| 133 | import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts"; | ||
| 134 | import { | ||
| 135 | type EnvironmentPresentation, | ||
| 136 | useEnvironments, | ||
| 137 | usePrimaryEnvironment, | ||
| 138 | useRelayEnvironmentDiscovery, | ||
| 139 | } from "~/state/environments"; | ||
| 140 | import { relayEnvironmentDiscovery } from "~/state/relay"; | ||
| 141 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 134 | 142 | ||
| 135 | const DEFAULT_TAILSCALE_SERVE_PORT = 443; | 143 | const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 144 | const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = []; | ||
| 145 | const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray<DesktopDiscoveredSshHost> = []; | ||
| 136 | 146 | ||
| 137 | const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { | 147 | const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { |
| 138 | dateStyle: "medium", | 148 | dateStyle: "medium", |
| ... | @@ -274,6 +284,7 @@ function ConnectionStatusDot({ | ... | @@ -274,6 +284,7 @@ function ConnectionStatusDot({ |
| 274 | const dot = ( | 284 | const dot = ( |
| 275 | <button | 285 | <button |
| 276 | type="button" | 286 | type="button" |
| 287 | title={tooltipText} | ||
| 277 | aria-label={tooltipText} | 288 | aria-label={tooltipText} |
| 278 | className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden" | 289 | className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden" |
| 279 | > | 290 | > |
| ... | @@ -291,32 +302,7 @@ function ConnectionStatusDot({ | ... | @@ -291,32 +302,7 @@ function ConnectionStatusDot({ |
| 291 | ); | 302 | ); |
| 292 | } | 303 | } |
| 293 | 304 | ||
| 294 | function getSavedBackendStatusTooltip( | 305 | function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { |
| 295 | runtime: SavedEnvironmentRuntimeState | null, | ||
| 296 | record: SavedEnvironmentRecord, | ||
| 297 | nowMs: number, | ||
| 298 | ) { | ||
| 299 | const connectionState = runtime?.connectionState ?? "disconnected"; | ||
| 300 | |||
| 301 | if (connectionState === "connected") { | ||
| 302 | const connectedAt = runtime?.connectedAt ?? record.lastConnectedAt; | ||
| 303 | return connectedAt ? `Connected for ${formatElapsedDurationLabel(connectedAt, nowMs)}` : null; | ||
| 304 | } | ||
| 305 | |||
| 306 | if (connectionState === "connecting") { | ||
| 307 | return null; | ||
| 308 | } | ||
| 309 | |||
| 310 | if (connectionState === "error") { | ||
| 311 | return runtime?.lastError ?? "An unknown connection error occurred."; | ||
| 312 | } | ||
| 313 | |||
| 314 | return record.lastConnectedAt | ||
| 315 | ? `Last connected at ${formatAccessTimestamp(record.lastConnectedAt)}` | ||
| 316 | : "Not connected yet."; | ||
| 317 | } | ||
| 318 | |||
| 319 | function formatDesktopSshTarget(target: NonNullable<SavedEnvironmentRecord["desktopSsh"]>): string { | ||
| 320 | const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; | 306 | const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; |
| 321 | return target.port ? `${authority}:${target.port}` : authority; | 307 | return target.port ? `${authority}:${target.port}` : authority; |
| 322 | } | 308 | } |
| ... | @@ -501,45 +487,6 @@ function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerC | ... | @@ -501,45 +487,6 @@ function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerC |
| 501 | }; | 487 | }; |
| 502 | } | 488 | } |
| 503 | 489 | ||
| 504 | function upsertDesktopPairingLink( | ||
| 505 | current: ReadonlyArray<ServerPairingLinkRecord>, | ||
| 506 | next: ServerPairingLinkRecord, | ||
| 507 | ) { | ||
| 508 | const existingIndex = current.findIndex((pairingLink) => pairingLink.id === next.id); | ||
| 509 | if (existingIndex === -1) { | ||
| 510 | return sortDesktopPairingLinks([...current, next]); | ||
| 511 | } | ||
| 512 | const updated = [...current]; | ||
| 513 | updated[existingIndex] = next; | ||
| 514 | return sortDesktopPairingLinks(updated); | ||
| 515 | } | ||
| 516 | |||
| 517 | function removeDesktopPairingLink(current: ReadonlyArray<ServerPairingLinkRecord>, id: string) { | ||
| 518 | return current.filter((pairingLink) => pairingLink.id !== id); | ||
| 519 | } | ||
| 520 | |||
| 521 | function upsertDesktopClientSession( | ||
| 522 | current: ReadonlyArray<ServerClientSessionRecord>, | ||
| 523 | next: ServerClientSessionRecord, | ||
| 524 | ) { | ||
| 525 | const existingIndex = current.findIndex( | ||
| 526 | (clientSession) => clientSession.sessionId === next.sessionId, | ||
| 527 | ); | ||
| 528 | if (existingIndex === -1) { | ||
| 529 | return sortDesktopClientSessions([...current, next]); | ||
| 530 | } | ||
| 531 | const updated = [...current]; | ||
| 532 | updated[existingIndex] = next; | ||
| 533 | return sortDesktopClientSessions(updated); | ||
| 534 | } | ||
| 535 | |||
| 536 | function removeDesktopClientSession( | ||
| 537 | current: ReadonlyArray<ServerClientSessionRecord>, | ||
| 538 | sessionId: ServerClientSessionRecord["sessionId"], | ||
| 539 | ) { | ||
| 540 | return current.filter((clientSession) => clientSession.sessionId !== sessionId); | ||
| 541 | } | ||
| 542 | |||
| 543 | function selectPairingEndpoint( | 490 | function selectPairingEndpoint( |
| 544 | endpoints: ReadonlyArray<AdvertisedEndpoint>, | 491 | endpoints: ReadonlyArray<AdvertisedEndpoint>, |
| 545 | defaultEndpointKey?: string | null, | 492 | defaultEndpointKey?: string | null, |
| ... | @@ -880,16 +827,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -880,16 +827,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 880 | ) : null} | 827 | ) : null} |
| 881 | </Popover> | 828 | </Popover> |
| 882 | </div> | 829 | </div> |
| 883 | <Tooltip> | 830 | <p className="text-xs text-muted-foreground" title={expiresAbsolute}> |
| 884 | <TooltipTrigger | 831 | {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} |
| 885 | render={<p aria-label={expiresAbsolute} className="text-xs text-muted-foreground" />} | 832 | <span aria-hidden> · </span> |
| 886 | > | 833 | <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" /> |
| 887 | {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} | 834 | </p> |
| 888 | <span aria-hidden> · </span> | ||
| 889 | <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" /> | ||
| 890 | </TooltipTrigger> | ||
| 891 | <TooltipPopup side="top">{expiresAbsolute}</TooltipPopup> | ||
| 892 | </Tooltip> | ||
| 893 | {shareablePairingUrl === null ? ( | 835 | {shareablePairingUrl === null ? ( |
| 894 | <p className="text-[11px] text-muted-foreground/70"> | 836 | <p className="text-[11px] text-muted-foreground/70"> |
| 895 | Copy the token and pair from another client using this backend&apos;s reachable host. | 837 | Copy the token and pair from another client using this backend&apos;s reachable host. |
| ... | @@ -902,26 +844,17 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -902,26 +844,17 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 902 | <> | 844 | <> |
| 903 | {shareablePairingUrl ? ( | 845 | {shareablePairingUrl ? ( |
| 904 | <Group aria-label="Copy selected endpoint"> | 846 | <Group aria-label="Copy selected endpoint"> |
| 905 | <Tooltip> | 847 | <Button |
| 906 | <TooltipTrigger | 848 | size="xs" |
| 907 | render={ | 849 | variant="outline" |
| 908 | <Button | 850 | className="max-w-56" |
| 909 | size="xs" | 851 | title={`Copy pairing URL for: ${defaultEndpointCopyLabel}`} |
| 910 | variant="outline" | 852 | onClick={handleCopyDefaultLink} |
| 911 | className="max-w-56" | 853 | > |
| 912 | aria-label={`Copy pairing URL for: ${defaultEndpointCopyLabel}`} | 854 | <span className="truncate"> |
| 913 | onClick={handleCopyDefaultLink} | ||
| 914 | /> | ||
| 915 | } | ||
| 916 | > | ||
| 917 | <span className="truncate"> | ||
| 918 | Copy pairing URL for: {defaultEndpointCopyLabel} | ||
| 919 | </span> | ||
| 920 | </TooltipTrigger> | ||
| 921 | <TooltipPopup side="top"> | ||
| 922 | Copy pairing URL for: {defaultEndpointCopyLabel} | 855 | Copy pairing URL for: {defaultEndpointCopyLabel} |
| 923 | </TooltipPopup> | 856 | </span> |
| 924 | </Tooltip> | 857 | </Button> |
| 925 | <GroupSeparator /> | 858 | <GroupSeparator /> |
| 926 | <Menu> | 859 | <Menu> |
| 927 | <MenuTrigger | 860 | <MenuTrigger |
| ... | @@ -1366,19 +1299,12 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ | ... | @@ -1366,19 +1299,12 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ |
| 1366 | {endpoint.label} | 1299 | {endpoint.label} |
| 1367 | </h3> | 1300 | </h3> |
| 1368 | {shouldShowEndpointUrl ? ( | 1301 | {shouldShowEndpointUrl ? ( |
| 1369 | <Tooltip> | 1302 | <p |
| 1370 | <TooltipTrigger | 1303 | className="min-w-0 truncate text-xs leading-5 text-muted-foreground" |
| 1371 | render={ | 1304 | title={endpoint.httpBaseUrl} |
| 1372 | <p | 1305 | > |
| 1373 | aria-label={endpoint.httpBaseUrl} | 1306 | {endpoint.httpBaseUrl} |
| 1374 | className="min-w-0 truncate text-xs leading-5 text-muted-foreground" | 1307 | </p> |
| 1375 | /> | ||
| 1376 | } | ||
| 1377 | > | ||
| 1378 | {endpoint.httpBaseUrl} | ||
| 1379 | </TooltipTrigger> | ||
| 1380 | <TooltipPopup side="top">{endpoint.httpBaseUrl}</TooltipPopup> | ||
| 1381 | </Tooltip> | ||
| 1382 | ) : null} | 1308 | ) : null} |
| 1383 | {!isAvailable ? ( | 1309 | {!isAvailable ? ( |
| 1384 | <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-[10px] text-muted-foreground"> | 1310 | <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-[10px] text-muted-foreground"> |
| ... | @@ -1471,54 +1397,42 @@ function NetworkAccessDescription({ | ... | @@ -1471,54 +1397,42 @@ function NetworkAccessDescription({ |
| 1471 | } | 1397 | } |
| 1472 | 1398 | ||
| 1473 | type SavedBackendListRowProps = { | 1399 | type SavedBackendListRowProps = { |
| 1474 | environmentId: EnvironmentId; | 1400 | environment: EnvironmentPresentation; |
| 1475 | reconnectingEnvironmentId: EnvironmentId | null; | ||
| 1476 | disconnectingEnvironmentId: EnvironmentId | null; | ||
| 1477 | removingEnvironmentId: EnvironmentId | null; | 1401 | removingEnvironmentId: EnvironmentId | null; |
| 1478 | onConnect: (environmentId: EnvironmentId) => void; | 1402 | onConnect: (environmentId: EnvironmentId) => void; |
| 1479 | onDisconnect: (environmentId: EnvironmentId) => void; | ||
| 1480 | onRemove: (environmentId: EnvironmentId) => void; | 1403 | onRemove: (environmentId: EnvironmentId) => void; |
| 1481 | }; | 1404 | }; |
| 1482 | 1405 | ||
| 1483 | function SavedBackendListRow({ | 1406 | function SavedBackendListRow({ |
| 1484 | environmentId, | 1407 | environment, |
| 1485 | reconnectingEnvironmentId, | ||
| 1486 | disconnectingEnvironmentId, | ||
| 1487 | removingEnvironmentId, | 1408 | removingEnvironmentId, |
| 1488 | onConnect, | 1409 | onConnect, |
| 1489 | onDisconnect, | ||
| 1490 | onRemove, | 1410 | onRemove, |
| 1491 | }: SavedBackendListRowProps) { | 1411 | }: SavedBackendListRowProps) { |
| 1492 | const nowMs = useRelativeTimeTick(1_000); | 1412 | const environmentId = environment.environmentId; |
| 1493 | const record = useSavedEnvironmentRegistryStore((state) => state.byId[environmentId] ?? null); | 1413 | const connectionState = environment.connection.phase; |
| 1494 | const runtime = useSavedEnvironmentRuntimeStore((state) => state.byId[environmentId] ?? null); | ||
| 1495 | |||
| 1496 | if (!record) { | ||
| 1497 | return null; | ||
| 1498 | } | ||
| 1499 | |||
| 1500 | const connectionState = runtime?.connectionState ?? "disconnected"; | ||
| 1501 | const isConnected = connectionState === "connected"; | 1414 | const isConnected = connectionState === "connected"; |
| 1502 | const isConnecting = | 1415 | const isConnecting = connectionState === "connecting" || connectionState === "reconnecting"; |
| 1503 | connectionState === "connecting" || reconnectingEnvironmentId === environmentId; | ||
| 1504 | const isDisconnecting = disconnectingEnvironmentId === environmentId; | ||
| 1505 | const stateDotClassName = | 1416 | const stateDotClassName = |
| 1506 | connectionState === "connected" | 1417 | connectionState === "connected" |
| 1507 | ? "bg-success" | 1418 | ? "bg-success" |
| 1508 | : connectionState === "connecting" | 1419 | : connectionState === "connecting" || connectionState === "reconnecting" |
| 1509 | ? "bg-warning" | 1420 | ? "bg-warning" |
| 1510 | : connectionState === "error" | 1421 | : connectionState === "error" |
| 1511 | ? "bg-destructive" | 1422 | ? "bg-destructive" |
| 1512 | : "bg-muted-foreground/40"; | 1423 | : "bg-muted-foreground/40"; |
| 1513 | const descriptorLabel = runtime?.descriptor?.label ?? null; | 1424 | const statusTooltip = connectionStatusText(environment.connection); |
| 1514 | const displayLabel = descriptorLabel ?? record.label; | 1425 | const errorTraceId = environment.connection.traceId; |
| 1515 | const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); | 1426 | const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); |
| 1516 | const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig); | 1427 | const sshTarget = |
| 1428 | environment.entry.target._tag === "SshConnectionTarget" && | ||
| 1429 | Option.isSome(environment.entry.profile) && | ||
| 1430 | environment.entry.profile.value._tag === "SshConnectionProfile" | ||
| 1431 | ? environment.entry.profile.value.target | ||
| 1432 | : null; | ||
| 1517 | const metadataBits = [ | 1433 | const metadataBits = [ |
| 1518 | record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, | 1434 | sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, |
| 1519 | record.lastConnectedAt | 1435 | environment.relayManaged ? "T3 Cloud" : null, |
| 1520 | ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` | ||
| 1521 | : null, | ||
| 1522 | ].filter((value): value is string => value !== null); | 1436 | ].filter((value): value is string => value !== null); |
| 1523 | 1437 | ||
| 1524 | return ( | 1438 | return ( |
| ... | @@ -1530,19 +1444,15 @@ function SavedBackendListRow({ | ... | @@ -1530,19 +1444,15 @@ function SavedBackendListRow({ |
| 1530 | tooltipText={statusTooltip} | 1444 | tooltipText={statusTooltip} |
| 1531 | dotClassName={stateDotClassName} | 1445 | dotClassName={stateDotClassName} |
| 1532 | pingClassName={ | 1446 | pingClassName={ |
| 1533 | connectionState === "connecting" ? "bg-warning/60 duration-2000" : null | 1447 | connectionState === "connecting" || connectionState === "reconnecting" |
| 1448 | ? "bg-warning/60 duration-2000" | ||
| 1449 | : null | ||
| 1534 | } | 1450 | } |
| 1535 | /> | 1451 | /> |
| 1536 | <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3> | 1452 | <h3 className="text-sm font-medium text-foreground">{environment.label}</h3> |
| 1537 | </div> | 1453 | </div> |
| 1538 | {metadataBits.length > 0 || runtime?.scopes ? ( | 1454 | {metadataBits.length > 0 ? ( |
| 1539 | <p className="text-xs text-muted-foreground"> | 1455 | <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> |
| 1540 | {metadataBits.length > 0 ? metadataBits.join(" · ") : null} | ||
| 1541 | {metadataBits.length > 0 && runtime?.scopes ? <span aria-hidden> · </span> : null} | ||
| 1542 | {runtime?.scopes ? ( | ||
| 1543 | <AccessScopeSummary scopes={runtime.scopes} label="Granted scopes" /> | ||
| 1544 | ) : null} | ||
| 1545 | </p> | ||
| 1546 | ) : null} | 1456 | ) : null} |
| 1547 | {versionMismatch ? ( | 1457 | {versionMismatch ? ( |
| 1548 | <p className="flex items-center gap-1 text-warning text-xs"> | 1458 | <p className="flex items-center gap-1 text-warning text-xs"> |
| ... | @@ -1551,32 +1461,36 @@ function SavedBackendListRow({ | ... | @@ -1551,32 +1461,36 @@ function SavedBackendListRow({ |
| 1551 | {versionMismatch.serverVersion}. | 1461 | {versionMismatch.serverVersion}. |
| 1552 | </p> | 1462 | </p> |
| 1553 | ) : null} | 1463 | ) : null} |
| 1464 | {environment.connection.error ? ( | ||
| 1465 | <p className="flex min-w-0 items-center gap-2 text-destructive text-xs"> | ||
| 1466 | <span className="truncate">{connectionStatusText(environment.connection)}</span> | ||
| 1467 | {errorTraceId ? ( | ||
| 1468 | <button | ||
| 1469 | type="button" | ||
| 1470 | className="shrink-0 underline underline-offset-2" | ||
| 1471 | onClick={() => void navigator.clipboard.writeText(errorTraceId)} | ||
| 1472 | > | ||
| 1473 | Copy trace ID | ||
| 1474 | </button> | ||
| 1475 | ) : null} | ||
| 1476 | </p> | ||
| 1477 | ) : null} | ||
| 1554 | </div> | 1478 | </div> |
| 1555 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 1479 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 1556 | <Button | 1480 | <Button |
| 1557 | size="xs" | 1481 | size="xs" |
| 1558 | variant="outline" | 1482 | variant="outline" |
| 1559 | disabled={isConnected ? isDisconnecting : isConnecting} | 1483 | disabled={isConnecting || removingEnvironmentId === environmentId} |
| 1560 | onClick={() => | 1484 | onClick={() => void (isConnected ? onRemove(environmentId) : onConnect(environmentId))} |
| 1561 | void (isConnected ? onDisconnect(environmentId) : onConnect(environmentId)) | ||
| 1562 | } | ||
| 1563 | > | 1485 | > |
| 1564 | {isConnected | 1486 | {isConnected |
| 1565 | ? isDisconnecting | 1487 | ? removingEnvironmentId === environmentId |
| 1566 | ? "Disconnecting…" | 1488 | ? "Disconnecting…" |
| 1567 | : "Disconnect" | 1489 | : "Disconnect" |
| 1568 | : isConnecting | 1490 | : isConnecting |
| 1569 | ? "Connecting…" | 1491 | ? "Connecting…" |
| 1570 | : "Connect"} | 1492 | : "Connect"} |
| 1571 | </Button> | 1493 | </Button> |
| 1572 | <Button | ||
| 1573 | size="xs" | ||
| 1574 | variant="destructive-outline" | ||
| 1575 | disabled={removingEnvironmentId === environmentId} | ||
| 1576 | onClick={() => void onRemove(environmentId)} | ||
| 1577 | > | ||
| 1578 | {removingEnvironmentId === environmentId ? "Removing…" : "Remove"} | ||
| 1579 | </Button> | ||
| 1580 | </div> | 1494 | </div> |
| 1581 | </div> | 1495 | </div> |
| 1582 | </div> | 1496 | </div> |
| ... | @@ -1636,7 +1550,7 @@ function CloudLinkSwitch({ | ... | @@ -1636,7 +1550,7 @@ function CloudLinkSwitch({ |
| 1636 | }) { | 1550 | }) { |
| 1637 | const control = ( | 1551 | const control = ( |
| 1638 | <Switch | 1552 | <Switch |
| 1639 | aria-label="Enable T3 Connect" | 1553 | aria-label="Enable T3 Cloud" |
| 1640 | checked={checked} | 1554 | checked={checked} |
| 1641 | disabled={disabled} | 1555 | disabled={disabled} |
| 1642 | {...(onCheckedChange ? { onCheckedChange } : {})} | 1556 | {...(onCheckedChange ? { onCheckedChange } : {})} |
| ... | @@ -1654,128 +1568,125 @@ function CloudLinkSwitch({ | ... | @@ -1654,128 +1568,125 @@ function CloudLinkSwitch({ |
| 1654 | 1568 | ||
| 1655 | function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { | 1569 | function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { |
| 1656 | const { getToken, isSignedIn } = useAuth(); | 1570 | const { getToken, isSignedIn } = useAuth(); |
| 1657 | const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt(); | 1571 | const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { |
| 1572 | reportFailure: false, | ||
| 1573 | }); | ||
| 1574 | const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, { | ||
| 1575 | reportFailure: false, | ||
| 1576 | }); | ||
| 1577 | const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { | ||
| 1578 | reportFailure: false, | ||
| 1579 | }); | ||
| 1658 | const primaryCloudLinkState = usePrimaryCloudLinkState(); | 1580 | const primaryCloudLinkState = usePrimaryCloudLinkState(); |
| 1659 | const [operationError, setOperationError] = useState<string | null>(null); | 1581 | const [operationError, setOperationError] = useState<string | null>(null); |
| 1660 | const [isUpdating, setIsUpdating] = useState(false); | 1582 | const [isUpdating, setIsUpdating] = useState(false); |
| 1661 | const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); | 1583 | |
| 1584 | const reportUpdateFailure = (cause: unknown) => { | ||
| 1585 | const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; | ||
| 1586 | const traceId = findErrorTraceId(cause); | ||
| 1587 | console.error("[t3-cloud] Could not update T3 Cloud", { message, traceId, cause }); | ||
| 1588 | setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); | ||
| 1589 | toastManager.add({ | ||
| 1590 | type: "error", | ||
| 1591 | title: "Could not update T3 Cloud", | ||
| 1592 | description: message, | ||
| 1593 | data: traceId | ||
| 1594 | ? { | ||
| 1595 | secondaryActionProps: { | ||
| 1596 | children: "Copy trace ID", | ||
| 1597 | onClick: () => void navigator.clipboard?.writeText(traceId), | ||
| 1598 | }, | ||
| 1599 | } | ||
| 1600 | : undefined, | ||
| 1601 | }); | ||
| 1602 | }; | ||
| 1662 | 1603 | ||
| 1663 | const updateLink = async (enabled: boolean) => { | 1604 | const updateLink = async (enabled: boolean) => { |
| 1664 | setIsUpdating(true); | 1605 | setIsUpdating(true); |
| 1665 | setOperationError(null); | 1606 | setOperationError(null); |
| 1666 | try { | 1607 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); |
| 1667 | const clerkToken = await getToken(resolveRelayClerkTokenOptions()); | 1608 | if (tokenResult._tag === "Failure") { |
| 1668 | if (enabled) { | 1609 | reportUpdateFailure(squashAtomCommandFailure(tokenResult)); |
| 1669 | if (!clerkToken) { | 1610 | setIsUpdating(false); |
| 1670 | throw new Error("Sign in to T3 Connect before linking this environment."); | 1611 | return; |
| 1671 | } | 1612 | } |
| 1672 | await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken })); | 1613 | |
| 1673 | } else { | 1614 | const target = primaryCloudLinkState.target; |
| 1674 | await webRuntime.runPromise( | 1615 | if (!target) { |
| 1675 | unlinkPrimaryEnvironmentFromCloud({ clerkToken: clerkToken ?? null }), | 1616 | reportUpdateFailure(new Error("Local environment is not ready yet.")); |
| 1676 | ); | 1617 | setIsUpdating(false); |
| 1618 | return; | ||
| 1619 | } | ||
| 1620 | if (enabled && !tokenResult.value) { | ||
| 1621 | reportUpdateFailure( | ||
| 1622 | new Error("Sign in from T3 Cloud settings before linking this environment."), | ||
| 1623 | ); | ||
| 1624 | setIsUpdating(false); | ||
| 1625 | return; | ||
| 1626 | } | ||
| 1627 | |||
| 1628 | const linkResult = | ||
| 1629 | enabled && tokenResult.value | ||
| 1630 | ? await linkPrimaryEnvironment({ | ||
| 1631 | target, | ||
| 1632 | clerkToken: tokenResult.value, | ||
| 1633 | }) | ||
| 1634 | : await unlinkPrimaryEnvironment({ | ||
| 1635 | target, | ||
| 1636 | clerkToken: tokenResult.value ?? null, | ||
| 1637 | }); | ||
| 1638 | if (linkResult._tag === "Failure") { | ||
| 1639 | if (!isAtomCommandInterrupted(linkResult)) { | ||
| 1640 | reportUpdateFailure(squashAtomCommandFailure(linkResult)); | ||
| 1677 | } | 1641 | } |
| 1678 | primaryCloudLinkState.refresh(); | ||
| 1679 | refreshManagedRelayEnvironments(); | ||
| 1680 | toastManager.add({ | ||
| 1681 | type: "success", | ||
| 1682 | title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", | ||
| 1683 | description: enabled | ||
| 1684 | ? "This environment is available through T3 Connect." | ||
| 1685 | : "This environment is no longer available through T3 Connect.", | ||
| 1686 | }); | ||
| 1687 | } catch (cause) { | ||
| 1688 | const message = | ||
| 1689 | cause instanceof Error ? cause.message : "Could not update T3 Connect access."; | ||
| 1690 | setOperationError(message); | ||
| 1691 | toastManager.add({ | ||
| 1692 | type: "error", | ||
| 1693 | title: "Could not update T3 Connect", | ||
| 1694 | description: message, | ||
| 1695 | }); | ||
| 1696 | } finally { | ||
| 1697 | setIsUpdating(false); | 1642 | setIsUpdating(false); |
| 1643 | return; | ||
| 1698 | } | 1644 | } |
| 1699 | }; | 1645 | |
| 1700 | const updatePublishAgentActivity = async (enabled: boolean) => { | 1646 | primaryCloudLinkState.refresh(); |
| 1701 | setIsUpdatingPreference(true); | 1647 | const refreshResult = await refreshRelayEnvironments(); |
| 1702 | try { | 1648 | if (refreshResult._tag === "Failure") { |
| 1703 | await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled })); | 1649 | if (!isAtomCommandInterrupted(refreshResult)) { |
| 1704 | primaryCloudLinkState.refresh(); | 1650 | reportUpdateFailure(squashAtomCommandFailure(refreshResult)); |
| 1705 | toastManager.add({ | 1651 | } |
| 1706 | type: "success", | 1652 | setIsUpdating(false); |
| 1707 | title: enabled ? "Agent activity enabled" : "Agent activity disabled", | 1653 | return; |
| 1708 | description: enabled | ||
| 1709 | ? "This environment can publish agent activity to your notification devices." | ||
| 1710 | : "This environment will stop publishing agent activity.", | ||
| 1711 | }); | ||
| 1712 | } catch (cause) { | ||
| 1713 | toastManager.add({ | ||
| 1714 | type: "error", | ||
| 1715 | title: "Could not update T3 Connect preferences", | ||
| 1716 | description: | ||
| 1717 | cause instanceof Error ? cause.message : "Could not update agent activity publishing.", | ||
| 1718 | }); | ||
| 1719 | } finally { | ||
| 1720 | setIsUpdatingPreference(false); | ||
| 1721 | } | 1654 | } |
| 1655 | |||
| 1656 | toastManager.add({ | ||
| 1657 | type: "success", | ||
| 1658 | title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", | ||
| 1659 | description: enabled | ||
| 1660 | ? "This environment is available through T3 Cloud." | ||
| 1661 | : "This environment is no longer available through T3 Cloud.", | ||
| 1662 | }); | ||
| 1663 | setIsUpdating(false); | ||
| 1722 | }; | 1664 | }; |
| 1723 | const disabledReason = !isSignedIn | 1665 | const disabledReason = !isSignedIn |
| 1724 | ? "Sign in to T3 Connect" | 1666 | ? "Sign in from T3 Cloud settings to manage this environment." |
| 1725 | : !canManageRelay | 1667 | : !canManageRelay |
| 1726 | ? "Your session does not have permission to manage T3 Connect access." | 1668 | ? "Your session does not have permission to manage T3 Cloud access." |
| 1727 | : null; | 1669 | : null; |
| 1728 | const linked = primaryCloudLinkState.data?.linked ?? false; | 1670 | const linked = primaryCloudLinkState.data?.linked ?? false; |
| 1729 | 1671 | ||
| 1730 | return ( | 1672 | return ( |
| 1731 | <> | 1673 | <SettingsRow |
| 1732 | <SettingsRow | 1674 | title="T3 Cloud" |
| 1733 | title="T3 Connect" | 1675 | description={ |
| 1734 | description={ | 1676 | linked |
| 1735 | linked | 1677 | ? "This environment is available to your other devices through T3 Cloud." |
| 1736 | ? "This environment is available to your other devices through T3 Connect." | 1678 | : "Make this environment available to your other devices through T3 Cloud." |
| 1737 | : "Make this environment available to your other devices through T3 Connect." | 1679 | } |
| 1738 | } | 1680 | status={operationError ?? primaryCloudLinkState.error} |
| 1739 | status={operationError ?? primaryCloudLinkState.error} | 1681 | control={ |
| 1740 | control={ | 1682 | <CloudLinkSwitch |
| 1741 | <CloudLinkSwitch | 1683 | checked={linked} |
| 1742 | checked={linked} | 1684 | disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating} |
| 1743 | disabled={ | 1685 | disabledReason={disabledReason} |
| 1744 | (isSignedIn && !canManageRelay) || primaryCloudLinkState.isPending || isUpdating | 1686 | onCheckedChange={(enabled) => void updateLink(enabled)} |
| 1745 | } | ||
| 1746 | disabledReason={disabledReason} | ||
| 1747 | onCheckedChange={(enabled) => { | ||
| 1748 | if (!isSignedIn) { | ||
| 1749 | openAuthPrompt(); | ||
| 1750 | return; | ||
| 1751 | } | ||
| 1752 | void updateLink(enabled); | ||
| 1753 | }} | ||
| 1754 | /> | ||
| 1755 | } | ||
| 1756 | /> | ||
| 1757 | {linked ? ( | ||
| 1758 | <SettingsRow | ||
| 1759 | title="Publish agent activity" | ||
| 1760 | description="Send agent activity from this environment to your notification devices." | ||
| 1761 | className="bg-muted/20 pl-7 sm:pl-8" | ||
| 1762 | control={ | ||
| 1763 | <Switch | ||
| 1764 | aria-label="Publish agent activity" | ||
| 1765 | checked={primaryCloudLinkState.data?.publishAgentActivity ?? false} | ||
| 1766 | disabled={ | ||
| 1767 | !canManageRelay || | ||
| 1768 | !isSignedIn || | ||
| 1769 | primaryCloudLinkState.isPending || | ||
| 1770 | isUpdatingPreference | ||
| 1771 | } | ||
| 1772 | onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)} | ||
| 1773 | /> | ||
| 1774 | } | ||
| 1775 | /> | 1687 | /> |
| 1776 | ) : null} | 1688 | } |
| 1777 | {authPrompt} | 1689 | /> |
| 1778 | </> | ||
| 1779 | ); | 1690 | ); |
| 1780 | } | 1691 | } |
| 1781 | 1692 | ||
| ... | @@ -1783,13 +1694,7 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) | ... | @@ -1783,13 +1694,7 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) |
| 1783 | return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null; | 1694 | return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null; |
| 1784 | } | 1695 | } |
| 1785 | 1696 | ||
| 1786 | function EmptyRemoteEnvironments({ | 1697 | function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { |
| 1787 | cloudEnabled = true, | ||
| 1788 | onConnectFromCloud, | ||
| 1789 | }: { | ||
| 1790 | readonly cloudEnabled?: boolean; | ||
| 1791 | readonly onConnectFromCloud?: () => void; | ||
| 1792 | }) { | ||
| 1793 | return ( | 1698 | return ( |
| 1794 | <Empty className="min-h-52"> | 1699 | <Empty className="min-h-52"> |
| 1795 | <EmptyMedia variant="icon"> | 1700 | <EmptyMedia variant="icon"> |
| ... | @@ -1798,24 +1703,9 @@ function EmptyRemoteEnvironments({ | ... | @@ -1798,24 +1703,9 @@ function EmptyRemoteEnvironments({ |
| 1798 | <EmptyHeader> | 1703 | <EmptyHeader> |
| 1799 | <EmptyTitle>No saved remote environments</EmptyTitle> | 1704 | <EmptyTitle>No saved remote environments</EmptyTitle> |
| 1800 | <EmptyDescription> | 1705 | <EmptyDescription> |
| 1801 | Click “Add environment” to pair another environment | 1706 | {cloudEnabled |
| 1802 | {cloudEnabled ? ( | 1707 | ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." |
| 1803 | <> | 1708 | : "Click “Add environment” to pair another environment."} |
| 1804 | , or connect one from{" "} | ||
| 1805 | {onConnectFromCloud ? ( | ||
| 1806 | <button | ||
| 1807 | type="button" | ||
| 1808 | className="border-b border-dotted border-current text-foreground/80 hover:text-foreground" | ||
| 1809 | onClick={onConnectFromCloud} | ||
| 1810 | > | ||
| 1811 | T3 Connect | ||
| 1812 | </button> | ||
| 1813 | ) : ( | ||
| 1814 | "T3 Connect" | ||
| 1815 | )} | ||
| 1816 | </> | ||
| 1817 | ) : null} | ||
| 1818 | . | ||
| 1819 | </EmptyDescription> | 1709 | </EmptyDescription> |
| 1820 | </EmptyHeader> | 1710 | </EmptyHeader> |
| 1821 | </Empty> | 1711 | </Empty> |
| ... | @@ -1843,73 +1733,129 @@ function ConfiguredCloudRemoteEnvironmentRows({ | ... | @@ -1843,73 +1733,129 @@ function ConfiguredCloudRemoteEnvironmentRows({ |
| 1843 | readonly primaryEnvironmentId: EnvironmentId | null; | 1733 | readonly primaryEnvironmentId: EnvironmentId | null; |
| 1844 | readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>; | 1734 | readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>; |
| 1845 | }) { | 1735 | }) { |
| 1846 | const { getToken, isSignedIn } = useAuth(); | 1736 | const environmentsState = useRelayEnvironmentDiscovery(); |
| 1847 | const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt(); | 1737 | const registerEnvironment = useAtomCommand(environmentCatalog.register, { |
| 1848 | const environmentsState = useManagedRelayEnvironments(); | 1738 | reportFailure: false, |
| 1739 | }); | ||
| 1740 | const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { | ||
| 1741 | reportFailure: false, | ||
| 1742 | }); | ||
| 1743 | const connectRelayEnvironment = useCallback( | ||
| 1744 | (environment: RelayClientEnvironmentRecord) => | ||
| 1745 | registerEnvironment( | ||
| 1746 | new RelayConnectionRegistration({ | ||
| 1747 | target: new RelayConnectionTarget({ | ||
| 1748 | environmentId: environment.environmentId, | ||
| 1749 | label: environment.label, | ||
| 1750 | }), | ||
| 1751 | }), | ||
| 1752 | ), | ||
| 1753 | [registerEnvironment], | ||
| 1754 | ); | ||
| 1849 | const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>( | 1755 | const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>( |
| 1850 | null, | 1756 | null, |
| 1851 | ); | 1757 | ); |
| 1852 | const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); | 1758 | const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); |
| 1853 | 1759 | ||
| 1760 | useEffect(() => { | ||
| 1761 | void refreshRelayEnvironments(); | ||
| 1762 | }, [refreshRelayEnvironments]); | ||
| 1763 | |||
| 1854 | const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { | 1764 | const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { |
| 1855 | setConnectingEnvironmentId(environment.environmentId); | 1765 | setConnectingEnvironmentId(environment.environmentId); |
| 1856 | try { | 1766 | const result = await connectRelayEnvironment(environment); |
| 1857 | const clerkToken = await getToken(resolveRelayClerkTokenOptions()); | 1767 | setConnectingEnvironmentId(null); |
| 1858 | if (!clerkToken) { | 1768 | if (result._tag === "Success") { |
| 1859 | throw new Error("Sign in to T3 Connect before connecting this environment."); | ||
| 1860 | } | ||
| 1861 | const connection = await webRuntime.runPromise( | ||
| 1862 | connectManagedCloudEnvironment({ clerkToken, environment }), | ||
| 1863 | ); | ||
| 1864 | await addManagedRelayEnvironment(connection); | ||
| 1865 | toastManager.add({ | 1769 | toastManager.add({ |
| 1866 | type: "success", | 1770 | type: "success", |
| 1867 | title: "Environment connected", | 1771 | title: "Environment connected", |
| 1868 | description: `${connection.label} is available through T3 Connect.`, | 1772 | description: `${environment.label} is available through T3 Cloud.`, |
| 1869 | }); | ||
| 1870 | } catch (cause) { | ||
| 1871 | toastManager.add({ | ||
| 1872 | type: "error", | ||
| 1873 | title: "Could not connect environment", | ||
| 1874 | description: | ||
| 1875 | cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment.", | ||
| 1876 | }); | 1773 | }); |
| 1877 | } finally { | 1774 | return; |
| 1878 | setConnectingEnvironmentId(null); | ||
| 1879 | } | 1775 | } |
| 1776 | if (isAtomCommandInterrupted(result)) { | ||
| 1777 | return; | ||
| 1778 | } | ||
| 1779 | const cause = squashAtomCommandFailure(result); | ||
| 1780 | const message = | ||
| 1781 | cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment."; | ||
| 1782 | const traceId = findErrorTraceId(cause); | ||
| 1783 | console.error("[t3-cloud] Could not connect environment", { message, traceId, cause }); | ||
| 1784 | toastManager.add({ | ||
| 1785 | type: "error", | ||
| 1786 | title: "Could not connect environment", | ||
| 1787 | description: message, | ||
| 1788 | data: traceId | ||
| 1789 | ? { | ||
| 1790 | secondaryActionProps: { | ||
| 1791 | children: "Copy trace ID", | ||
| 1792 | onClick: () => void navigator.clipboard?.writeText(traceId), | ||
| 1793 | }, | ||
| 1794 | } | ||
| 1795 | : undefined, | ||
| 1796 | }); | ||
| 1880 | }; | 1797 | }; |
| 1881 | 1798 | ||
| 1882 | const connectableEnvironments = (environmentsState.data ?? []).filter( | 1799 | const connectableEnvironments = [...environmentsState.environments.values()].filter( |
| 1883 | (environment) => | 1800 | ({ environment }) => |
| 1884 | environment.environmentId !== primaryEnvironmentId && | 1801 | environment.environmentId !== primaryEnvironmentId && |
| 1885 | !savedIds.has(environment.environmentId), | 1802 | !savedIds.has(environment.environmentId), |
| 1886 | ); | 1803 | ); |
| 1887 | 1804 | ||
| 1888 | if (savedEnvironmentIds.length === 0 && environmentsState.data === null) { | 1805 | if ( |
| 1806 | savedEnvironmentIds.length === 0 && | ||
| 1807 | environmentsState.refreshing && | ||
| 1808 | environmentsState.environments.size === 0 | ||
| 1809 | ) { | ||
| 1889 | return <RemoteEnvironmentRowsSkeleton />; | 1810 | return <RemoteEnvironmentRowsSkeleton />; |
| 1890 | } | 1811 | } |
| 1891 | 1812 | ||
| 1892 | if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) { | 1813 | if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) { |
| 1893 | return ( | 1814 | return <EmptyRemoteEnvironments />; |
| 1894 | <> | ||
| 1895 | <EmptyRemoteEnvironments {...(!isSignedIn ? { onConnectFromCloud: openAuthPrompt } : {})} /> | ||
| 1896 | {authPrompt} | ||
| 1897 | </> | ||
| 1898 | ); | ||
| 1899 | } | 1815 | } |
| 1900 | 1816 | ||
| 1901 | return connectableEnvironments.map((environment) => ( | 1817 | return connectableEnvironments.map(({ environment, availability, error }) => ( |
| 1902 | <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}> | 1818 | <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}> |
| 1903 | <div className={ITEM_ROW_INNER_CLASSNAME}> | 1819 | <div className={ITEM_ROW_INNER_CLASSNAME}> |
| 1904 | <div className="min-w-0"> | 1820 | <div className="min-w-0"> |
| 1905 | <div className="flex items-center gap-2"> | 1821 | <div className="flex items-center gap-2"> |
| 1906 | <ConnectionStatusDot | 1822 | <ConnectionStatusDot |
| 1907 | dotClassName="bg-muted-foreground/35" | 1823 | dotClassName={ |
| 1908 | tooltipText="Available through T3 Connect" | 1824 | availability === "online" |
| 1825 | ? "bg-success" | ||
| 1826 | : availability === "error" | ||
| 1827 | ? "bg-destructive" | ||
| 1828 | : availability === "checking" | ||
| 1829 | ? "bg-warning" | ||
| 1830 | : "bg-muted-foreground/35" | ||
| 1831 | } | ||
| 1832 | pingClassName={availability === "checking" ? "bg-warning/60 duration-2000" : null} | ||
| 1833 | tooltipText={ | ||
| 1834 | availability === "online" | ||
| 1835 | ? "Relay online" | ||
| 1836 | : availability === "offline" | ||
| 1837 | ? "Relay offline" | ||
| 1838 | : availability === "checking" | ||
| 1839 | ? "Checking relay status" | ||
| 1840 | : (Option.getOrNull(error)?.message ?? "Relay status unavailable") | ||
| 1841 | } | ||
| 1909 | /> | 1842 | /> |
| 1910 | <p className="truncate text-sm font-medium">{environment.label}</p> | 1843 | <p className="truncate text-sm font-medium">{environment.label}</p> |
| 1911 | </div> | 1844 | </div> |
| 1912 | <p className="mt-1 truncate text-xs text-muted-foreground">T3 Connect</p> | 1845 | <p |
| 1846 | className={cn( | ||
| 1847 | "mt-1 truncate text-xs", | ||
| 1848 | availability === "error" ? "text-destructive" : "text-muted-foreground", | ||
| 1849 | )} | ||
| 1850 | > | ||
| 1851 | {availability === "online" | ||
| 1852 | ? "Available · Relay online" | ||
| 1853 | : availability === "offline" | ||
| 1854 | ? "Available · Relay offline" | ||
| 1855 | : availability === "checking" | ||
| 1856 | ? "Available · Checking relay status…" | ||
| 1857 | : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} | ||
| 1858 | </p> | ||
| 1913 | </div> | 1859 | </div> |
| 1914 | <Button | 1860 | <Button |
| 1915 | size="sm" | 1861 | size="sm" |
| ... | @@ -1942,7 +1888,15 @@ function CloudRemoteEnvironmentRows({ | ... | @@ -1942,7 +1888,15 @@ function CloudRemoteEnvironmentRows({ |
| 1942 | 1888 | ||
| 1943 | export function ConnectionsSettings() { | 1889 | export function ConnectionsSettings() { |
| 1944 | const desktopBridge = window.desktopBridge; | 1890 | const desktopBridge = window.desktopBridge; |
| 1945 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 1891 | const { environments } = useEnvironments(); |
| 1892 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 1893 | const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); | ||
| 1894 | const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { | ||
| 1895 | reportFailure: false, | ||
| 1896 | }); | ||
| 1897 | const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); | ||
| 1898 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); | ||
| 1899 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; | ||
| 1946 | const primarySessionState = usePrimarySessionState(); | 1900 | const primarySessionState = usePrimarySessionState(); |
| 1947 | const currentSessionScopes = desktopBridge | 1901 | const currentSessionScopes = desktopBridge |
| 1948 | ? AuthAdministrativeScopes | 1902 | ? AuthAdministrativeScopes |
| ... | @@ -1950,61 +1904,61 @@ export function ConnectionsSettings() { | ... | @@ -1950,61 +1904,61 @@ export function ConnectionsSettings() { |
| 1950 | ? (primarySessionState.data.scopes ?? null) | 1904 | ? (primarySessionState.data.scopes ?? null) |
| 1951 | : null; | 1905 | : null; |
| 1952 | const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); | 1906 | const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); |
| 1953 | const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId); | 1907 | const savedEnvironments = useMemo( |
| 1954 | const savedEnvironmentIds = useMemo( | ||
| 1955 | () => | 1908 | () => |
| 1956 | Object.values(savedEnvironmentsById) | 1909 | environments |
| 1957 | .toSorted((left, right) => left.label.localeCompare(right.label)) | 1910 | .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget") |
| 1958 | .map((record) => record.environmentId), | 1911 | .toSorted((left, right) => left.label.localeCompare(right.label)), |
| 1959 | [savedEnvironmentsById], | 1912 | [environments], |
| 1913 | ); | ||
| 1914 | const savedEnvironmentIds = useMemo( | ||
| 1915 | () => savedEnvironments.map((environment) => environment.environmentId), | ||
| 1916 | [savedEnvironments], | ||
| 1960 | ); | 1917 | ); |
| 1961 | const savedDesktopSshEnvironmentsByAlias = useMemo( | 1918 | const savedDesktopSshEnvironmentsByAlias = useMemo( |
| 1962 | () => | 1919 | () => |
| 1963 | Object.values(savedEnvironmentsById).reduce<Record<string, SavedEnvironmentRecord>>( | 1920 | savedEnvironments.reduce<Record<string, EnvironmentPresentation>>( |
| 1964 | (accumulator, record) => { | 1921 | (accumulator, environment) => { |
| 1965 | if (record.desktopSsh?.alias) { | 1922 | const profile = environment.entry.profile; |
| 1966 | accumulator[record.desktopSsh.alias] = record; | 1923 | if ( |
| 1924 | environment.entry.target._tag === "SshConnectionTarget" && | ||
| 1925 | Option.isSome(profile) && | ||
| 1926 | profile.value._tag === "SshConnectionProfile" | ||
| 1927 | ) { | ||
| 1928 | accumulator[profile.value.target.alias] = environment; | ||
| 1967 | } | 1929 | } |
| 1968 | return accumulator; | 1930 | return accumulator; |
| 1969 | }, | 1931 | }, |
| 1970 | {}, | 1932 | {}, |
| 1971 | ), | 1933 | ), |
| 1972 | [savedEnvironmentsById], | 1934 | [savedEnvironments], |
| 1973 | ); | 1935 | ); |
| 1974 | const savedDesktopSshEnvironmentKeys = useMemo(() => { | 1936 | const savedDesktopSshEnvironmentKeys = useMemo(() => { |
| 1975 | const keys = new Set<string>(); | 1937 | const keys = new Set<string>(); |
| 1976 | for (const record of Object.values(savedEnvironmentsById)) { | 1938 | for (const environment of savedEnvironments) { |
| 1977 | const target = record.desktopSsh; | 1939 | const profile = environment.entry.profile; |
| 1978 | if (!target) continue; | 1940 | if ( |
| 1941 | environment.entry.target._tag !== "SshConnectionTarget" || | ||
| 1942 | Option.isNone(profile) || | ||
| 1943 | profile.value._tag !== "SshConnectionProfile" | ||
| 1944 | ) { | ||
| 1945 | continue; | ||
| 1946 | } | ||
| 1947 | const target = profile.value.target; | ||
| 1979 | keys.add(target.alias); | 1948 | keys.add(target.alias); |
| 1980 | keys.add(formatDesktopSshTarget(target)); | 1949 | keys.add(formatDesktopSshTarget(target)); |
| 1981 | } | 1950 | } |
| 1982 | return keys; | 1951 | return keys; |
| 1983 | }, [savedEnvironmentsById]); | 1952 | }, [savedEnvironments]); |
| 1984 | const [discoveredSshHosts, setDiscoveredSshHosts] = useState< | 1953 | const [sshConnectionError, setSshConnectionError] = useState<string | null>(null); |
| 1985 | ReadonlyArray<DesktopDiscoveredSshHost> | ||
| 1986 | >([]); | ||
| 1987 | const [hasLoadedDiscoveredSshHosts, setHasLoadedDiscoveredSshHosts] = useState(false); | ||
| 1988 | const [isLoadingDiscoveredSshHosts, setIsLoadingDiscoveredSshHosts] = useState(false); | ||
| 1989 | const [discoveredSshHostsError, setDiscoveredSshHostsError] = useState<string | null>(null); | ||
| 1990 | const [connectingSshHostAlias, setConnectingSshHostAlias] = useState<string | null>(null); | 1954 | const [connectingSshHostAlias, setConnectingSshHostAlias] = useState<string | null>(null); |
| 1991 | 1955 | ||
| 1992 | const [desktopServerExposureState, setDesktopServerExposureState] = | 1956 | const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< |
| 1993 | useState<DesktopServerExposureState | null>(null); | 1957 | string | null |
| 1994 | const [desktopAdvertisedEndpoints, setDesktopAdvertisedEndpoints] = useState< | 1958 | >(null); |
| 1995 | ReadonlyArray<AdvertisedEndpoint> | 1959 | const [desktopAccessManagementMutationError, setDesktopAccessManagementMutationError] = useState< |
| 1996 | >([]); | 1960 | string | null |
| 1997 | const [desktopServerExposureError, setDesktopServerExposureError] = useState<string | null>(null); | 1961 | >(null); |
| 1998 | const [desktopPairingLinks, setDesktopPairingLinks] = useState< | ||
| 1999 | ReadonlyArray<ServerPairingLinkRecord> | ||
| 2000 | >([]); | ||
| 2001 | const [desktopClientSessions, setDesktopClientSessions] = useState< | ||
| 2002 | ReadonlyArray<ServerClientSessionRecord> | ||
| 2003 | >([]); | ||
| 2004 | const [desktopAccessManagementError, setDesktopAccessManagementError] = useState<string | null>( | ||
| 2005 | null, | ||
| 2006 | ); | ||
| 2007 | const [isLoadingDesktopAccessManagement, setIsLoadingDesktopAccessManagement] = useState(false); | ||
| 2008 | const [revokingDesktopPairingLinkId, setRevokingDesktopPairingLinkId] = useState<string | null>( | 1962 | const [revokingDesktopPairingLinkId, setRevokingDesktopPairingLinkId] = useState<string | null>( |
| 2009 | null, | 1963 | null, |
| 2010 | ); | 1964 | ); |
| ... | @@ -2021,21 +1975,6 @@ export function ConnectionsSettings() { | ... | @@ -2021,21 +1975,6 @@ export function ConnectionsSettings() { |
| 2021 | const [savedBackendSshPort, setSavedBackendSshPort] = useState(""); | 1975 | const [savedBackendSshPort, setSavedBackendSshPort] = useState(""); |
| 2022 | const [savedBackendError, setSavedBackendError] = useState<string | null>(null); | 1976 | const [savedBackendError, setSavedBackendError] = useState<string | null>(null); |
| 2023 | const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); | 1977 | const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); |
| 2024 | const unsavedDiscoveredSshHosts = useMemo( | ||
| 2025 | () => | ||
| 2026 | discoveredSshHosts.filter((target) => { | ||
| 2027 | const address = formatDesktopSshTarget(target); | ||
| 2028 | return ( | ||
| 2029 | !savedDesktopSshEnvironmentKeys.has(target.alias) && | ||
| 2030 | !savedDesktopSshEnvironmentKeys.has(address) | ||
| 2031 | ); | ||
| 2032 | }), | ||
| 2033 | [discoveredSshHosts, savedDesktopSshEnvironmentKeys], | ||
| 2034 | ); | ||
| 2035 | const [reconnectingSavedEnvironmentId, setReconnectingSavedEnvironmentId] = | ||
| 2036 | useState<EnvironmentId | null>(null); | ||
| 2037 | const [disconnectingSavedEnvironmentId, setDisconnectingSavedEnvironmentId] = | ||
| 2038 | useState<EnvironmentId | null>(null); | ||
| 2039 | const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = | 1978 | const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = |
| 2040 | useState<EnvironmentId | null>(null); | 1979 | useState<EnvironmentId | null>(null); |
| 2041 | const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); | 1980 | const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); |
| ... | @@ -2050,7 +1989,7 @@ export function ConnectionsSettings() { | ... | @@ -2050,7 +1989,7 @@ export function ConnectionsSettings() { |
| 2050 | const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< | 1989 | const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< |
| 2051 | DesktopServerExposureState["mode"] | null | 1990 | DesktopServerExposureState["mode"] | null |
| 2052 | >(null); | 1991 | >(null); |
| 2053 | const primaryServerConfig = useServerConfig(); | 1992 | const primaryServerConfig = primaryEnvironment?.serverConfig ?? null; |
| 2054 | const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig); | 1993 | const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig); |
| 2055 | const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false); | 1994 | const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false); |
| 2056 | const defaultAdvertisedEndpointKey = useUiStateStore( | 1995 | const defaultAdvertisedEndpointKey = useUiStateStore( |
| ... | @@ -2061,6 +2000,65 @@ export function ConnectionsSettings() { | ... | @@ -2061,6 +2000,65 @@ export function ConnectionsSettings() { |
| 2061 | ); | 2000 | ); |
| 2062 | const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; | 2001 | const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; |
| 2063 | const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; | 2002 | const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; |
| 2003 | const authAccessChanges = useEnvironmentQuery( | ||
| 2004 | canManageLocalBackend && primaryEnvironmentId !== null | ||
| 2005 | ? authEnvironment.accessChanges({ | ||
| 2006 | environmentId: primaryEnvironmentId, | ||
| 2007 | input: null, | ||
| 2008 | }) | ||
| 2009 | : null, | ||
| 2010 | ); | ||
| 2011 | const desktopNetworkAccess = useEnvironmentQuery( | ||
| 2012 | canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null, | ||
| 2013 | ); | ||
| 2014 | const desktopSshHosts = useEnvironmentQuery( | ||
| 2015 | desktopBridge && addBackendDialogOpen && savedBackendMode === "ssh" | ||
| 2016 | ? desktopSshHostsStateAtom | ||
| 2017 | : null, | ||
| 2018 | ); | ||
| 2019 | const discoveredSshHosts = desktopSshHosts.data ?? EMPTY_DISCOVERED_SSH_HOSTS; | ||
| 2020 | const unsavedDiscoveredSshHosts = useMemo( | ||
| 2021 | () => | ||
| 2022 | discoveredSshHosts.filter((target) => { | ||
| 2023 | const address = formatDesktopSshTarget(target); | ||
| 2024 | return ( | ||
| 2025 | !savedDesktopSshEnvironmentKeys.has(target.alias) && | ||
| 2026 | !savedDesktopSshEnvironmentKeys.has(address) | ||
| 2027 | ); | ||
| 2028 | }), | ||
| 2029 | [discoveredSshHosts, savedDesktopSshEnvironmentKeys], | ||
| 2030 | ); | ||
| 2031 | const hasLoadedDiscoveredSshHosts = | ||
| 2032 | desktopSshHosts.data !== null || desktopSshHosts.error !== null; | ||
| 2033 | const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending; | ||
| 2034 | const discoveredSshHostsError = sshConnectionError ?? desktopSshHosts.error; | ||
| 2035 | const desktopServerExposureState = desktopNetworkAccess.data?.serverExposureState ?? null; | ||
| 2036 | const desktopAdvertisedEndpoints = | ||
| 2037 | desktopNetworkAccess.data?.advertisedEndpoints ?? EMPTY_ADVERTISED_ENDPOINTS; | ||
| 2038 | const desktopServerExposureError = | ||
| 2039 | desktopServerExposureMutationError ?? desktopNetworkAccess.error; | ||
| 2040 | const desktopAccessManagementError = | ||
| 2041 | desktopAccessManagementMutationError ?? authAccessChanges.error; | ||
| 2042 | const isLoadingDesktopAccessManagement = | ||
| 2043 | authAccessChanges.isPending && authAccessChanges.data === null; | ||
| 2044 | const desktopPairingLinks = useMemo(() => { | ||
| 2045 | const event = authAccessChanges.data; | ||
| 2046 | if (event?.type !== "snapshot") return []; | ||
| 2047 | return sortDesktopPairingLinks( | ||
| 2048 | event.payload.pairingLinks.map((pairingLink: AuthPairingLink) => | ||
| 2049 | toDesktopPairingLinkRecord(pairingLink), | ||
| 2050 | ), | ||
| 2051 | ); | ||
| 2052 | }, [authAccessChanges.data]); | ||
| 2053 | const desktopClientSessions = useMemo(() => { | ||
| 2054 | const event = authAccessChanges.data; | ||
| 2055 | if (event?.type !== "snapshot") return []; | ||
| 2056 | return sortDesktopClientSessions( | ||
| 2057 | event.payload.clientSessions.map((clientSession: AuthClientSession) => | ||
| 2058 | toDesktopClientSessionRecord(clientSession), | ||
| 2059 | ), | ||
| 2060 | ); | ||
| 2061 | }, [authAccessChanges.data]); | ||
| 2064 | const isLocalBackendNetworkAccessible = desktopBridge | 2062 | const isLocalBackendNetworkAccessible = desktopBridge |
| 2065 | ? desktopServerExposureState?.mode === "network-accessible" | 2063 | ? desktopServerExposureState?.mode === "network-accessible" |
| 2066 | : currentAuthPolicy === "remote-reachable"; | 2064 | : currentAuthPolicy === "remote-reachable"; |
| ... | @@ -2091,19 +2089,17 @@ export function ConnectionsSettings() { | ... | @@ -2091,19 +2089,17 @@ export function ConnectionsSettings() { |
| 2091 | async (checked: boolean) => { | 2089 | async (checked: boolean) => { |
| 2092 | if (!desktopBridge) return; | 2090 | if (!desktopBridge) return; |
| 2093 | setIsUpdatingDesktopServerExposure(true); | 2091 | setIsUpdatingDesktopServerExposure(true); |
| 2094 | setDesktopServerExposureError(null); | 2092 | setDesktopServerExposureMutationError(null); |
| 2095 | try { | 2093 | try { |
| 2096 | const nextState = await desktopBridge.setServerExposureMode( | 2094 | await desktopBridge.setServerExposureMode(checked ? "network-accessible" : "local-only"); |
| 2097 | checked ? "network-accessible" : "local-only", | 2095 | refreshDesktopNetworkAccessState(); |
| 2098 | ); | ||
| 2099 | setDesktopServerExposureState(nextState); | ||
| 2100 | setIsDesktopServerExposureDialogOpen(false); | 2096 | setIsDesktopServerExposureDialogOpen(false); |
| 2101 | setIsUpdatingDesktopServerExposure(false); | 2097 | setIsUpdatingDesktopServerExposure(false); |
| 2102 | } catch (error) { | 2098 | } catch (error) { |
| 2103 | const message = | 2099 | const message = |
| 2104 | error instanceof Error ? error.message : "Failed to update network exposure."; | 2100 | error instanceof Error ? error.message : "Failed to update network exposure."; |
| 2105 | setIsDesktopServerExposureDialogOpen(false); | 2101 | setIsDesktopServerExposureDialogOpen(false); |
| 2106 | setDesktopServerExposureError(message); | 2102 | setDesktopServerExposureMutationError(message); |
| 2107 | toastManager.add( | 2103 | toastManager.add( |
| 2108 | stackedThreadToast({ | 2104 | stackedThreadToast({ |
| 2109 | type: "error", | 2105 | type: "error", |
| ... | @@ -2127,18 +2123,18 @@ export function ConnectionsSettings() { | ... | @@ -2127,18 +2123,18 @@ export function ConnectionsSettings() { |
| 2127 | if (!desktopBridge) return; | 2123 | if (!desktopBridge) return; |
| 2128 | if (!isTailscaleServePortValid) return; | 2124 | if (!isTailscaleServePortValid) return; |
| 2129 | setIsUpdatingTailscaleServe(true); | 2125 | setIsUpdatingTailscaleServe(true); |
| 2130 | setDesktopServerExposureError(null); | 2126 | setDesktopServerExposureMutationError(null); |
| 2131 | try { | 2127 | try { |
| 2132 | const nextState = await desktopBridge.setTailscaleServeEnabled({ | 2128 | await desktopBridge.setTailscaleServeEnabled({ |
| 2133 | enabled: true, | 2129 | enabled: true, |
| 2134 | port: parsedTailscaleServePort, | 2130 | port: parsedTailscaleServePort, |
| 2135 | }); | 2131 | }); |
| 2136 | setDesktopServerExposureState(nextState); | 2132 | refreshDesktopNetworkAccessState(); |
| 2137 | setPendingTailscaleServeEndpoint(null); | 2133 | setPendingTailscaleServeEndpoint(null); |
| 2138 | } catch (error) { | 2134 | } catch (error) { |
| 2139 | const message = | 2135 | const message = |
| 2140 | error instanceof Error ? error.message : "Failed to configure Tailscale HTTPS."; | 2136 | error instanceof Error ? error.message : "Failed to configure Tailscale HTTPS."; |
| 2141 | setDesktopServerExposureError(message); | 2137 | setDesktopServerExposureMutationError(message); |
| 2142 | toastManager.add( | 2138 | toastManager.add( |
| 2143 | stackedThreadToast({ | 2139 | stackedThreadToast({ |
| 2144 | type: "error", | 2140 | type: "error", |
| ... | @@ -2164,17 +2160,17 @@ export function ConnectionsSettings() { | ... | @@ -2164,17 +2160,17 @@ export function ConnectionsSettings() { |
| 2164 | const handleConfirmTailscaleServeDisable = useCallback(async () => { | 2160 | const handleConfirmTailscaleServeDisable = useCallback(async () => { |
| 2165 | if (!desktopBridge) return; | 2161 | if (!desktopBridge) return; |
| 2166 | setIsUpdatingTailscaleServe(true); | 2162 | setIsUpdatingTailscaleServe(true); |
| 2167 | setDesktopServerExposureError(null); | 2163 | setDesktopServerExposureMutationError(null); |
| 2168 | try { | 2164 | try { |
| 2169 | const nextState = await desktopBridge.setTailscaleServeEnabled({ | 2165 | await desktopBridge.setTailscaleServeEnabled({ |
| 2170 | enabled: false, | 2166 | enabled: false, |
| 2171 | port: desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT, | 2167 | port: desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT, |
| 2172 | }); | 2168 | }); |
| 2173 | setDesktopServerExposureState(nextState); | 2169 | refreshDesktopNetworkAccessState(); |
| 2174 | setDisableTailscaleServeDialogOpen(false); | 2170 | setDisableTailscaleServeDialogOpen(false); |
| 2175 | } catch (error) { | 2171 | } catch (error) { |
| 2176 | const message = error instanceof Error ? error.message : "Failed to disable Tailscale HTTPS."; | 2172 | const message = error instanceof Error ? error.message : "Failed to disable Tailscale HTTPS."; |
| 2177 | setDesktopServerExposureError(message); | 2173 | setDesktopServerExposureMutationError(message); |
| 2178 | toastManager.add( | 2174 | toastManager.add( |
| 2179 | stackedThreadToast({ | 2175 | stackedThreadToast({ |
| 2180 | type: "error", | 2176 | type: "error", |
| ... | @@ -2193,12 +2189,12 @@ export function ConnectionsSettings() { | ... | @@ -2193,12 +2189,12 @@ export function ConnectionsSettings() { |
| 2193 | 2189 | ||
| 2194 | const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { | 2190 | const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { |
| 2195 | setRevokingDesktopPairingLinkId(id); | 2191 | setRevokingDesktopPairingLinkId(id); |
| 2196 | setDesktopAccessManagementError(null); | 2192 | setDesktopAccessManagementMutationError(null); |
| 2197 | try { | 2193 | try { |
| 2198 | await revokeServerPairingLink(id); | 2194 | await revokeServerPairingLink(id); |
| 2199 | } catch (error) { | 2195 | } catch (error) { |
| 2200 | const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; | 2196 | const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; |
| 2201 | setDesktopAccessManagementError(message); | 2197 | setDesktopAccessManagementMutationError(message); |
| 2202 | toastManager.add( | 2198 | toastManager.add( |
| 2203 | stackedThreadToast({ | 2199 | stackedThreadToast({ |
| 2204 | type: "error", | 2200 | type: "error", |
| ... | @@ -2214,12 +2210,12 @@ export function ConnectionsSettings() { | ... | @@ -2214,12 +2210,12 @@ export function ConnectionsSettings() { |
| 2214 | const handleRevokeDesktopClientSession = useCallback( | 2210 | const handleRevokeDesktopClientSession = useCallback( |
| 2215 | async (sessionId: ServerClientSessionRecord["sessionId"]) => { | 2211 | async (sessionId: ServerClientSessionRecord["sessionId"]) => { |
| 2216 | setRevokingDesktopClientSessionId(sessionId); | 2212 | setRevokingDesktopClientSessionId(sessionId); |
| 2217 | setDesktopAccessManagementError(null); | 2213 | setDesktopAccessManagementMutationError(null); |
| 2218 | try { | 2214 | try { |
| 2219 | await revokeServerClientSession(sessionId); | 2215 | await revokeServerClientSession(sessionId); |
| 2220 | } catch (error) { | 2216 | } catch (error) { |
| 2221 | const message = error instanceof Error ? error.message : "Failed to revoke client access."; | 2217 | const message = error instanceof Error ? error.message : "Failed to revoke client access."; |
| 2222 | setDesktopAccessManagementError(message); | 2218 | setDesktopAccessManagementMutationError(message); |
| 2223 | toastManager.add( | 2219 | toastManager.add( |
| 2224 | stackedThreadToast({ | 2220 | stackedThreadToast({ |
| 2225 | type: "error", | 2221 | type: "error", |
| ... | @@ -2236,7 +2232,7 @@ export function ConnectionsSettings() { | ... | @@ -2236,7 +2232,7 @@ export function ConnectionsSettings() { |
| 2236 | 2232 | ||
| 2237 | const handleRevokeOtherDesktopClients = useCallback(async () => { | 2233 | const handleRevokeOtherDesktopClients = useCallback(async () => { |
| 2238 | setIsRevokingOtherDesktopClients(true); | 2234 | setIsRevokingOtherDesktopClients(true); |
| 2239 | setDesktopAccessManagementError(null); | 2235 | setDesktopAccessManagementMutationError(null); |
| 2240 | try { | 2236 | try { |
| 2241 | const revokedCount = await revokeOtherServerClientSessions(); | 2237 | const revokedCount = await revokeOtherServerClientSessions(); |
| 2242 | toastManager.add({ | 2238 | toastManager.add({ |
| ... | @@ -2246,7 +2242,7 @@ export function ConnectionsSettings() { | ... | @@ -2246,7 +2242,7 @@ export function ConnectionsSettings() { |
| 2246 | }); | 2242 | }); |
| 2247 | } catch (error) { | 2243 | } catch (error) { |
| 2248 | const message = error instanceof Error ? error.message : "Failed to revoke other clients."; | 2244 | const message = error instanceof Error ? error.message : "Failed to revoke other clients."; |
| 2249 | setDesktopAccessManagementError(message); | 2245 | setDesktopAccessManagementMutationError(message); |
| 2250 | toastManager.add( | 2246 | toastManager.add( |
| 2251 | stackedThreadToast({ | 2247 | stackedThreadToast({ |
| 2252 | type: "error", | 2248 | type: "error", |
| ... | @@ -2263,45 +2259,28 @@ export function ConnectionsSettings() { | ... | @@ -2263,45 +2259,28 @@ export function ConnectionsSettings() { |
| 2263 | if (savedBackendMode === "ssh") { | 2259 | if (savedBackendMode === "ssh") { |
| 2264 | setIsAddingSavedBackend(true); | 2260 | setIsAddingSavedBackend(true); |
| 2265 | setSavedBackendError(null); | 2261 | setSavedBackendError(null); |
| 2262 | let target: DesktopSshEnvironmentTarget; | ||
| 2266 | try { | 2263 | try { |
| 2267 | const target = parseManualDesktopSshTarget({ | 2264 | target = parseManualDesktopSshTarget({ |
| 2268 | host: savedBackendSshHost, | 2265 | host: savedBackendSshHost, |
| 2269 | username: savedBackendSshUsername, | 2266 | username: savedBackendSshUsername, |
| 2270 | port: savedBackendSshPort, | 2267 | port: savedBackendSshPort, |
| 2271 | }); | 2268 | }); |
| 2272 | const record = await connectDesktopSshEnvironment(target, { label: "" }); | ||
| 2273 | setSavedBackendHost(""); | ||
| 2274 | setSavedBackendPairingCode(""); | ||
| 2275 | setSavedBackendSshHost(""); | ||
| 2276 | setSavedBackendSshUsername(""); | ||
| 2277 | setSavedBackendSshPort(""); | ||
| 2278 | |||
| 2279 | setAddBackendDialogOpen(false); | ||
| 2280 | toastManager.add({ | ||
| 2281 | type: "success", | ||
| 2282 | title: "Environment connected", | ||
| 2283 | description: `${record.label} is ready over an SSH-managed tunnel.`, | ||
| 2284 | }); | ||
| 2285 | } catch (error) { | 2269 | } catch (error) { |
| 2286 | const message = formatDesktopSshConnectionError(error); | 2270 | setSavedBackendError(formatDesktopSshConnectionError(error)); |
| 2287 | setSavedBackendError(message); | ||
| 2288 | } finally { | ||
| 2289 | setIsAddingSavedBackend(false); | 2271 | setIsAddingSavedBackend(false); |
| 2272 | return; | ||
| 2273 | } | ||
| 2274 | |||
| 2275 | const result = await connectSshEnvironment({ target, label: "" }); | ||
| 2276 | if (result._tag === "Failure") { | ||
| 2277 | if (!isAtomCommandInterrupted(result)) { | ||
| 2278 | setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result))); | ||
| 2279 | } | ||
| 2280 | setIsAddingSavedBackend(false); | ||
| 2281 | return; | ||
| 2290 | } | 2282 | } |
| 2291 | return; | ||
| 2292 | } | ||
| 2293 | 2283 | ||
| 2294 | setIsAddingSavedBackend(true); | ||
| 2295 | setSavedBackendError(null); | ||
| 2296 | try { | ||
| 2297 | const remotePairingInput = parseRemotePairingFields({ | ||
| 2298 | host: savedBackendHost, | ||
| 2299 | pairingCode: savedBackendPairingCode, | ||
| 2300 | }); | ||
| 2301 | const record = await addSavedEnvironment({ | ||
| 2302 | label: "", | ||
| 2303 | ...remotePairingInput, | ||
| 2304 | }); | ||
| 2305 | setSavedBackendHost(""); | 2284 | setSavedBackendHost(""); |
| 2306 | setSavedBackendPairingCode(""); | 2285 | setSavedBackendPairingCode(""); |
| 2307 | setSavedBackendSshHost(""); | 2286 | setSavedBackendSshHost(""); |
| ... | @@ -2310,8 +2289,20 @@ export function ConnectionsSettings() { | ... | @@ -2310,8 +2289,20 @@ export function ConnectionsSettings() { |
| 2310 | setAddBackendDialogOpen(false); | 2289 | setAddBackendDialogOpen(false); |
| 2311 | toastManager.add({ | 2290 | toastManager.add({ |
| 2312 | type: "success", | 2291 | type: "success", |
| 2313 | title: "Backend added", | 2292 | title: "Environment connected", |
| 2314 | description: `${record.label} is now saved and will reconnect on app startup.`, | 2293 | description: `${target.alias} is ready over an SSH-managed tunnel.`, |
| 2294 | }); | ||
| 2295 | setIsAddingSavedBackend(false); | ||
| 2296 | return; | ||
| 2297 | } | ||
| 2298 | |||
| 2299 | setIsAddingSavedBackend(true); | ||
| 2300 | setSavedBackendError(null); | ||
| 2301 | let remotePairingInput: ReturnType<typeof parseRemotePairingFields>; | ||
| 2302 | try { | ||
| 2303 | remotePairingInput = parseRemotePairingFields({ | ||
| 2304 | host: savedBackendHost, | ||
| 2305 | pairingCode: savedBackendPairingCode, | ||
| 2315 | }); | 2306 | }); |
| 2316 | } catch (error) { | 2307 | } catch (error) { |
| 2317 | const message = error instanceof Error ? error.message : "Failed to add backend."; | 2308 | const message = error instanceof Error ? error.message : "Failed to add backend."; |
| ... | @@ -2323,10 +2314,43 @@ export function ConnectionsSettings() { | ... | @@ -2323,10 +2314,43 @@ export function ConnectionsSettings() { |
| 2323 | description: message, | 2314 | description: message, |
| 2324 | }), | 2315 | }), |
| 2325 | ); | 2316 | ); |
| 2326 | } finally { | ||
| 2327 | setIsAddingSavedBackend(false); | 2317 | setIsAddingSavedBackend(false); |
| 2318 | return; | ||
| 2328 | } | 2319 | } |
| 2320 | |||
| 2321 | const result = await connectPairing(remotePairingInput); | ||
| 2322 | if (result._tag === "Failure") { | ||
| 2323 | if (!isAtomCommandInterrupted(result)) { | ||
| 2324 | const error = squashAtomCommandFailure(result); | ||
| 2325 | const message = error instanceof Error ? error.message : "Failed to add backend."; | ||
| 2326 | setSavedBackendError(message); | ||
| 2327 | toastManager.add( | ||
| 2328 | stackedThreadToast({ | ||
| 2329 | type: "error", | ||
| 2330 | title: "Could not add backend", | ||
| 2331 | description: message, | ||
| 2332 | }), | ||
| 2333 | ); | ||
| 2334 | } | ||
| 2335 | setIsAddingSavedBackend(false); | ||
| 2336 | return; | ||
| 2337 | } | ||
| 2338 | |||
| 2339 | setSavedBackendHost(""); | ||
| 2340 | setSavedBackendPairingCode(""); | ||
| 2341 | setSavedBackendSshHost(""); | ||
| 2342 | setSavedBackendSshUsername(""); | ||
| 2343 | setSavedBackendSshPort(""); | ||
| 2344 | setAddBackendDialogOpen(false); | ||
| 2345 | toastManager.add({ | ||
| 2346 | type: "success", | ||
| 2347 | title: "Backend added", | ||
| 2348 | description: "The environment is saved and will reconnect on app startup.", | ||
| 2349 | }); | ||
| 2350 | setIsAddingSavedBackend(false); | ||
| 2329 | }, [ | 2351 | }, [ |
| 2352 | connectPairing, | ||
| 2353 | connectSshEnvironment, | ||
| 2330 | savedBackendHost, | 2354 | savedBackendHost, |
| 2331 | savedBackendMode, | 2355 | savedBackendMode, |
| 2332 | savedBackendPairingCode, | 2356 | savedBackendPairingCode, |
| ... | @@ -2335,88 +2359,47 @@ export function ConnectionsSettings() { | ... | @@ -2335,88 +2359,47 @@ export function ConnectionsSettings() { |
| 2335 | savedBackendSshUsername, | 2359 | savedBackendSshUsername, |
| 2336 | ]); | 2360 | ]); |
| 2337 | 2361 | ||
| 2338 | const handleConnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => { | 2362 | const handleConnectSavedBackend = useCallback( |
| 2339 | setReconnectingSavedEnvironmentId(environmentId); | 2363 | async (environmentId: EnvironmentId) => { |
| 2340 | setSavedBackendError(null); | 2364 | setSavedBackendError(null); |
| 2341 | try { | 2365 | const result = await retryEnvironment(environmentId); |
| 2342 | await reconnectSavedEnvironment(environmentId); | 2366 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 2343 | } catch (error) { | 2367 | const error = squashAtomCommandFailure(result); |
| 2344 | const message = error instanceof Error ? error.message : "Failed to connect backend."; | 2368 | const message = error instanceof Error ? error.message : "Failed to connect backend."; |
| 2345 | setSavedBackendError(message); | 2369 | setSavedBackendError(message); |
| 2346 | toastManager.add( | 2370 | toastManager.add( |
| 2347 | stackedThreadToast({ | 2371 | stackedThreadToast({ |
| 2348 | type: "error", | 2372 | type: "error", |
| 2349 | title: "Could not connect backend", | 2373 | title: "Could not connect backend", |
| 2350 | description: message, | 2374 | description: message, |
| 2351 | }), | 2375 | }), |
| 2352 | ); | 2376 | ); |
| 2353 | } finally { | 2377 | } |
| 2354 | setReconnectingSavedEnvironmentId(null); | 2378 | }, |
| 2355 | } | 2379 | [retryEnvironment], |
| 2356 | }, []); | 2380 | ); |
| 2357 | |||
| 2358 | const handleDisconnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => { | ||
| 2359 | setDisconnectingSavedEnvironmentId(environmentId); | ||
| 2360 | setSavedBackendError(null); | ||
| 2361 | try { | ||
| 2362 | await disconnectSavedEnvironment(environmentId); | ||
| 2363 | } catch (error) { | ||
| 2364 | const message = error instanceof Error ? error.message : "Failed to disconnect backend."; | ||
| 2365 | setSavedBackendError(message); | ||
| 2366 | toastManager.add( | ||
| 2367 | stackedThreadToast({ | ||
| 2368 | type: "error", | ||
| 2369 | title: "Could not disconnect backend", | ||
| 2370 | description: message, | ||
| 2371 | }), | ||
| 2372 | ); | ||
| 2373 | } finally { | ||
| 2374 | setDisconnectingSavedEnvironmentId(null); | ||
| 2375 | } | ||
| 2376 | }, []); | ||
| 2377 | 2381 | ||
| 2378 | const handleRemoveSavedBackend = useCallback(async (environmentId: EnvironmentId) => { | 2382 | const handleRemoveSavedBackend = useCallback( |
| 2379 | setRemovingSavedEnvironmentId(environmentId); | 2383 | async (environmentId: EnvironmentId) => { |
| 2380 | setSavedBackendError(null); | 2384 | setRemovingSavedEnvironmentId(environmentId); |
| 2381 | try { | 2385 | setSavedBackendError(null); |
| 2382 | await removeSavedEnvironment(environmentId); | 2386 | const result = await removeEnvironment(environmentId); |
| 2383 | } catch (error) { | ||
| 2384 | const message = error instanceof Error ? error.message : "Failed to remove backend."; | ||
| 2385 | setSavedBackendError(message); | ||
| 2386 | toastManager.add( | ||
| 2387 | stackedThreadToast({ | ||
| 2388 | type: "error", | ||
| 2389 | title: "Could not remove backend", | ||
| 2390 | description: message, | ||
| 2391 | }), | ||
| 2392 | ); | ||
| 2393 | } finally { | ||
| 2394 | setRemovingSavedEnvironmentId(null); | 2387 | setRemovingSavedEnvironmentId(null); |
| 2395 | } | 2388 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 2396 | }, []); | 2389 | const error = squashAtomCommandFailure(result); |
| 2397 | 2390 | const message = error instanceof Error ? error.message : "Failed to remove backend."; | |
| 2398 | const loadDiscoveredSshHosts = useCallback(async () => { | 2391 | setSavedBackendError(message); |
| 2399 | if (!desktopBridge) { | 2392 | toastManager.add( |
| 2400 | setDiscoveredSshHosts([]); | 2393 | stackedThreadToast({ |
| 2401 | setHasLoadedDiscoveredSshHosts(false); | 2394 | type: "error", |
| 2402 | setDiscoveredSshHostsError(null); | 2395 | title: "Could not remove backend", |
| 2403 | return; | 2396 | description: message, |
| 2404 | } | 2397 | }), |
| 2405 | 2398 | ); | |
| 2406 | setIsLoadingDiscoveredSshHosts(true); | 2399 | } |
| 2407 | setDiscoveredSshHostsError(null); | 2400 | }, |
| 2408 | try { | 2401 | [removeEnvironment], |
| 2409 | const hosts = await desktopBridge.discoverSshHosts(); | 2402 | ); |
| 2410 | setDiscoveredSshHosts(hosts); | ||
| 2411 | setHasLoadedDiscoveredSshHosts(true); | ||
| 2412 | } catch (error) { | ||
| 2413 | const message = error instanceof Error ? error.message : "Failed to discover SSH hosts."; | ||
| 2414 | setDiscoveredSshHostsError(message); | ||
| 2415 | setHasLoadedDiscoveredSshHosts(true); | ||
| 2416 | } finally { | ||
| 2417 | setIsLoadingDiscoveredSshHosts(false); | ||
| 2418 | } | ||
| 2419 | }, [desktopBridge]); | ||
| 2420 | 2403 | ||
| 2421 | const handleConnectSshHost = useCallback( | 2404 | const handleConnectSshHost = useCallback( |
| 2422 | async (target: DesktopSshEnvironmentTarget, label?: string) => { | 2405 | async (target: DesktopSshEnvironmentTarget, label?: string) => { |
| ... | @@ -2424,13 +2407,14 @@ export function ConnectionsSettings() { | ... | @@ -2424,13 +2407,14 @@ export function ConnectionsSettings() { |
| 2424 | if (savedBackendMode === "ssh") { | 2407 | if (savedBackendMode === "ssh") { |
| 2425 | setSavedBackendError(null); | 2408 | setSavedBackendError(null); |
| 2426 | } else { | 2409 | } else { |
| 2427 | setDiscoveredSshHostsError(null); | 2410 | setSshConnectionError(null); |
| 2428 | } | 2411 | } |
| 2429 | try { | 2412 | const result = await connectSshEnvironment({ |
| 2430 | const record = await connectDesktopSshEnvironment( | 2413 | target, |
| 2431 | target, | 2414 | ...(label === undefined ? {} : { label }), |
| 2432 | label === undefined ? undefined : { label }, | 2415 | }); |
| 2433 | ); | 2416 | setConnectingSshHostAlias(null); |
| 2417 | if (result._tag === "Success") { | ||
| 2434 | setSavedBackendSshHost(""); | 2418 | setSavedBackendSshHost(""); |
| 2435 | setSavedBackendSshUsername(""); | 2419 | setSavedBackendSshUsername(""); |
| 2436 | setSavedBackendSshPort(""); | 2420 | setSavedBackendSshPort(""); |
| ... | @@ -2440,151 +2424,23 @@ export function ConnectionsSettings() { | ... | @@ -2440,151 +2424,23 @@ export function ConnectionsSettings() { |
| 2440 | title: savedDesktopSshEnvironmentsByAlias[target.alias] | 2424 | title: savedDesktopSshEnvironmentsByAlias[target.alias] |
| 2441 | ? "Environment reconnected" | 2425 | ? "Environment reconnected" |
| 2442 | : "Environment connected", | 2426 | : "Environment connected", |
| 2443 | description: `${record.label} is ready over an SSH-managed tunnel.`, | 2427 | description: `${label?.trim() || target.alias} is ready over an SSH-managed tunnel.`, |
| 2444 | }); | 2428 | }); |
| 2445 | } catch (error) { | 2429 | return; |
| 2430 | } | ||
| 2431 | if (!isAtomCommandInterrupted(result)) { | ||
| 2432 | const error = squashAtomCommandFailure(result); | ||
| 2446 | const message = formatDesktopSshConnectionError(error); | 2433 | const message = formatDesktopSshConnectionError(error); |
| 2447 | if (savedBackendMode === "ssh") { | 2434 | if (savedBackendMode === "ssh") { |
| 2448 | setSavedBackendError(message); | 2435 | setSavedBackendError(message); |
| 2449 | } else { | 2436 | } else { |
| 2450 | setDiscoveredSshHostsError(message); | 2437 | setSshConnectionError(message); |
| 2451 | } | 2438 | } |
| 2452 | } finally { | ||
| 2453 | setConnectingSshHostAlias(null); | ||
| 2454 | } | 2439 | } |
| 2455 | }, | 2440 | }, |
| 2456 | [savedBackendMode, savedDesktopSshEnvironmentsByAlias], | 2441 | [connectSshEnvironment, savedBackendMode, savedDesktopSshEnvironmentsByAlias], |
| 2457 | ); | 2442 | ); |
| 2458 | 2443 | ||
| 2459 | useEffect(() => { | ||
| 2460 | if (!desktopBridge || !addBackendDialogOpen || savedBackendMode !== "ssh") { | ||
| 2461 | return; | ||
| 2462 | } | ||
| 2463 | if (hasLoadedDiscoveredSshHosts || isLoadingDiscoveredSshHosts) { | ||
| 2464 | return; | ||
| 2465 | } | ||
| 2466 | void loadDiscoveredSshHosts(); | ||
| 2467 | }, [ | ||
| 2468 | addBackendDialogOpen, | ||
| 2469 | desktopBridge, | ||
| 2470 | hasLoadedDiscoveredSshHosts, | ||
| 2471 | isLoadingDiscoveredSshHosts, | ||
| 2472 | loadDiscoveredSshHosts, | ||
| 2473 | savedBackendMode, | ||
| 2474 | ]); | ||
| 2475 | |||
| 2476 | useEffect(() => { | ||
| 2477 | if (!canManageLocalBackend) return; | ||
| 2478 | |||
| 2479 | let cancelled = false; | ||
| 2480 | setIsLoadingDesktopAccessManagement(true); | ||
| 2481 | type AuthAccessEvent = Parameters< | ||
| 2482 | Parameters<WsRpcClient["server"]["subscribeAuthAccess"]>[0] | ||
| 2483 | >[0]; | ||
| 2484 | const unsubscribeAuthAccess = | ||
| 2485 | getPrimaryEnvironmentConnection().client.server.subscribeAuthAccess( | ||
| 2486 | (event: AuthAccessEvent) => { | ||
| 2487 | if (cancelled) { | ||
| 2488 | return; | ||
| 2489 | } | ||
| 2490 | |||
| 2491 | switch (event.type) { | ||
| 2492 | case "snapshot": | ||
| 2493 | setDesktopPairingLinks( | ||
| 2494 | sortDesktopPairingLinks( | ||
| 2495 | event.payload.pairingLinks.map((pairingLink: AuthPairingLink) => | ||
| 2496 | toDesktopPairingLinkRecord(pairingLink), | ||
| 2497 | ), | ||
| 2498 | ), | ||
| 2499 | ); | ||
| 2500 | setDesktopClientSessions( | ||
| 2501 | sortDesktopClientSessions( | ||
| 2502 | event.payload.clientSessions.map((clientSession: AuthClientSession) => | ||
| 2503 | toDesktopClientSessionRecord(clientSession), | ||
| 2504 | ), | ||
| 2505 | ), | ||
| 2506 | ); | ||
| 2507 | break; | ||
| 2508 | case "pairingLinkUpserted": | ||
| 2509 | setDesktopPairingLinks((current) => | ||
| 2510 | upsertDesktopPairingLink(current, toDesktopPairingLinkRecord(event.payload)), | ||
| 2511 | ); | ||
| 2512 | break; | ||
| 2513 | case "pairingLinkRemoved": | ||
| 2514 | setDesktopPairingLinks((current) => | ||
| 2515 | removeDesktopPairingLink(current, event.payload.id), | ||
| 2516 | ); | ||
| 2517 | break; | ||
| 2518 | case "clientUpserted": | ||
| 2519 | setDesktopClientSessions((current) => | ||
| 2520 | upsertDesktopClientSession(current, toDesktopClientSessionRecord(event.payload)), | ||
| 2521 | ); | ||
| 2522 | break; | ||
| 2523 | case "clientRemoved": | ||
| 2524 | setDesktopClientSessions((current) => | ||
| 2525 | removeDesktopClientSession(current, event.payload.sessionId), | ||
| 2526 | ); | ||
| 2527 | break; | ||
| 2528 | } | ||
| 2529 | |||
| 2530 | setDesktopAccessManagementError(null); | ||
| 2531 | setIsLoadingDesktopAccessManagement(false); | ||
| 2532 | }, | ||
| 2533 | { | ||
| 2534 | onResubscribe: () => { | ||
| 2535 | if (!cancelled) { | ||
| 2536 | setIsLoadingDesktopAccessManagement(true); | ||
| 2537 | } | ||
| 2538 | }, | ||
| 2539 | }, | ||
| 2540 | ); | ||
| 2541 | if (desktopBridge) { | ||
| 2542 | void desktopBridge | ||
| 2543 | .getServerExposureState() | ||
| 2544 | .then((state) => { | ||
| 2545 | if (cancelled) return; | ||
| 2546 | setDesktopServerExposureState(state); | ||
| 2547 | }) | ||
| 2548 | .catch((error: unknown) => { | ||
| 2549 | if (cancelled) return; | ||
| 2550 | const message = | ||
| 2551 | error instanceof Error ? error.message : "Failed to load network exposure state."; | ||
| 2552 | setDesktopServerExposureError(message); | ||
| 2553 | }); | ||
| 2554 | void desktopBridge | ||
| 2555 | .getAdvertisedEndpoints() | ||
| 2556 | .then((endpoints) => { | ||
| 2557 | if (cancelled) return; | ||
| 2558 | setDesktopAdvertisedEndpoints(endpoints); | ||
| 2559 | }) | ||
| 2560 | .catch((error: unknown) => { | ||
| 2561 | if (cancelled) return; | ||
| 2562 | const message = | ||
| 2563 | error instanceof Error ? error.message : "Failed to load reachable endpoints."; | ||
| 2564 | setDesktopServerExposureError(message); | ||
| 2565 | }); | ||
| 2566 | } else { | ||
| 2567 | setDesktopServerExposureState(null); | ||
| 2568 | setDesktopAdvertisedEndpoints([]); | ||
| 2569 | setDesktopServerExposureError(null); | ||
| 2570 | } | ||
| 2571 | |||
| 2572 | return () => { | ||
| 2573 | cancelled = true; | ||
| 2574 | unsubscribeAuthAccess(); | ||
| 2575 | }; | ||
| 2576 | }, [canManageLocalBackend, desktopBridge]); | ||
| 2577 | |||
| 2578 | useEffect(() => { | ||
| 2579 | if (canManageLocalBackend) return; | ||
| 2580 | setIsLoadingDesktopAccessManagement(false); | ||
| 2581 | setDesktopPairingLinks([]); | ||
| 2582 | setDesktopClientSessions([]); | ||
| 2583 | setDesktopAccessManagementError(null); | ||
| 2584 | setDesktopServerExposureState(null); | ||
| 2585 | setDesktopAdvertisedEndpoints([]); | ||
| 2586 | setDesktopServerExposureError(null); | ||
| 2587 | }, [canManageLocalBackend]); | ||
| 2588 | const visibleDesktopPairingLinks = desktopPairingLinks; | 2444 | const visibleDesktopPairingLinks = desktopPairingLinks; |
| 2589 | const tailscaleHttpsEndpoint = useMemo( | 2445 | const tailscaleHttpsEndpoint = useMemo( |
| 2590 | () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, | 2446 | () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, |
| ... | @@ -2790,7 +2646,7 @@ export function ConnectionsSettings() { | ... | @@ -2790,7 +2646,7 @@ export function ConnectionsSettings() { |
| 2790 | size="xs" | 2646 | size="xs" |
| 2791 | variant="ghost" | 2647 | variant="ghost" |
| 2792 | disabled={isLoadingDiscoveredSshHosts} | 2648 | disabled={isLoadingDiscoveredSshHosts} |
| 2793 | onClick={() => void loadDiscoveredSshHosts()} | 2649 | onClick={desktopSshHosts.refresh} |
| 2794 | > | 2650 | > |
| 2795 | {isLoadingDiscoveredSshHosts ? ( | 2651 | {isLoadingDiscoveredSshHosts ? ( |
| 2796 | <RefreshCwIcon className="size-3 animate-spin" /> | 2652 | <RefreshCwIcon className="size-3 animate-spin" /> |
| ... | @@ -3142,21 +2998,12 @@ export function ConnectionsSettings() { | ... | @@ -3142,21 +2998,12 @@ export function ConnectionsSettings() { |
| 3142 | ) : null} | 2998 | ) : null} |
| 3143 | <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2"> | 2999 | <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2"> |
| 3144 | <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p> | 3000 | <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p> |
| 3145 | <Tooltip> | 3001 | <p |
| 3146 | <TooltipTrigger | 3002 | className="mt-1 truncate text-sm text-foreground" |
| 3147 | render={ | 3003 | title={pendingTailscaleServeBaseUrl ?? undefined} |
| 3148 | <p | 3004 | > |
| 3149 | aria-label={pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} | 3005 | {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} |
| 3150 | className="mt-1 truncate text-sm text-foreground" | 3006 | </p> |
| 3151 | /> | ||
| 3152 | } | ||
| 3153 | > | ||
| 3154 | {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} | ||
| 3155 | </TooltipTrigger> | ||
| 3156 | <TooltipPopup side="top"> | ||
| 3157 | {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} | ||
| 3158 | </TooltipPopup> | ||
| 3159 | </Tooltip> | ||
| 3160 | </div> | 3007 | </div> |
| 3161 | </DialogPanel> | 3008 | </DialogPanel> |
| 3162 | <DialogFooter> | 3009 | <DialogFooter> |
| ... | @@ -3257,15 +3104,12 @@ export function ConnectionsSettings() { | ... | @@ -3257,15 +3104,12 @@ export function ConnectionsSettings() { |
| 3257 | </Dialog> | 3104 | </Dialog> |
| 3258 | } | 3105 | } |
| 3259 | > | 3106 | > |
| 3260 | {savedEnvironmentIds.map((environmentId) => ( | 3107 | {savedEnvironments.map((environment) => ( |
| 3261 | <SavedBackendListRow | 3108 | <SavedBackendListRow |
| 3262 | key={environmentId} | 3109 | key={environment.environmentId} |
| 3263 | environmentId={environmentId} | 3110 | environment={environment} |
| 3264 | reconnectingEnvironmentId={reconnectingSavedEnvironmentId} | ||
| 3265 | disconnectingEnvironmentId={disconnectingSavedEnvironmentId} | ||
| 3266 | removingEnvironmentId={removingSavedEnvironmentId} | 3111 | removingEnvironmentId={removingSavedEnvironmentId} |
| 3267 | onConnect={handleConnectSavedBackend} | 3112 | onConnect={handleConnectSavedBackend} |
| 3268 | onDisconnect={handleDisconnectSavedBackend} | ||
| 3269 | onRemove={handleRemoveSavedBackend} | 3113 | onRemove={handleRemoveSavedBackend} |
| 3270 | /> | 3114 | /> |
| 3271 | ))} | 3115 | ))} |
apps/web/src/components/settings/DiagnosticsSettings.tsx+95-48| ... | @@ -7,6 +7,11 @@ import { | ... | @@ -7,6 +7,11 @@ import { |
| 7 | InfoIcon, | 7 | InfoIcon, |
| 8 | RefreshCwIcon, | 8 | RefreshCwIcon, |
| 9 | } from "lucide-react"; | 9 | } from "lucide-react"; |
| 10 | import { useAtomValue } from "@effect/atom-react"; | ||
| 11 | import { | ||
| 12 | isAtomCommandInterrupted, | ||
| 13 | squashAtomCommandFailure, | ||
| 14 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 10 | import { useCallback, useMemo, useState, type ReactNode } from "react"; | 15 | import { useCallback, useMemo, useState, type ReactNode } from "react"; |
| 11 | import type { | 16 | import type { |
| 12 | ServerProcessDiagnosticsEntry, | 17 | ServerProcessDiagnosticsEntry, |
| ... | @@ -16,21 +21,23 @@ import type { | ... | @@ -16,21 +21,23 @@ import type { |
| 16 | import * as DateTime from "effect/DateTime"; | 21 | import * as DateTime from "effect/DateTime"; |
| 17 | import * as Option from "effect/Option"; | 22 | import * as Option from "effect/Option"; |
| 18 | 23 | ||
| 19 | import { ensureLocalApi } from "../../localApi"; | ||
| 20 | import { cn } from "../../lib/utils"; | 24 | import { cn } from "../../lib/utils"; |
| 21 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; | 25 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; |
| 22 | import { formatRelativeTime } from "../../timestampFormat"; | 26 | import { formatRelativeTime } from "../../timestampFormat"; |
| 23 | import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState"; | 27 | import { useEnvironmentQuery } from "../../state/query"; |
| 24 | import { | 28 | import { |
| 25 | useProcessDiagnostics, | 29 | primaryServerAvailableEditorsAtom, |
| 26 | useProcessResourceHistory, | 30 | primaryServerObservabilityAtom, |
| 27 | } from "../../lib/processDiagnosticsState"; | 31 | serverEnvironment, |
| 28 | import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState"; | 32 | } from "../../state/server"; |
| 33 | import { shellEnvironment } from "../../state/shell"; | ||
| 34 | import { usePrimaryEnvironment } from "../../state/environments"; | ||
| 29 | import { Button } from "../ui/button"; | 35 | import { Button } from "../ui/button"; |
| 30 | import { ScrollArea } from "../ui/scroll-area"; | 36 | import { ScrollArea } from "../ui/scroll-area"; |
| 31 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 37 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 32 | import { toastManager } from "../ui/toast"; | 38 | import { toastManager } from "../ui/toast"; |
| 33 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; | 39 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; |
| 40 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 34 | 41 | ||
| 35 | const NUMBER_FORMAT = new Intl.NumberFormat(); | 42 | const NUMBER_FORMAT = new Intl.NumberFormat(); |
| 36 | 43 | ||
| ... | @@ -803,28 +810,51 @@ function DiagnosticsRefreshButton({ | ... | @@ -803,28 +810,51 @@ function DiagnosticsRefreshButton({ |
| 803 | } | 810 | } |
| 804 | 811 | ||
| 805 | export function DiagnosticsSettingsPanel() { | 812 | export function DiagnosticsSettingsPanel() { |
| 806 | const observability = useServerObservability(); | 813 | const observability = useAtomValue(primaryServerObservabilityAtom); |
| 807 | const availableEditors = useServerAvailableEditors(); | 814 | const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); |
| 815 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 816 | const environmentId = primaryEnvironment?.environmentId ?? null; | ||
| 817 | const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { | ||
| 818 | reportFailure: false, | ||
| 819 | }); | ||
| 820 | const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { | ||
| 821 | reportFailure: false, | ||
| 822 | }); | ||
| 808 | const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000); | 823 | const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000); |
| 809 | const selectedResourceWindow = | 824 | const selectedResourceWindow = |
| 810 | RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? | 825 | RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? |
| 811 | RESOURCE_HISTORY_WINDOWS[1]; | 826 | RESOURCE_HISTORY_WINDOWS[1]; |
| 812 | const { data, error, isPending, refresh } = useTraceDiagnostics(); | 827 | const { data, error, isPending, refresh } = useEnvironmentQuery( |
| 828 | environmentId === null | ||
| 829 | ? null | ||
| 830 | : serverEnvironment.traceDiagnostics({ environmentId, input: {} }), | ||
| 831 | ); | ||
| 813 | const { | 832 | const { |
| 814 | data: processData, | 833 | data: processData, |
| 815 | error: processError, | 834 | error: processError, |
| 816 | isPending: isProcessPending, | 835 | isPending: isProcessPending, |
| 817 | refresh: refreshProcesses, | 836 | refresh: refreshProcesses, |
| 818 | } = useProcessDiagnostics(); | 837 | } = useEnvironmentQuery( |
| 838 | environmentId === null | ||
| 839 | ? null | ||
| 840 | : serverEnvironment.processDiagnostics({ environmentId, input: {} }), | ||
| 841 | ); | ||
| 819 | const { | 842 | const { |
| 820 | data: resourceData, | 843 | data: resourceData, |
| 821 | error: resourceError, | 844 | error: resourceError, |
| 822 | isPending: isResourcePending, | 845 | isPending: isResourcePending, |
| 823 | refresh: refreshResources, | 846 | refresh: refreshResources, |
| 824 | } = useProcessResourceHistory({ | 847 | } = useEnvironmentQuery( |
| 825 | windowMs: selectedResourceWindow.windowMs, | 848 | environmentId === null |
| 826 | bucketMs: selectedResourceWindow.bucketMs, | 849 | ? null |
| 827 | }); | 850 | : serverEnvironment.processResourceHistory({ |
| 851 | environmentId, | ||
| 852 | input: { | ||
| 853 | windowMs: selectedResourceWindow.windowMs, | ||
| 854 | bucketMs: selectedResourceWindow.bucketMs, | ||
| 855 | }, | ||
| 856 | }), | ||
| 857 | ); | ||
| 828 | const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); | 858 | const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); |
| 829 | const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null); | 859 | const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null); |
| 830 | const [signalingPid, setSignalingPid] = useState<number | null>(null); | 860 | const [signalingPid, setSignalingPid] = useState<number | null>(null); |
| ... | @@ -838,20 +868,30 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -838,20 +868,30 @@ export function DiagnosticsSettingsPanel() { |
| 838 | setOpenLogsDirectoryError("No available editors found."); | 868 | setOpenLogsDirectoryError("No available editors found."); |
| 839 | return; | 869 | return; |
| 840 | } | 870 | } |
| 871 | if (environmentId === null) { | ||
| 872 | setOpenLogsDirectoryError("No environment is selected."); | ||
| 873 | return; | ||
| 874 | } | ||
| 841 | 875 | ||
| 842 | setIsOpeningLogsDirectory(true); | 876 | setIsOpeningLogsDirectory(true); |
| 843 | setOpenLogsDirectoryError(null); | 877 | setOpenLogsDirectoryError(null); |
| 844 | void ensureLocalApi() | 878 | void (async () => { |
| 845 | .shell.openInEditor(logsDirectoryPath, editor) | 879 | const result = await openInEditor({ |
| 846 | .catch((error: unknown) => { | 880 | environmentId, |
| 881 | input: { | ||
| 882 | cwd: logsDirectoryPath, | ||
| 883 | editor, | ||
| 884 | }, | ||
| 885 | }); | ||
| 886 | setIsOpeningLogsDirectory(false); | ||
| 887 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 888 | const error = squashAtomCommandFailure(result); | ||
| 847 | setOpenLogsDirectoryError( | 889 | setOpenLogsDirectoryError( |
| 848 | error instanceof Error ? error.message : "Unable to open logs folder.", | 890 | error instanceof Error ? error.message : "Unable to open logs folder.", |
| 849 | ); | 891 | ); |
| 850 | }) | 892 | } |
| 851 | .finally(() => { | 893 | })(); |
| 852 | setIsOpeningLogsDirectory(false); | 894 | }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]); |
| 853 | }); | ||
| 854 | }, [availableEditors, observability?.logsDirectoryPath]); | ||
| 855 | 895 | ||
| 856 | const isInitialLoading = isPending && data === null; | 896 | const isInitialLoading = isPending && data === null; |
| 857 | const isProcessInitialLoading = isProcessPending && processData === null; | 897 | const isProcessInitialLoading = isProcessPending && processData === null; |
| ... | @@ -863,45 +903,52 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -863,45 +903,52 @@ export function DiagnosticsSettingsPanel() { |
| 863 | ) { | 903 | ) { |
| 864 | return; | 904 | return; |
| 865 | } | 905 | } |
| 906 | if (environmentId === null) { | ||
| 907 | return; | ||
| 908 | } | ||
| 866 | 909 | ||
| 867 | setSignalingPid(pid); | 910 | setSignalingPid(pid); |
| 868 | void ensureLocalApi() | 911 | void (async () => { |
| 869 | .server.signalProcess({ pid, signal }) | 912 | const result = await signalServerProcess({ |
| 870 | .then((result) => { | 913 | environmentId, |
| 871 | if (!result.signaled) { | 914 | input: { pid, signal }, |
| 872 | const message = Option.getOrUndefined(result.message); | 915 | }); |
| 873 | refreshProcesses(); | 916 | setSignalingPid(null); |
| 874 | if (isStaleProcessSignalMessage(message)) { | 917 | if (result._tag === "Failure") { |
| 875 | toastManager.add({ | 918 | if (!isAtomCommandInterrupted(result)) { |
| 876 | type: "info", | 919 | const error = squashAtomCommandFailure(result); |
| 877 | title: "Process already exited", | ||
| 878 | description: | ||
| 879 | "The process is not a child of the T3 Server. It might already have exited.", | ||
| 880 | }); | ||
| 881 | return; | ||
| 882 | } | ||
| 883 | |||
| 884 | toastManager.add({ | 920 | toastManager.add({ |
| 885 | type: "error", | 921 | type: "error", |
| 886 | title: `Could not send ${signal}`, | 922 | title: `Could not send ${signal}`, |
| 887 | description: message ?? `Failed to send ${signal}.`, | 923 | description: error instanceof Error ? error.message : `Failed to send ${signal}.`, |
| 888 | }); | 924 | }); |
| 889 | return; | ||
| 890 | } | 925 | } |
| 926 | return; | ||
| 927 | } | ||
| 928 | if (!result.value.signaled) { | ||
| 929 | const message = Option.getOrUndefined(result.value.message); | ||
| 891 | refreshProcesses(); | 930 | refreshProcesses(); |
| 892 | }) | 931 | if (isStaleProcessSignalMessage(message)) { |
| 893 | .catch((error: unknown) => { | 932 | toastManager.add({ |
| 933 | type: "info", | ||
| 934 | title: "Process already exited", | ||
| 935 | description: | ||
| 936 | "The process is not a child of the T3 Server. It might already have exited.", | ||
| 937 | }); | ||
| 938 | return; | ||
| 939 | } | ||
| 940 | |||
| 894 | toastManager.add({ | 941 | toastManager.add({ |
| 895 | type: "error", | 942 | type: "error", |
| 896 | title: `Could not send ${signal}`, | 943 | title: `Could not send ${signal}`, |
| 897 | description: error instanceof Error ? error.message : `Failed to send ${signal}.`, | 944 | description: message ?? `Failed to send ${signal}.`, |
| 898 | }); | 945 | }); |
| 899 | }) | 946 | return; |
| 900 | .finally(() => { | 947 | } |
| 901 | setSignalingPid(null); | 948 | refreshProcesses(); |
| 902 | }); | 949 | })(); |
| 903 | }, | 950 | }, |
| 904 | [refreshProcesses], | 951 | [environmentId, refreshProcesses, signalServerProcess], |
| 905 | ); | 952 | ); |
| 906 | 953 | ||
| 907 | const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; | 954 | const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; |
apps/web/src/components/settings/KeybindingsSettings.tsx+86-43| ... | @@ -27,13 +27,23 @@ import { | ... | @@ -27,13 +27,23 @@ import { |
| 27 | type ServerRemoveKeybindingInput, | 27 | type ServerRemoveKeybindingInput, |
| 28 | type ServerUpsertKeybindingInput, | 28 | type ServerUpsertKeybindingInput, |
| 29 | } from "@t3tools/contracts"; | 29 | } from "@t3tools/contracts"; |
| 30 | import { useAtomValue } from "@effect/atom-react"; | ||
| 31 | import { | ||
| 32 | isAtomCommandInterrupted, | ||
| 33 | squashAtomCommandFailure, | ||
| 34 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 30 | 35 | ||
| 31 | import { isElectron } from "../../env"; | 36 | import { isElectron } from "../../env"; |
| 32 | import { openInPreferredEditor } from "../../editorPreferences"; | 37 | import { useOpenInPreferredEditor } from "../../editorPreferences"; |
| 33 | import { formatShortcutLabel } from "../../keybindings"; | 38 | import { formatShortcutLabel } from "../../keybindings"; |
| 34 | import { cn } from "../../lib/utils"; | 39 | import { cn } from "../../lib/utils"; |
| 35 | import { ensureLocalApi } from "../../localApi"; | 40 | import { |
| 36 | import { useServerKeybindings, useServerKeybindingsConfigPath } from "../../rpc/serverState"; | 41 | primaryServerAvailableEditorsAtom, |
| 42 | primaryServerKeybindingsAtom, | ||
| 43 | primaryServerKeybindingsConfigPathAtom, | ||
| 44 | serverEnvironment, | ||
| 45 | } from "../../state/server"; | ||
| 46 | import { usePrimaryEnvironment } from "../../state/environments"; | ||
| 37 | import { Button } from "../ui/button"; | 47 | import { Button } from "../ui/button"; |
| 38 | import { Input } from "../ui/input"; | 48 | import { Input } from "../ui/input"; |
| 39 | import { Kbd, KbdGroup } from "../ui/kbd"; | 49 | import { Kbd, KbdGroup } from "../ui/kbd"; |
| ... | @@ -61,6 +71,7 @@ import { | ... | @@ -61,6 +71,7 @@ import { |
| 61 | } from "./KeybindingsSettings.logic"; | 71 | } from "./KeybindingsSettings.logic"; |
| 62 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; | 72 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; |
| 63 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 73 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 74 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 64 | 75 | ||
| 65 | function KeybindingPill({ value }: { value: string }) { | 76 | function KeybindingPill({ value }: { value: string }) { |
| 66 | const parts = value.split("+"); | 77 | const parts = value.split("+"); |
| ... | @@ -1069,8 +1080,20 @@ function NewKeybindingTableRow({ | ... | @@ -1069,8 +1080,20 @@ function NewKeybindingTableRow({ |
| 1069 | } | 1080 | } |
| 1070 | 1081 | ||
| 1071 | export function KeybindingsSettingsPanel() { | 1082 | export function KeybindingsSettingsPanel() { |
| 1072 | const keybindings = useServerKeybindings(); | 1083 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 1073 | const keybindingsConfigPath = useServerKeybindingsConfigPath(); | 1084 | const keybindingsConfigPath = useAtomValue(primaryServerKeybindingsConfigPathAtom); |
| 1085 | const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); | ||
| 1086 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 1087 | const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { | ||
| 1088 | reportFailure: false, | ||
| 1089 | }); | ||
| 1090 | const removeKeybindingMutation = useAtomCommand(serverEnvironment.removeKeybinding, { | ||
| 1091 | reportFailure: false, | ||
| 1092 | }); | ||
| 1093 | const openInPreferredEditor = useOpenInPreferredEditor( | ||
| 1094 | primaryEnvironment?.environmentId ?? null, | ||
| 1095 | availableEditors, | ||
| 1096 | ); | ||
| 1074 | const [query, setQuery] = useState(""); | 1097 | const [query, setQuery] = useState(""); |
| 1075 | const [isSearchOpen, setIsSearchOpen] = useState(false); | 1098 | const [isSearchOpen, setIsSearchOpen] = useState(false); |
| 1076 | const searchInputRef = useRef<HTMLInputElement>(null); | 1099 | const searchInputRef = useRef<HTMLInputElement>(null); |
| ... | @@ -1107,56 +1130,76 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1107,56 +1130,76 @@ export function KeybindingsSettingsPanel() { |
| 1107 | 1130 | ||
| 1108 | const openKeybindingsFile = useCallback(() => { | 1131 | const openKeybindingsFile = useCallback(() => { |
| 1109 | if (!keybindingsConfigPath) return; | 1132 | if (!keybindingsConfigPath) return; |
| 1110 | void openInPreferredEditor(ensureLocalApi(), keybindingsConfigPath).catch((error: unknown) => { | 1133 | void (async () => { |
| 1134 | const result = await openInPreferredEditor(keybindingsConfigPath); | ||
| 1135 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | ||
| 1136 | return; | ||
| 1137 | } | ||
| 1138 | const error = squashAtomCommandFailure(result); | ||
| 1111 | toastManager.add({ | 1139 | toastManager.add({ |
| 1112 | title: "Unable to open keybindings file", | 1140 | title: "Unable to open keybindings file", |
| 1113 | description: | 1141 | description: |
| 1114 | error instanceof Error ? error.message : "The keybindings file was not opened.", | 1142 | error instanceof Error ? error.message : "The keybindings file was not opened.", |
| 1115 | type: "error", | 1143 | type: "error", |
| 1116 | }); | 1144 | }); |
| 1117 | }); | 1145 | })(); |
| 1118 | }, [keybindingsConfigPath]); | 1146 | }, [keybindingsConfigPath, openInPreferredEditor]); |
| 1119 | 1147 | ||
| 1120 | const saveKeybinding = useCallback((input: ServerUpsertKeybindingInput) => { | 1148 | const saveKeybinding = useCallback( |
| 1121 | setSavingCommand(input.command); | 1149 | (input: ServerUpsertKeybindingInput) => { |
| 1122 | const payload: ServerUpsertKeybindingInput = { | 1150 | if (!primaryEnvironment) return; |
| 1123 | command: input.command, | 1151 | setSavingCommand(input.command); |
| 1124 | key: input.key.trim(), | 1152 | const payload: ServerUpsertKeybindingInput = { |
| 1125 | ...(input.when?.trim() ? { when: input.when.trim() } : {}), | 1153 | command: input.command, |
| 1126 | ...(input.replace ? { replace: input.replace } : {}), | 1154 | key: input.key.trim(), |
| 1127 | }; | 1155 | ...(input.when?.trim() ? { when: input.when.trim() } : {}), |
| 1128 | void ensureLocalApi() | 1156 | ...(input.replace ? { replace: input.replace } : {}), |
| 1129 | .server.upsertKeybinding(payload) | 1157 | }; |
| 1130 | .then(() => { | 1158 | void (async () => { |
| 1131 | setIsAddingBinding(false); | 1159 | const result = await upsertKeybinding({ |
| 1132 | }) | 1160 | environmentId: primaryEnvironment.environmentId, |
| 1133 | .catch((error: unknown) => { | 1161 | input: payload, |
| 1134 | toastManager.add({ | ||
| 1135 | title: "Unable to save keybinding", | ||
| 1136 | description: error instanceof Error ? error.message : "The keybinding was not saved.", | ||
| 1137 | type: "error", | ||
| 1138 | }); | 1162 | }); |
| 1139 | }) | ||
| 1140 | .finally(() => { | ||
| 1141 | setSavingCommand(null); | 1163 | setSavingCommand(null); |
| 1142 | }); | 1164 | if (result._tag === "Success") { |
| 1143 | }, []); | 1165 | setIsAddingBinding(false); |
| 1166 | return; | ||
| 1167 | } | ||
| 1168 | if (!isAtomCommandInterrupted(result)) { | ||
| 1169 | const error = squashAtomCommandFailure(result); | ||
| 1170 | toastManager.add({ | ||
| 1171 | title: "Unable to save keybinding", | ||
| 1172 | description: error instanceof Error ? error.message : "The keybinding was not saved.", | ||
| 1173 | type: "error", | ||
| 1174 | }); | ||
| 1175 | } | ||
| 1176 | })(); | ||
| 1177 | }, | ||
| 1178 | [primaryEnvironment, upsertKeybinding], | ||
| 1179 | ); | ||
| 1144 | 1180 | ||
| 1145 | const removeKeybinding = useCallback((row: KeybindingRow) => { | 1181 | const removeKeybinding = useCallback( |
| 1146 | setSavingCommand(row.command); | 1182 | (row: KeybindingRow) => { |
| 1147 | void ensureLocalApi() | 1183 | if (!primaryEnvironment) return; |
| 1148 | .server.removeKeybinding(rowKeybindingTarget(row)) | 1184 | setSavingCommand(row.command); |
| 1149 | .catch((error: unknown) => { | 1185 | void (async () => { |
| 1150 | toastManager.add({ | 1186 | const result = await removeKeybindingMutation({ |
| 1151 | title: "Unable to remove keybinding", | 1187 | environmentId: primaryEnvironment.environmentId, |
| 1152 | description: error instanceof Error ? error.message : "The keybinding was not removed.", | 1188 | input: rowKeybindingTarget(row), |
| 1153 | type: "error", | ||
| 1154 | }); | 1189 | }); |
| 1155 | }) | ||
| 1156 | .finally(() => { | ||
| 1157 | setSavingCommand(null); | 1190 | setSavingCommand(null); |
| 1158 | }); | 1191 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 1159 | }, []); | 1192 | const error = squashAtomCommandFailure(result); |
| 1193 | toastManager.add({ | ||
| 1194 | title: "Unable to remove keybinding", | ||
| 1195 | description: error instanceof Error ? error.message : "The keybinding was not removed.", | ||
| 1196 | type: "error", | ||
| 1197 | }); | ||
| 1198 | } | ||
| 1199 | })(); | ||
| 1200 | }, | ||
| 1201 | [primaryEnvironment, removeKeybindingMutation], | ||
| 1202 | ); | ||
| 1160 | 1203 | ||
| 1161 | const resetKeybinding = useCallback( | 1204 | const resetKeybinding = useCallback( |
| 1162 | (row: KeybindingRow) => { | 1205 | (row: KeybindingRow) => { |
apps/web/src/components/settings/ProviderInstanceCard.tsx+1-5| ... | @@ -12,7 +12,7 @@ import { | ... | @@ -12,7 +12,7 @@ import { |
| 12 | } from "lucide-react"; | 12 | } from "lucide-react"; |
| 13 | import * as Arr from "effect/Array"; | 13 | import * as Arr from "effect/Array"; |
| 14 | import * as Result from "effect/Result"; | 14 | import * as Result from "effect/Result"; |
| 15 | import { useEffect, useState, type ReactNode } from "react"; | 15 | import { useState, type ReactNode } from "react"; |
| 16 | import { | 16 | import { |
| 17 | isProviderDriverKind, | 17 | isProviderDriverKind, |
| 18 | type ProviderInstanceConfig, | 18 | type ProviderInstanceConfig, |
| ... | @@ -161,10 +161,6 @@ function ProviderEnvironmentSection(props: { | ... | @@ -161,10 +161,6 @@ function ProviderEnvironmentSection(props: { |
| 161 | props.environment.map(makeEnvironmentDraftRow), | 161 | props.environment.map(makeEnvironmentDraftRow), |
| 162 | ); | 162 | ); |
| 163 | 163 | ||
| 164 | useEffect(() => { | ||
| 165 | setRows(props.environment.map(makeEnvironmentDraftRow)); | ||
| 166 | }, [props.environment]); | ||
| 167 | |||
| 168 | const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => { | 164 | const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => { |
| 169 | const published: ProviderInstanceEnvironmentVariable[] = []; | 165 | const published: ProviderInstanceEnvironmentVariable[] = []; |
| 170 | for (const row of nextRows) { | 166 | for (const row of nextRows) { |
apps/web/src/components/settings/SettingsPanels.browser.tsx deleted-1541| ... | @@ -1,1541 +0,0 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | type AuthAccessStreamEvent, | ||
| 5 | type AuthAccessSnapshot, | ||
| 6 | type AuthEnvironmentScope, | ||
| 7 | AuthSessionId, | ||
| 8 | DEFAULT_SERVER_SETTINGS, | ||
| 9 | EnvironmentId, | ||
| 10 | type DesktopBridge, | ||
| 11 | type DesktopUpdateChannel, | ||
| 12 | type DesktopUpdateState, | ||
| 13 | type LocalApi, | ||
| 14 | ProviderDriverKind, | ||
| 15 | ProviderInstanceId, | ||
| 16 | type ServerConfig, | ||
| 17 | type ServerProcessResourceHistoryResult, | ||
| 18 | type ServerProvider, | ||
| 19 | type SourceControlDiscoveryResult, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import * as DateTime from "effect/DateTime"; | ||
| 22 | import * as Option from "effect/Option"; | ||
| 23 | import { page } from "vite-plus/test/browser"; | ||
| 24 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 25 | import { render } from "vitest-browser-react"; | ||
| 26 | import type { ReactNode } from "react"; | ||
| 27 | import { | ||
| 28 | RouterProvider, | ||
| 29 | createMemoryHistory, | ||
| 30 | createRootRoute, | ||
| 31 | createRoute, | ||
| 32 | createRouter, | ||
| 33 | } from "@tanstack/react-router"; | ||
| 34 | |||
| 35 | import { __resetLocalApiForTests } from "../../localApi"; | ||
| 36 | import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; | ||
| 37 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; | ||
| 38 | import { useUiStateStore } from "../../uiStateStore"; | ||
| 39 | import { ConnectionsSettings } from "./ConnectionsSettings"; | ||
| 40 | import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings"; | ||
| 41 | import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; | ||
| 42 | import { SourceControlSettingsPanel } from "./SourceControlSettings"; | ||
| 43 | |||
| 44 | function renderWithTestRouter(children: ReactNode) { | ||
| 45 | const rootRoute = createRootRoute({ | ||
| 46 | component: () => children, | ||
| 47 | }); | ||
| 48 | const indexRoute = createRoute({ | ||
| 49 | getParentRoute: () => rootRoute, | ||
| 50 | path: "/", | ||
| 51 | }); | ||
| 52 | const router = createRouter({ | ||
| 53 | routeTree: rootRoute.addChildren([indexRoute]), | ||
| 54 | history: createMemoryHistory({ initialEntries: ["/"] }), | ||
| 55 | }); | ||
| 56 | |||
| 57 | return render(<RouterProvider router={router} />); | ||
| 58 | } | ||
| 59 | |||
| 60 | const authAccessHarness = vi.hoisted(() => { | ||
| 61 | type Snapshot = AuthAccessSnapshot; | ||
| 62 | let snapshot: Snapshot = { | ||
| 63 | pairingLinks: [], | ||
| 64 | clientSessions: [], | ||
| 65 | }; | ||
| 66 | let revision = 1; | ||
| 67 | const listeners = new Set<(event: AuthAccessStreamEvent) => void>(); | ||
| 68 | |||
| 69 | const emitEvent = (event: AuthAccessStreamEvent) => { | ||
| 70 | for (const listener of listeners) { | ||
| 71 | listener(event); | ||
| 72 | } | ||
| 73 | }; | ||
| 74 | |||
| 75 | return { | ||
| 76 | reset() { | ||
| 77 | snapshot = { | ||
| 78 | pairingLinks: [], | ||
| 79 | clientSessions: [], | ||
| 80 | }; | ||
| 81 | revision = 1; | ||
| 82 | listeners.clear(); | ||
| 83 | }, | ||
| 84 | setSnapshot(next: Snapshot) { | ||
| 85 | snapshot = next; | ||
| 86 | }, | ||
| 87 | emitSnapshot() { | ||
| 88 | emitEvent({ | ||
| 89 | version: 1 as const, | ||
| 90 | revision, | ||
| 91 | type: "snapshot" as const, | ||
| 92 | payload: snapshot, | ||
| 93 | }); | ||
| 94 | revision += 1; | ||
| 95 | }, | ||
| 96 | emitEvent, | ||
| 97 | emitPairingLinkUpserted(pairingLink: Snapshot["pairingLinks"][number]) { | ||
| 98 | emitEvent({ | ||
| 99 | version: 1, | ||
| 100 | revision, | ||
| 101 | type: "pairingLinkUpserted", | ||
| 102 | payload: pairingLink, | ||
| 103 | }); | ||
| 104 | revision += 1; | ||
| 105 | }, | ||
| 106 | emitPairingLinkRemoved(id: string) { | ||
| 107 | emitEvent({ | ||
| 108 | version: 1, | ||
| 109 | revision, | ||
| 110 | type: "pairingLinkRemoved", | ||
| 111 | payload: { id }, | ||
| 112 | }); | ||
| 113 | revision += 1; | ||
| 114 | }, | ||
| 115 | emitClientUpserted(clientSession: Snapshot["clientSessions"][number]) { | ||
| 116 | emitEvent({ | ||
| 117 | version: 1, | ||
| 118 | revision, | ||
| 119 | type: "clientUpserted", | ||
| 120 | payload: clientSession, | ||
| 121 | }); | ||
| 122 | revision += 1; | ||
| 123 | }, | ||
| 124 | emitClientRemoved(sessionId: string) { | ||
| 125 | emitEvent({ | ||
| 126 | version: 1, | ||
| 127 | revision, | ||
| 128 | type: "clientRemoved", | ||
| 129 | payload: { | ||
| 130 | sessionId: AuthSessionId.make(sessionId), | ||
| 131 | }, | ||
| 132 | }); | ||
| 133 | revision += 1; | ||
| 134 | }, | ||
| 135 | subscribe(listener: (event: AuthAccessStreamEvent) => void) { | ||
| 136 | listeners.add(listener); | ||
| 137 | listener({ | ||
| 138 | version: 1, | ||
| 139 | revision: 1, | ||
| 140 | type: "snapshot", | ||
| 141 | payload: snapshot, | ||
| 142 | }); | ||
| 143 | return () => { | ||
| 144 | listeners.delete(listener); | ||
| 145 | }; | ||
| 146 | }, | ||
| 147 | }; | ||
| 148 | }); | ||
| 149 | |||
| 150 | const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn()); | ||
| 151 | const mockGetClerkToken = vi.hoisted(() => vi.fn(async () => null)); | ||
| 152 | const mockOpenClerkWaitlist = vi.hoisted(() => vi.fn()); | ||
| 153 | |||
| 154 | vi.mock("@clerk/react", () => ({ | ||
| 155 | useAuth: () => ({ | ||
| 156 | getToken: mockGetClerkToken, | ||
| 157 | isSignedIn: false, | ||
| 158 | }), | ||
| 159 | useClerk: () => ({ | ||
| 160 | openWaitlist: mockOpenClerkWaitlist, | ||
| 161 | }), | ||
| 162 | })); | ||
| 163 | |||
| 164 | vi.mock("../../environments/runtime", () => { | ||
| 165 | const primaryConnection = { | ||
| 166 | kind: "primary" as const, | ||
| 167 | knownEnvironment: { | ||
| 168 | id: "environment-local", | ||
| 169 | label: "Local environment", | ||
| 170 | source: "manual" as const, | ||
| 171 | environmentId: EnvironmentId.make("environment-local"), | ||
| 172 | target: { | ||
| 173 | httpBaseUrl: "http://localhost:3000", | ||
| 174 | wsBaseUrl: "ws://localhost:3000", | ||
| 175 | }, | ||
| 176 | }, | ||
| 177 | environmentId: EnvironmentId.make("environment-local"), | ||
| 178 | client: { | ||
| 179 | server: { | ||
| 180 | subscribeAuthAccess: (listener: Parameters<typeof authAccessHarness.subscribe>[0]) => | ||
| 181 | authAccessHarness.subscribe(listener), | ||
| 182 | }, | ||
| 183 | }, | ||
| 184 | ensureBootstrapped: async () => undefined, | ||
| 185 | reconnect: async () => undefined, | ||
| 186 | dispose: async () => undefined, | ||
| 187 | }; | ||
| 188 | |||
| 189 | return { | ||
| 190 | getEnvironmentHttpBaseUrl: () => "http://localhost:3000", | ||
| 191 | getSavedEnvironmentRecord: () => null, | ||
| 192 | getSavedEnvironmentRuntimeState: () => null, | ||
| 193 | hasSavedEnvironmentRegistryHydrated: () => true, | ||
| 194 | listSavedEnvironmentRecords: () => [], | ||
| 195 | resetSavedEnvironmentRegistryStoreForTests: () => undefined, | ||
| 196 | resetSavedEnvironmentRuntimeStoreForTests: () => undefined, | ||
| 197 | resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => | ||
| 198 | new URL(path, "http://localhost:3000").toString(), | ||
| 199 | waitForSavedEnvironmentRegistryHydration: async () => undefined, | ||
| 200 | addManagedRelayEnvironment: vi.fn(), | ||
| 201 | addSavedEnvironment: vi.fn(), | ||
| 202 | connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment, | ||
| 203 | disconnectSavedEnvironment: vi.fn(), | ||
| 204 | ensureEnvironmentConnectionBootstrapped: async () => undefined, | ||
| 205 | getPrimaryEnvironmentConnection: () => primaryConnection, | ||
| 206 | readEnvironmentConnection: () => primaryConnection, | ||
| 207 | reconnectSavedEnvironment: vi.fn(), | ||
| 208 | removeSavedEnvironment: vi.fn(), | ||
| 209 | requireEnvironmentConnection: () => primaryConnection, | ||
| 210 | resetEnvironmentServiceForTests: () => undefined, | ||
| 211 | startEnvironmentConnectionService: () => undefined, | ||
| 212 | subscribeEnvironmentConnections: () => () => {}, | ||
| 213 | useSavedEnvironmentRegistryStore: ( | ||
| 214 | selector: (state: { byId: Record<string, never> }) => unknown, | ||
| 215 | ) => selector({ byId: {} }), | ||
| 216 | useSavedEnvironmentRuntimeStore: ( | ||
| 217 | selector: (state: { byId: Record<string, never> }) => unknown, | ||
| 218 | ) => selector({ byId: {} }), | ||
| 219 | }; | ||
| 220 | }); | ||
| 221 | |||
| 222 | function createBaseServerConfig(): ServerConfig { | ||
| 223 | return { | ||
| 224 | environment: { | ||
| 225 | environmentId: EnvironmentId.make("environment-local"), | ||
| 226 | label: "Local environment", | ||
| 227 | platform: { os: "darwin" as const, arch: "arm64" as const }, | ||
| 228 | serverVersion: "0.0.0-test", | ||
| 229 | capabilities: { repositoryIdentity: true }, | ||
| 230 | }, | ||
| 231 | auth: { | ||
| 232 | policy: "loopback-browser", | ||
| 233 | bootstrapMethods: ["one-time-token"], | ||
| 234 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | ||
| 235 | sessionCookieName: "t3_session", | ||
| 236 | }, | ||
| 237 | cwd: "/repo/project", | ||
| 238 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | ||
| 239 | keybindings: [], | ||
| 240 | issues: [], | ||
| 241 | providers: [], | ||
| 242 | availableEditors: ["cursor"], | ||
| 243 | observability: { | ||
| 244 | logsDirectoryPath: "/repo/project/.t3/logs", | ||
| 245 | localTracingEnabled: true, | ||
| 246 | otlpTracesUrl: "http://localhost:4318/v1/traces", | ||
| 247 | otlpTracesEnabled: true, | ||
| 248 | otlpMetricsEnabled: false, | ||
| 249 | }, | ||
| 250 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 251 | }; | ||
| 252 | } | ||
| 253 | |||
| 254 | function createOutdatedProvider( | ||
| 255 | driver: string, | ||
| 256 | updateCommand = "npm install -g openai/codex@latest", | ||
| 257 | ): ServerProvider { | ||
| 258 | return { | ||
| 259 | instanceId: ProviderInstanceId.make(driver), | ||
| 260 | driver: ProviderDriverKind.make(driver), | ||
| 261 | enabled: true, | ||
| 262 | installed: true, | ||
| 263 | version: "1.0.0", | ||
| 264 | status: "ready", | ||
| 265 | auth: { status: "authenticated" }, | ||
| 266 | checkedAt: "2026-05-04T10:00:00.000Z", | ||
| 267 | models: [], | ||
| 268 | slashCommands: [], | ||
| 269 | skills: [], | ||
| 270 | versionAdvisory: { | ||
| 271 | status: "behind_latest", | ||
| 272 | currentVersion: "1.0.0", | ||
| 273 | latestVersion: "1.1.0", | ||
| 274 | message: "Update available.", | ||
| 275 | checkedAt: "2026-05-04T10:00:00.000Z", | ||
| 276 | updateCommand, | ||
| 277 | canUpdate: true, | ||
| 278 | }, | ||
| 279 | }; | ||
| 280 | } | ||
| 281 | |||
| 282 | function makeUtc(value: string) { | ||
| 283 | return DateTime.makeUnsafe(value); | ||
| 284 | } | ||
| 285 | |||
| 286 | function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistoryResult { | ||
| 287 | return { | ||
| 288 | readAt: makeUtc("2036-04-07T00:00:00.000Z"), | ||
| 289 | windowMs: 15 * 60_000, | ||
| 290 | bucketMs: 60_000, | ||
| 291 | sampleIntervalMs: 5_000, | ||
| 292 | retainedSampleCount: 0, | ||
| 293 | totalCpuSecondsApprox: 0, | ||
| 294 | buckets: [], | ||
| 295 | topProcesses: [], | ||
| 296 | error: Option.none(), | ||
| 297 | }; | ||
| 298 | } | ||
| 299 | |||
| 300 | function makePairingLink(input: { | ||
| 301 | readonly id: string; | ||
| 302 | readonly credential: string; | ||
| 303 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | ||
| 304 | readonly subject: string; | ||
| 305 | readonly label?: string; | ||
| 306 | readonly createdAt: string; | ||
| 307 | readonly expiresAt: string; | ||
| 308 | }): AuthAccessSnapshot["pairingLinks"][number] { | ||
| 309 | return { | ||
| 310 | ...input, | ||
| 311 | createdAt: makeUtc(input.createdAt), | ||
| 312 | expiresAt: makeUtc(input.expiresAt), | ||
| 313 | }; | ||
| 314 | } | ||
| 315 | |||
| 316 | function makeClientSession(input: { | ||
| 317 | readonly sessionId: string; | ||
| 318 | readonly subject: string; | ||
| 319 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | ||
| 320 | readonly method: "browser-session-cookie"; | ||
| 321 | readonly client?: { | ||
| 322 | readonly label?: string; | ||
| 323 | readonly ipAddress?: string; | ||
| 324 | readonly userAgent?: string; | ||
| 325 | readonly deviceType?: "desktop" | "mobile" | "tablet" | "bot" | "unknown"; | ||
| 326 | readonly os?: string; | ||
| 327 | readonly browser?: string; | ||
| 328 | }; | ||
| 329 | readonly issuedAt: string; | ||
| 330 | readonly expiresAt: string; | ||
| 331 | readonly lastConnectedAt?: string | null; | ||
| 332 | readonly connected: boolean; | ||
| 333 | readonly current: boolean; | ||
| 334 | }): AuthAccessSnapshot["clientSessions"][number] { | ||
| 335 | return { | ||
| 336 | ...input, | ||
| 337 | client: { | ||
| 338 | deviceType: "unknown", | ||
| 339 | ...input.client, | ||
| 340 | }, | ||
| 341 | sessionId: AuthSessionId.make(input.sessionId), | ||
| 342 | issuedAt: makeUtc(input.issuedAt), | ||
| 343 | expiresAt: makeUtc(input.expiresAt), | ||
| 344 | lastConnectedAt: | ||
| 345 | input.lastConnectedAt === undefined || input.lastConnectedAt === null | ||
| 346 | ? null | ||
| 347 | : makeUtc(input.lastConnectedAt), | ||
| 348 | }; | ||
| 349 | } | ||
| 350 | |||
| 351 | const createDesktopBridgeStub = (overrides?: { | ||
| 352 | readonly discoverSshHosts?: DesktopBridge["discoverSshHosts"]; | ||
| 353 | readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>; | ||
| 354 | readonly advertisedEndpoints?: Awaited<ReturnType<DesktopBridge["getAdvertisedEndpoints"]>>; | ||
| 355 | readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; | ||
| 356 | readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"]; | ||
| 357 | }): DesktopBridge => { | ||
| 358 | const idleUpdateState: DesktopUpdateState = { | ||
| 359 | enabled: false, | ||
| 360 | status: "idle", | ||
| 361 | channel: "latest", | ||
| 362 | currentVersion: "0.0.0-test", | ||
| 363 | hostArch: "arm64", | ||
| 364 | appArch: "arm64", | ||
| 365 | runningUnderArm64Translation: false, | ||
| 366 | availableVersion: null, | ||
| 367 | downloadedVersion: null, | ||
| 368 | downloadPercent: null, | ||
| 369 | checkedAt: null, | ||
| 370 | message: null, | ||
| 371 | errorContext: null, | ||
| 372 | canRetry: false, | ||
| 373 | }; | ||
| 374 | |||
| 375 | return { | ||
| 376 | getAppBranding: vi.fn().mockReturnValue(null), | ||
| 377 | getLocalEnvironmentBootstrap: () => ({ | ||
| 378 | label: "Local environment", | ||
| 379 | httpBaseUrl: "http://127.0.0.1:3773", | ||
| 380 | wsBaseUrl: "ws://127.0.0.1:3773", | ||
| 381 | bootstrapToken: "desktop-bootstrap-token", | ||
| 382 | }), | ||
| 383 | getClientSettings: vi.fn().mockResolvedValue(null), | ||
| 384 | setClientSettings: vi.fn().mockResolvedValue(undefined), | ||
| 385 | getSavedEnvironmentRegistry: vi.fn().mockResolvedValue([]), | ||
| 386 | setSavedEnvironmentRegistry: vi.fn().mockResolvedValue(undefined), | ||
| 387 | getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null), | ||
| 388 | setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true), | ||
| 389 | removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined), | ||
| 390 | discoverSshHosts: overrides?.discoverSshHosts ?? vi.fn().mockResolvedValue([]), | ||
| 391 | ensureSshEnvironment: vi.fn().mockImplementation(async (target) => ({ | ||
| 392 | target, | ||
| 393 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 394 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 395 | pairingToken: "ssh-pairing-token", | ||
| 396 | })), | ||
| 397 | disconnectSshEnvironment: vi.fn().mockResolvedValue(undefined), | ||
| 398 | fetchSshEnvironmentDescriptor: vi.fn().mockResolvedValue({ | ||
| 399 | environmentId: "environment-ssh", | ||
| 400 | label: "SSH environment", | ||
| 401 | platform: { | ||
| 402 | os: "linux", | ||
| 403 | arch: "x64", | ||
| 404 | }, | ||
| 405 | serverVersion: "0.0.0-test", | ||
| 406 | capabilities: { | ||
| 407 | repositoryIdentity: true, | ||
| 408 | }, | ||
| 409 | }), | ||
| 410 | bootstrapSshBearerSession: vi.fn().mockResolvedValue({ | ||
| 411 | access_token: "ssh-bearer-token", | ||
| 412 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 413 | token_type: "Bearer", | ||
| 414 | expires_in: 3_600, | ||
| 415 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 416 | }), | ||
| 417 | fetchSshSessionState: vi.fn().mockResolvedValue({ | ||
| 418 | authenticated: true, | ||
| 419 | auth: { | ||
| 420 | policy: "remote-reachable", | ||
| 421 | bootstrapMethods: ["one-time-token"], | ||
| 422 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | ||
| 423 | sessionCookieName: "t3_session", | ||
| 424 | }, | ||
| 425 | scopes: ["orchestration:read", "access:write"], | ||
| 426 | sessionMethod: "bearer-access-token", | ||
| 427 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 428 | }), | ||
| 429 | issueSshWebSocketTicket: vi.fn().mockResolvedValue({ | ||
| 430 | ticket: "ssh-ws-ticket", | ||
| 431 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 432 | }), | ||
| 433 | onSshPasswordPrompt: vi.fn(() => () => {}), | ||
| 434 | resolveSshPasswordPrompt: vi.fn().mockResolvedValue(undefined), | ||
| 435 | getServerExposureState: vi.fn().mockResolvedValue( | ||
| 436 | overrides?.serverExposureState ?? { | ||
| 437 | mode: "local-only", | ||
| 438 | endpointUrl: null, | ||
| 439 | advertisedHost: null, | ||
| 440 | tailscaleServeEnabled: false, | ||
| 441 | tailscaleServePort: 443, | ||
| 442 | }, | ||
| 443 | ), | ||
| 444 | setServerExposureMode: | ||
| 445 | overrides?.setServerExposureMode ?? | ||
| 446 | vi.fn().mockImplementation(async (mode) => ({ | ||
| 447 | mode, | ||
| 448 | endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null, | ||
| 449 | advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null, | ||
| 450 | tailscaleServeEnabled: false, | ||
| 451 | tailscaleServePort: 443, | ||
| 452 | })), | ||
| 453 | setTailscaleServeEnabled: vi.fn().mockImplementation(async (input) => ({ | ||
| 454 | mode: overrides?.serverExposureState?.mode ?? "network-accessible", | ||
| 455 | endpointUrl: overrides?.serverExposureState?.endpointUrl ?? "http://192.168.1.44:3773", | ||
| 456 | advertisedHost: overrides?.serverExposureState?.advertisedHost ?? "192.168.1.44", | ||
| 457 | tailscaleServeEnabled: input.enabled, | ||
| 458 | tailscaleServePort: input.port ?? 443, | ||
| 459 | })), | ||
| 460 | getAdvertisedEndpoints: vi.fn().mockResolvedValue(overrides?.advertisedEndpoints ?? []), | ||
| 461 | pickFolder: vi.fn().mockResolvedValue(null), | ||
| 462 | confirm: vi.fn().mockResolvedValue(false), | ||
| 463 | setTheme: vi.fn().mockResolvedValue(undefined), | ||
| 464 | showContextMenu: vi.fn().mockResolvedValue(null), | ||
| 465 | openExternal: vi.fn().mockResolvedValue(true), | ||
| 466 | createCloudAuthRequest: vi.fn().mockResolvedValue("t3code-dev://auth/callback?t3_state=test"), | ||
| 467 | getCloudAuthToken: vi.fn().mockResolvedValue(null), | ||
| 468 | setCloudAuthToken: vi.fn().mockResolvedValue(true), | ||
| 469 | clearCloudAuthToken: vi.fn().mockResolvedValue(undefined), | ||
| 470 | fetchCloudAuth: vi.fn().mockResolvedValue({ | ||
| 471 | ok: true, | ||
| 472 | status: 200, | ||
| 473 | statusText: "OK", | ||
| 474 | headers: {}, | ||
| 475 | body: "", | ||
| 476 | }), | ||
| 477 | onCloudAuthCallback: () => () => {}, | ||
| 478 | onMenuAction: () => () => {}, | ||
| 479 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), | ||
| 480 | setUpdateChannel: | ||
| 481 | overrides?.setUpdateChannel ?? | ||
| 482 | vi.fn().mockImplementation(async (channel: DesktopUpdateChannel) => ({ | ||
| 483 | ...idleUpdateState, | ||
| 484 | channel, | ||
| 485 | })), | ||
| 486 | checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }), | ||
| 487 | downloadUpdate: vi | ||
| 488 | .fn() | ||
| 489 | .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), | ||
| 490 | installUpdate: vi | ||
| 491 | .fn() | ||
| 492 | .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), | ||
| 493 | onUpdateState: () => () => {}, | ||
| 494 | }; | ||
| 495 | }; | ||
| 496 | |||
| 497 | describe("GeneralSettingsPanel observability", () => { | ||
| 498 | let mounted: | ||
| 499 | | (Awaited<ReturnType<typeof render>> & { | ||
| 500 | cleanup?: () => Promise<void>; | ||
| 501 | unmount?: () => Promise<void>; | ||
| 502 | }) | ||
| 503 | | null = null; | ||
| 504 | |||
| 505 | beforeEach(async () => { | ||
| 506 | resetServerStateForTests(); | ||
| 507 | await __resetLocalApiForTests(); | ||
| 508 | localStorage.clear(); | ||
| 509 | useUiStateStore.setState({ defaultAdvertisedEndpointKey: null }); | ||
| 510 | authAccessHarness.reset(); | ||
| 511 | mockConnectDesktopSshEnvironment.mockReset(); | ||
| 512 | }); | ||
| 513 | |||
| 514 | afterEach(async () => { | ||
| 515 | if (mounted) { | ||
| 516 | const teardown = mounted.cleanup ?? mounted.unmount; | ||
| 517 | await teardown?.call(mounted).catch(() => {}); | ||
| 518 | } | ||
| 519 | mounted = null; | ||
| 520 | vi.unstubAllGlobals(); | ||
| 521 | Reflect.deleteProperty(window, "desktopBridge"); | ||
| 522 | Reflect.deleteProperty(window, "nativeApi"); | ||
| 523 | document.body.innerHTML = ""; | ||
| 524 | resetServerStateForTests(); | ||
| 525 | await __resetLocalApiForTests(); | ||
| 526 | authAccessHarness.reset(); | ||
| 527 | }); | ||
| 528 | |||
| 529 | it("hides owner pairing tools in browser-served loopback builds without remote exposure", async () => { | ||
| 530 | Reflect.deleteProperty(window, "desktopBridge"); | ||
| 531 | authAccessHarness.setSnapshot({ | ||
| 532 | pairingLinks: [], | ||
| 533 | clientSessions: [ | ||
| 534 | makeClientSession({ | ||
| 535 | sessionId: "session-owner", | ||
| 536 | subject: "browser-owner", | ||
| 537 | scopes: ["orchestration:read", "access:write"], | ||
| 538 | method: "browser-session-cookie", | ||
| 539 | client: { | ||
| 540 | label: "Chrome on Mac", | ||
| 541 | deviceType: "desktop", | ||
| 542 | os: "macOS", | ||
| 543 | browser: "Chrome", | ||
| 544 | ipAddress: "127.0.0.1", | ||
| 545 | }, | ||
| 546 | issuedAt: "2036-04-07T00:00:00.000Z", | ||
| 547 | expiresAt: "2036-05-07T00:00:00.000Z", | ||
| 548 | connected: true, | ||
| 549 | current: true, | ||
| 550 | }), | ||
| 551 | ], | ||
| 552 | }); | ||
| 553 | const fetchMock = vi.fn<typeof fetch>().mockImplementation(async (input) => { | ||
| 554 | const url = String(input); | ||
| 555 | if (url.endsWith("/api/auth/session")) { | ||
| 556 | return new Response( | ||
| 557 | JSON.stringify({ | ||
| 558 | authenticated: true, | ||
| 559 | auth: createBaseServerConfig().auth, | ||
| 560 | scopes: ["orchestration:read", "access:write"], | ||
| 561 | sessionMethod: "browser-session-cookie", | ||
| 562 | expiresAt: "2036-05-07T00:00:00.000Z", | ||
| 563 | }), | ||
| 564 | { | ||
| 565 | status: 200, | ||
| 566 | headers: { "content-type": "application/json" }, | ||
| 567 | }, | ||
| 568 | ); | ||
| 569 | } | ||
| 570 | |||
| 571 | throw new Error(`Unhandled fetch GET ${url}`); | ||
| 572 | }); | ||
| 573 | vi.stubGlobal("fetch", fetchMock); | ||
| 574 | |||
| 575 | mounted = await render( | ||
| 576 | <AppAtomRegistryProvider> | ||
| 577 | <ConnectionsSettings /> | ||
| 578 | </AppAtomRegistryProvider>, | ||
| 579 | ); | ||
| 580 | |||
| 581 | await expect | ||
| 582 | .element(page.getByRole("heading", { name: "This environment", exact: true })) | ||
| 583 | .toBeInTheDocument(); | ||
| 584 | await expect.element(page.getByLabelText("Enable network access")).toBeDisabled(); | ||
| 585 | await expect | ||
| 586 | .element( | ||
| 587 | page.getByText( | ||
| 588 | "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing.", | ||
| 589 | ), | ||
| 590 | ) | ||
| 591 | .toBeInTheDocument(); | ||
| 592 | await expect.element(page.getByText("Authorized clients")).not.toBeInTheDocument(); | ||
| 593 | await expect.element(page.getByText("Chrome on Mac")).not.toBeInTheDocument(); | ||
| 594 | await expect | ||
| 595 | .element(page.getByRole("heading", { name: "Remote environments", exact: true })) | ||
| 596 | .toBeInTheDocument(); | ||
| 597 | }); | ||
| 598 | |||
| 599 | it("hides advertised endpoint rows when desktop network access is disabled", async () => { | ||
| 600 | window.desktopBridge = createDesktopBridgeStub({ | ||
| 601 | serverExposureState: { | ||
| 602 | mode: "local-only", | ||
| 603 | endpointUrl: null, | ||
| 604 | advertisedHost: null, | ||
| 605 | tailscaleServeEnabled: false, | ||
| 606 | tailscaleServePort: 443, | ||
| 607 | }, | ||
| 608 | advertisedEndpoints: [ | ||
| 609 | { | ||
| 610 | id: "loopback", | ||
| 611 | label: "This machine", | ||
| 612 | provider: { | ||
| 613 | id: "desktop-core", | ||
| 614 | label: "Desktop", | ||
| 615 | kind: "manual", | ||
| 616 | isAddon: false, | ||
| 617 | }, | ||
| 618 | httpBaseUrl: "http://127.0.0.1:3773/", | ||
| 619 | wsBaseUrl: "ws://127.0.0.1:3773/", | ||
| 620 | reachability: "loopback", | ||
| 621 | compatibility: { | ||
| 622 | hostedHttpsApp: "mixed-content-blocked", | ||
| 623 | desktopApp: "compatible", | ||
| 624 | }, | ||
| 625 | source: "desktop-core", | ||
| 626 | status: "available", | ||
| 627 | isDefault: true, | ||
| 628 | }, | ||
| 629 | { | ||
| 630 | id: "tailscale-ip", | ||
| 631 | label: "Tailscale IP", | ||
| 632 | provider: { | ||
| 633 | id: "tailscale", | ||
| 634 | label: "Tailscale", | ||
| 635 | kind: "private-network", | ||
| 636 | isAddon: true, | ||
| 637 | }, | ||
| 638 | httpBaseUrl: "http://100.105.39.17:3773/", | ||
| 639 | wsBaseUrl: "ws://100.105.39.17:3773/", | ||
| 640 | reachability: "private-network", | ||
| 641 | compatibility: { | ||
| 642 | hostedHttpsApp: "mixed-content-blocked", | ||
| 643 | desktopApp: "compatible", | ||
| 644 | }, | ||
| 645 | source: "desktop-addon", | ||
| 646 | status: "available", | ||
| 647 | }, | ||
| 648 | ], | ||
| 649 | }); | ||
| 650 | authAccessHarness.setSnapshot({ | ||
| 651 | pairingLinks: [], | ||
| 652 | clientSessions: [], | ||
| 653 | }); | ||
| 654 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 655 | |||
| 656 | mounted = await render( | ||
| 657 | <AppAtomRegistryProvider> | ||
| 658 | <ConnectionsSettings /> | ||
| 659 | </AppAtomRegistryProvider>, | ||
| 660 | ); | ||
| 661 | |||
| 662 | await expect.element(page.getByText("Limited to this machine.")).toBeInTheDocument(); | ||
| 663 | await expect | ||
| 664 | .element(page.getByRole("heading", { name: "This machine", exact: true })) | ||
| 665 | .not.toBeInTheDocument(); | ||
| 666 | await expect | ||
| 667 | .element(page.getByRole("heading", { name: "Tailscale IP", exact: true })) | ||
| 668 | .not.toBeInTheDocument(); | ||
| 669 | }); | ||
| 670 | |||
| 671 | it("collapses advertised endpoints behind the network access summary", async () => { | ||
| 672 | window.desktopBridge = createDesktopBridgeStub({ | ||
| 673 | serverExposureState: { | ||
| 674 | mode: "network-accessible", | ||
| 675 | endpointUrl: "http://192.168.86.39:3773", | ||
| 676 | advertisedHost: "192.168.86.39", | ||
| 677 | tailscaleServeEnabled: false, | ||
| 678 | tailscaleServePort: 443, | ||
| 679 | }, | ||
| 680 | advertisedEndpoints: [ | ||
| 681 | { | ||
| 682 | id: "desktop-loopback:3773", | ||
| 683 | label: "This machine", | ||
| 684 | provider: { | ||
| 685 | id: "desktop-core", | ||
| 686 | label: "Desktop", | ||
| 687 | kind: "manual", | ||
| 688 | isAddon: false, | ||
| 689 | }, | ||
| 690 | httpBaseUrl: "http://127.0.0.1:3773/", | ||
| 691 | wsBaseUrl: "ws://127.0.0.1:3773/", | ||
| 692 | reachability: "loopback", | ||
| 693 | compatibility: { | ||
| 694 | hostedHttpsApp: "mixed-content-blocked", | ||
| 695 | desktopApp: "compatible", | ||
| 696 | }, | ||
| 697 | source: "desktop-core", | ||
| 698 | status: "available", | ||
| 699 | }, | ||
| 700 | { | ||
| 701 | id: "desktop-lan:http://192.168.86.39:3773", | ||
| 702 | label: "Local network", | ||
| 703 | provider: { | ||
| 704 | id: "desktop-core", | ||
| 705 | label: "Desktop", | ||
| 706 | kind: "manual", | ||
| 707 | isAddon: false, | ||
| 708 | }, | ||
| 709 | httpBaseUrl: "http://192.168.86.39:3773/", | ||
| 710 | wsBaseUrl: "ws://192.168.86.39:3773/", | ||
| 711 | reachability: "lan", | ||
| 712 | compatibility: { | ||
| 713 | hostedHttpsApp: "mixed-content-blocked", | ||
| 714 | desktopApp: "compatible", | ||
| 715 | }, | ||
| 716 | source: "desktop-core", | ||
| 717 | status: "available", | ||
| 718 | isDefault: true, | ||
| 719 | }, | ||
| 720 | { | ||
| 721 | id: "tailscale-ip:http://100.105.39.17:3773", | ||
| 722 | label: "Tailscale IP", | ||
| 723 | provider: { | ||
| 724 | id: "tailscale", | ||
| 725 | label: "Tailscale", | ||
| 726 | kind: "private-network", | ||
| 727 | isAddon: true, | ||
| 728 | }, | ||
| 729 | httpBaseUrl: "http://100.105.39.17:3773/", | ||
| 730 | wsBaseUrl: "ws://100.105.39.17:3773/", | ||
| 731 | reachability: "private-network", | ||
| 732 | compatibility: { | ||
| 733 | hostedHttpsApp: "mixed-content-blocked", | ||
| 734 | desktopApp: "compatible", | ||
| 735 | }, | ||
| 736 | source: "desktop-addon", | ||
| 737 | status: "available", | ||
| 738 | }, | ||
| 739 | ], | ||
| 740 | }); | ||
| 741 | authAccessHarness.setSnapshot({ | ||
| 742 | pairingLinks: [], | ||
| 743 | clientSessions: [], | ||
| 744 | }); | ||
| 745 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 746 | |||
| 747 | mounted = await render( | ||
| 748 | <AppAtomRegistryProvider> | ||
| 749 | <ConnectionsSettings /> | ||
| 750 | </AppAtomRegistryProvider>, | ||
| 751 | ); | ||
| 752 | |||
| 753 | await expect.element(page.getByText("http://192.168.86.39:3773/")).toBeInTheDocument(); | ||
| 754 | await expect.element(page.getByRole("button", { name: "+2" })).toBeInTheDocument(); | ||
| 755 | await expect | ||
| 756 | .element(page.getByRole("heading", { name: "Local network", exact: true })) | ||
| 757 | .not.toBeInTheDocument(); | ||
| 758 | |||
| 759 | await page.getByRole("button", { name: "+2" }).click(); | ||
| 760 | |||
| 761 | await expect | ||
| 762 | .element(page.getByRole("heading", { name: "Local network", exact: true })) | ||
| 763 | .toBeInTheDocument(); | ||
| 764 | await expect.element(page.getByText("Default", { exact: true })).toBeInTheDocument(); | ||
| 765 | await page.getByRole("button", { name: "Set as default" }).first().click(); | ||
| 766 | await expect.element(page.getByText("http://127.0.0.1:3773/").first()).toBeInTheDocument(); | ||
| 767 | }); | ||
| 768 | |||
| 769 | it("shows diagnostics inside About with a diagnostics link", async () => { | ||
| 770 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 771 | |||
| 772 | mounted = await renderWithTestRouter( | ||
| 773 | <AppAtomRegistryProvider> | ||
| 774 | <GeneralSettingsPanel /> | ||
| 775 | </AppAtomRegistryProvider>, | ||
| 776 | ); | ||
| 777 | |||
| 778 | await expect.element(page.getByText("About")).toBeInTheDocument(); | ||
| 779 | await expect | ||
| 780 | .element(page.getByRole("heading", { name: "Diagnostics", exact: true })) | ||
| 781 | .toBeInTheDocument(); | ||
| 782 | await expect.element(page.getByRole("link", { name: "View diagnostics" })).toBeInTheDocument(); | ||
| 783 | await expect | ||
| 784 | .element( | ||
| 785 | page.getByText( | ||
| 786 | "Local trace file. Exporting OTEL traces to http://localhost:4318/v1/traces.", | ||
| 787 | ), | ||
| 788 | ) | ||
| 789 | .toBeInTheDocument(); | ||
| 790 | }); | ||
| 791 | |||
| 792 | it("creates and shows a pairing link when network access is enabled", async () => { | ||
| 793 | window.desktopBridge = createDesktopBridgeStub({ | ||
| 794 | serverExposureState: { | ||
| 795 | mode: "network-accessible", | ||
| 796 | endpointUrl: "http://192.168.1.44:3773", | ||
| 797 | advertisedHost: "192.168.1.44", | ||
| 798 | tailscaleServeEnabled: false, | ||
| 799 | tailscaleServePort: 443, | ||
| 800 | }, | ||
| 801 | }); | ||
| 802 | let pairingLinks: Array<AuthAccessSnapshot["pairingLinks"][number]> = []; | ||
| 803 | let clientSessions: Array<AuthAccessSnapshot["clientSessions"][number]> = [ | ||
| 804 | makeClientSession({ | ||
| 805 | sessionId: "session-owner", | ||
| 806 | subject: "desktop-bootstrap", | ||
| 807 | scopes: ["orchestration:read", "access:write"], | ||
| 808 | method: "browser-session-cookie", | ||
| 809 | client: { | ||
| 810 | label: "This Mac", | ||
| 811 | deviceType: "desktop", | ||
| 812 | os: "macOS", | ||
| 813 | browser: "Electron", | ||
| 814 | ipAddress: "127.0.0.1", | ||
| 815 | }, | ||
| 816 | issuedAt: "2036-04-07T00:00:00.000Z", | ||
| 817 | expiresAt: "2036-05-07T00:00:00.000Z", | ||
| 818 | connected: true, | ||
| 819 | current: true, | ||
| 820 | }), | ||
| 821 | ]; | ||
| 822 | authAccessHarness.setSnapshot({ | ||
| 823 | pairingLinks, | ||
| 824 | clientSessions, | ||
| 825 | }); | ||
| 826 | vi.stubGlobal( | ||
| 827 | "fetch", | ||
| 828 | vi.fn<typeof fetch>().mockImplementation(async (input, init) => { | ||
| 829 | const url = String(input); | ||
| 830 | const method = init?.method ?? "GET"; | ||
| 831 | if (url.endsWith("/api/auth/pairing-token") && method === "POST") { | ||
| 832 | pairingLinks = [ | ||
| 833 | makePairingLink({ | ||
| 834 | id: "pairing-link-1", | ||
| 835 | credential: "pairing-token", | ||
| 836 | scopes: ["orchestration:read"], | ||
| 837 | subject: "one-time-token", | ||
| 838 | label: "Julius iPhone", | ||
| 839 | createdAt: "2036-04-07T00:00:00.000Z", | ||
| 840 | expiresAt: "2036-04-10T00:05:00.000Z", | ||
| 841 | }), | ||
| 842 | ]; | ||
| 843 | clientSessions = [ | ||
| 844 | ...clientSessions, | ||
| 845 | makeClientSession({ | ||
| 846 | sessionId: "session-client", | ||
| 847 | subject: "one-time-token", | ||
| 848 | scopes: ["orchestration:read"], | ||
| 849 | method: "browser-session-cookie", | ||
| 850 | client: { | ||
| 851 | label: "Julius iPhone", | ||
| 852 | deviceType: "mobile", | ||
| 853 | os: "iOS", | ||
| 854 | browser: "Safari", | ||
| 855 | ipAddress: "192.168.1.88", | ||
| 856 | }, | ||
| 857 | issuedAt: "2036-04-07T00:01:00.000Z", | ||
| 858 | expiresAt: "2036-05-07T00:01:00.000Z", | ||
| 859 | connected: false, | ||
| 860 | current: false, | ||
| 861 | }), | ||
| 862 | ]; | ||
| 863 | authAccessHarness.setSnapshot({ | ||
| 864 | pairingLinks, | ||
| 865 | clientSessions, | ||
| 866 | }); | ||
| 867 | return new Response( | ||
| 868 | JSON.stringify({ | ||
| 869 | id: "pairing-link-1", | ||
| 870 | credential: "pairing-token", | ||
| 871 | label: "Julius iPhone", | ||
| 872 | expiresAt: "2036-04-10T00:05:00.000Z", | ||
| 873 | }), | ||
| 874 | { | ||
| 875 | status: 200, | ||
| 876 | headers: { "content-type": "application/json" }, | ||
| 877 | }, | ||
| 878 | ); | ||
| 879 | } | ||
| 880 | |||
| 881 | throw new Error(`Unhandled fetch ${method} ${url}`); | ||
| 882 | }), | ||
| 883 | ); | ||
| 884 | |||
| 885 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 886 | |||
| 887 | mounted = await render( | ||
| 888 | <AppAtomRegistryProvider> | ||
| 889 | <ConnectionsSettings /> | ||
| 890 | </AppAtomRegistryProvider>, | ||
| 891 | ); | ||
| 892 | |||
| 893 | await expect.element(page.getByText("Authorized clients")).toBeInTheDocument(); | ||
| 894 | await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); | ||
| 895 | await expect.element(page.getByText("This Mac")).toBeInTheDocument(); | ||
| 896 | await page.getByRole("button", { name: "Create link", exact: true }).click(); | ||
| 897 | await expect.element(page.getByText("Create pairing link")).toBeInTheDocument(); | ||
| 898 | await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked(); | ||
| 899 | await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).toBeChecked(); | ||
| 900 | await page.getByRole("button", { name: "Read only", exact: true }).click(); | ||
| 901 | await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked(); | ||
| 902 | await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).not.toBeChecked(); | ||
| 903 | await page.getByRole("button", { name: "Create link", exact: true }).click(); | ||
| 904 | authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!); | ||
| 905 | authAccessHarness.emitClientUpserted(clientSessions[1]!); | ||
| 906 | await expect | ||
| 907 | .element(page.getByRole("button", { name: "Pairing link scopes: show 1 scope" })) | ||
| 908 | .toBeInTheDocument(); | ||
| 909 | await expect | ||
| 910 | .element(page.getByText("Mobile · iOS · Safari · 192.168.1.88")) | ||
| 911 | .toBeInTheDocument(); | ||
| 912 | await page.getByRole("button", { name: "Client scopes: show 1 scope" }).click(); | ||
| 913 | await expect.element(page.getByText("orchestration:read", { exact: true })).toBeInTheDocument(); | ||
| 914 | await expect | ||
| 915 | .element(page.getByRole("button", { name: /^Copy pairing URL for:/ })) | ||
| 916 | .toBeInTheDocument(); | ||
| 917 | await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); | ||
| 918 | }); | ||
| 919 | |||
| 920 | it("keeps authorized clients within a five-row fading scroll area", async () => { | ||
| 921 | window.desktopBridge = createDesktopBridgeStub({ | ||
| 922 | serverExposureState: { | ||
| 923 | mode: "network-accessible", | ||
| 924 | endpointUrl: "http://192.168.1.44:3773", | ||
| 925 | advertisedHost: "192.168.1.44", | ||
| 926 | tailscaleServeEnabled: false, | ||
| 927 | tailscaleServePort: 443, | ||
| 928 | }, | ||
| 929 | }); | ||
| 930 | authAccessHarness.setSnapshot({ | ||
| 931 | pairingLinks: [], | ||
| 932 | clientSessions: Array.from({ length: 7 }, (_, index) => | ||
| 933 | makeClientSession({ | ||
| 934 | sessionId: `session-client-${index}`, | ||
| 935 | subject: `client-${index}`, | ||
| 936 | scopes: ["orchestration:read"], | ||
| 937 | method: "browser-session-cookie", | ||
| 938 | client: { | ||
| 939 | label: `Client ${index + 1}`, | ||
| 940 | deviceType: "desktop", | ||
| 941 | os: "macOS", | ||
| 942 | browser: "Electron", | ||
| 943 | ipAddress: `192.168.1.${index + 10}`, | ||
| 944 | }, | ||
| 945 | issuedAt: "2036-04-07T00:00:00.000Z", | ||
| 946 | expiresAt: "2036-05-07T00:00:00.000Z", | ||
| 947 | connected: index === 0, | ||
| 948 | current: index === 0, | ||
| 949 | }), | ||
| 950 | ), | ||
| 951 | }); | ||
| 952 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 953 | |||
| 954 | mounted = await render( | ||
| 955 | <AppAtomRegistryProvider> | ||
| 956 | <ConnectionsSettings /> | ||
| 957 | </AppAtomRegistryProvider>, | ||
| 958 | ); | ||
| 959 | |||
| 960 | await expect.element(page.getByText("Client 7")).toBeInTheDocument(); | ||
| 961 | const scrollArea = document.querySelector<HTMLElement>( | ||
| 962 | '[data-testid="authorized-clients-scroll-area"]', | ||
| 963 | ); | ||
| 964 | const viewport = scrollArea?.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]'); | ||
| 965 | |||
| 966 | expect(scrollArea).not.toBeNull(); | ||
| 967 | expect(viewport).not.toBeNull(); | ||
| 968 | expect(scrollArea?.clientHeight).toBe(360); | ||
| 969 | expect(viewport?.scrollHeight).toBeGreaterThan(viewport?.clientHeight ?? 0); | ||
| 970 | expect(viewport?.className).toContain("mask-b-from"); | ||
| 971 | }); | ||
| 972 | |||
| 973 | it("revokes all other paired clients from settings", async () => { | ||
| 974 | window.desktopBridge = createDesktopBridgeStub({ | ||
| 975 | serverExposureState: { | ||
| 976 | mode: "network-accessible", | ||
| 977 | endpointUrl: "http://192.168.1.44:3773", | ||
| 978 | advertisedHost: "192.168.1.44", | ||
| 979 | tailscaleServeEnabled: false, | ||
| 980 | tailscaleServePort: 443, | ||
| 981 | }, | ||
| 982 | }); | ||
| 983 | let clientSessions: Array<AuthAccessSnapshot["clientSessions"][number]> = [ | ||
| 984 | makeClientSession({ | ||
| 985 | sessionId: "session-owner", | ||
| 986 | subject: "desktop-bootstrap", | ||
| 987 | scopes: ["orchestration:read", "access:write"], | ||
| 988 | method: "browser-session-cookie", | ||
| 989 | client: { | ||
| 990 | label: "This Mac", | ||
| 991 | deviceType: "desktop", | ||
| 992 | os: "macOS", | ||
| 993 | browser: "Electron", | ||
| 994 | }, | ||
| 995 | issuedAt: "2036-04-05T00:00:00.000Z", | ||
| 996 | expiresAt: "2036-05-05T00:00:00.000Z", | ||
| 997 | connected: true, | ||
| 998 | current: true, | ||
| 999 | }), | ||
| 1000 | makeClientSession({ | ||
| 1001 | sessionId: "session-client", | ||
| 1002 | subject: "one-time-token", | ||
| 1003 | scopes: ["orchestration:read"], | ||
| 1004 | method: "browser-session-cookie", | ||
| 1005 | client: { | ||
| 1006 | label: "Julius iPhone", | ||
| 1007 | deviceType: "mobile", | ||
| 1008 | os: "iOS", | ||
| 1009 | browser: "Safari", | ||
| 1010 | ipAddress: "192.168.1.88", | ||
| 1011 | }, | ||
| 1012 | issuedAt: "2036-04-05T00:01:00.000Z", | ||
| 1013 | expiresAt: "2036-05-05T00:01:00.000Z", | ||
| 1014 | connected: false, | ||
| 1015 | current: false, | ||
| 1016 | }), | ||
| 1017 | ]; | ||
| 1018 | authAccessHarness.setSnapshot({ | ||
| 1019 | pairingLinks: [], | ||
| 1020 | clientSessions, | ||
| 1021 | }); | ||
| 1022 | |||
| 1023 | const fetchMock = vi.fn<typeof fetch>().mockImplementation(async (input, init) => { | ||
| 1024 | const url = String(input); | ||
| 1025 | const method = init?.method ?? "GET"; | ||
| 1026 | if (url.endsWith("/api/auth/clients/revoke-others") && method === "POST") { | ||
| 1027 | clientSessions = clientSessions.filter((session) => session.current); | ||
| 1028 | authAccessHarness.setSnapshot({ | ||
| 1029 | pairingLinks: [], | ||
| 1030 | clientSessions, | ||
| 1031 | }); | ||
| 1032 | authAccessHarness.emitClientRemoved("session-client"); | ||
| 1033 | return new Response(JSON.stringify({ revokedCount: 1 }), { | ||
| 1034 | status: 200, | ||
| 1035 | headers: { "content-type": "application/json" }, | ||
| 1036 | }); | ||
| 1037 | } | ||
| 1038 | |||
| 1039 | throw new Error(`Unhandled fetch ${method} ${url}`); | ||
| 1040 | }); | ||
| 1041 | vi.stubGlobal("fetch", fetchMock); | ||
| 1042 | |||
| 1043 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 1044 | |||
| 1045 | mounted = await render( | ||
| 1046 | <AppAtomRegistryProvider> | ||
| 1047 | <ConnectionsSettings /> | ||
| 1048 | </AppAtomRegistryProvider>, | ||
| 1049 | ); | ||
| 1050 | |||
| 1051 | await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument(); | ||
| 1052 | await page.getByRole("button", { name: "Revoke others", exact: true }).click(); | ||
| 1053 | await expect.element(page.getByText("This Mac")).toBeInTheDocument(); | ||
| 1054 | await expect.element(page.getByText("Julius iPhone")).not.toBeInTheDocument(); | ||
| 1055 | expect(fetchMock).toHaveBeenCalled(); | ||
| 1056 | }); | ||
| 1057 | |||
| 1058 | it("shows a disabled network access toggle with guidance in desktop builds", async () => { | ||
| 1059 | const desktopBridge = createDesktopBridgeStub(); | ||
| 1060 | window.desktopBridge = desktopBridge; | ||
| 1061 | |||
| 1062 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 1063 | |||
| 1064 | mounted = await render( | ||
| 1065 | <AppAtomRegistryProvider> | ||
| 1066 | <ConnectionsSettings /> | ||
| 1067 | </AppAtomRegistryProvider>, | ||
| 1068 | ); | ||
| 1069 | |||
| 1070 | const networkAccessToggle = page.getByLabelText("Enable network access"); | ||
| 1071 | await expect.element(networkAccessToggle).not.toBeDisabled(); | ||
| 1072 | await networkAccessToggle.click(); | ||
| 1073 | await expect.element(page.getByText("Enable network access?")).toBeInTheDocument(); | ||
| 1074 | await expect | ||
| 1075 | .element(page.getByText("T3 Code will restart to expose this environment over the network.")) | ||
| 1076 | .toBeInTheDocument(); | ||
| 1077 | await page.getByRole("button", { name: "Restart and enable", exact: true }).click(); | ||
| 1078 | await vi.waitFor(() => { | ||
| 1079 | expect(desktopBridge.setServerExposureMode).toHaveBeenCalledWith("network-accessible"); | ||
| 1080 | }); | ||
| 1081 | await expect.element(page.getByText("http://192.168.1.44:3773")).toBeInTheDocument(); | ||
| 1082 | }); | ||
| 1083 | |||
| 1084 | it("adds desktop ssh environments from the add-environment dialog", async () => { | ||
| 1085 | const discoverSshHosts = vi.fn().mockResolvedValue([ | ||
| 1086 | { | ||
| 1087 | alias: "devbox", | ||
| 1088 | hostname: "devbox.example.com", | ||
| 1089 | username: "julius", | ||
| 1090 | port: 22, | ||
| 1091 | source: "ssh-config" as const, | ||
| 1092 | }, | ||
| 1093 | ]); | ||
| 1094 | window.desktopBridge = createDesktopBridgeStub({ | ||
| 1095 | discoverSshHosts, | ||
| 1096 | }); | ||
| 1097 | mockConnectDesktopSshEnvironment.mockResolvedValue({ | ||
| 1098 | environmentId: EnvironmentId.make("environment-devbox"), | ||
| 1099 | label: "Build box", | ||
| 1100 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 1101 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 1102 | createdAt: "2036-04-07T00:00:00.000Z", | ||
| 1103 | lastConnectedAt: "2036-04-07T00:00:00.000Z", | ||
| 1104 | desktopSsh: { | ||
| 1105 | alias: "devbox.example.com", | ||
| 1106 | hostname: "devbox.example.com", | ||
| 1107 | username: "julius", | ||
| 1108 | port: 2222, | ||
| 1109 | }, | ||
| 1110 | }); | ||
| 1111 | |||
| 1112 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 1113 | |||
| 1114 | mounted = await render( | ||
| 1115 | <AppAtomRegistryProvider> | ||
| 1116 | <ConnectionsSettings /> | ||
| 1117 | </AppAtomRegistryProvider>, | ||
| 1118 | ); | ||
| 1119 | |||
| 1120 | await page.getByRole("button", { name: "Add environment", exact: true }).click(); | ||
| 1121 | const addEnvironmentDialog = page.getByRole("dialog", { name: "Add Environment" }); | ||
| 1122 | await expect | ||
| 1123 | .element(addEnvironmentDialog.getByRole("heading", { name: "Add Environment", exact: true })) | ||
| 1124 | .toBeInTheDocument(); | ||
| 1125 | await addEnvironmentDialog.getByRole("button", { name: /^SSH\b/ }).click(); | ||
| 1126 | await vi.waitFor(() => { | ||
| 1127 | expect(discoverSshHosts).toHaveBeenCalledTimes(1); | ||
| 1128 | }); | ||
| 1129 | await expect | ||
| 1130 | .element(page.getByRole("heading", { name: "devbox", exact: true })) | ||
| 1131 | .toBeInTheDocument(); | ||
| 1132 | |||
| 1133 | await addEnvironmentDialog.getByLabelText("SSH host or alias").fill("devbox.example.com"); | ||
| 1134 | await addEnvironmentDialog.getByLabelText("Username").fill("julius"); | ||
| 1135 | await addEnvironmentDialog.getByLabelText("Port").fill("2222"); | ||
| 1136 | await addEnvironmentDialog | ||
| 1137 | .getByRole("button", { name: "Add environment", exact: true }) | ||
| 1138 | .first() | ||
| 1139 | .click(); | ||
| 1140 | |||
| 1141 | await vi.waitFor(() => { | ||
| 1142 | expect(mockConnectDesktopSshEnvironment).toHaveBeenCalledWith( | ||
| 1143 | { | ||
| 1144 | alias: "devbox.example.com", | ||
| 1145 | hostname: "devbox.example.com", | ||
| 1146 | username: "julius", | ||
| 1147 | port: 2222, | ||
| 1148 | }, | ||
| 1149 | { label: "" }, | ||
| 1150 | ); | ||
| 1151 | }); | ||
| 1152 | }); | ||
| 1153 | |||
| 1154 | it("opens the logs folder in the preferred editor", async () => { | ||
| 1155 | const openInEditor = vi.fn<LocalApi["shell"]["openInEditor"]>().mockResolvedValue(undefined); | ||
| 1156 | window.nativeApi = { | ||
| 1157 | persistence: { | ||
| 1158 | getClientSettings: vi.fn().mockResolvedValue(null), | ||
| 1159 | setClientSettings: vi.fn().mockResolvedValue(undefined), | ||
| 1160 | }, | ||
| 1161 | shell: { | ||
| 1162 | openInEditor, | ||
| 1163 | }, | ||
| 1164 | server: { | ||
| 1165 | getProcessDiagnostics: vi.fn().mockResolvedValue({ | ||
| 1166 | serverPid: 1234, | ||
| 1167 | readAt: makeUtc("2036-04-07T00:00:00.000Z"), | ||
| 1168 | processCount: 0, | ||
| 1169 | totalRssBytes: 0, | ||
| 1170 | totalCpuPercent: 0, | ||
| 1171 | processes: [], | ||
| 1172 | error: Option.none(), | ||
| 1173 | }), | ||
| 1174 | getProcessResourceHistory: vi | ||
| 1175 | .fn() | ||
| 1176 | .mockResolvedValue(createEmptyProcessResourceHistoryResult()), | ||
| 1177 | getTraceDiagnostics: vi.fn().mockResolvedValue({ | ||
| 1178 | traceFilePath: "/repo/project/.t3/traces.jsonl", | ||
| 1179 | scannedFilePaths: ["/repo/project/.t3/traces.jsonl"], | ||
| 1180 | readAt: makeUtc("2036-04-07T00:00:00.000Z"), | ||
| 1181 | recordCount: 0, | ||
| 1182 | parseErrorCount: 0, | ||
| 1183 | firstSpanAt: Option.none(), | ||
| 1184 | lastSpanAt: Option.none(), | ||
| 1185 | failureCount: 0, | ||
| 1186 | interruptionCount: 0, | ||
| 1187 | slowSpanThresholdMs: 5_000, | ||
| 1188 | slowSpanCount: 0, | ||
| 1189 | logLevelCounts: {}, | ||
| 1190 | topSpansByCount: [], | ||
| 1191 | slowestSpans: [], | ||
| 1192 | commonFailures: [], | ||
| 1193 | latestFailures: [], | ||
| 1194 | latestWarningAndErrorLogs: [], | ||
| 1195 | partialFailure: Option.none(), | ||
| 1196 | error: Option.none(), | ||
| 1197 | }), | ||
| 1198 | }, | ||
| 1199 | } as unknown as LocalApi; | ||
| 1200 | |||
| 1201 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 1202 | |||
| 1203 | mounted = await render( | ||
| 1204 | <AppAtomRegistryProvider> | ||
| 1205 | <DiagnosticsSettingsPanel /> | ||
| 1206 | </AppAtomRegistryProvider>, | ||
| 1207 | ); | ||
| 1208 | |||
| 1209 | const openLogsButton = page.getByLabelText("Open logs folder"); | ||
| 1210 | await openLogsButton.click(); | ||
| 1211 | |||
| 1212 | expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor"); | ||
| 1213 | }); | ||
| 1214 | |||
| 1215 | it("shows an OpenCode server URL field in provider settings", async () => { | ||
| 1216 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 1217 | |||
| 1218 | mounted = await render( | ||
| 1219 | <AppAtomRegistryProvider> | ||
| 1220 | <ProviderSettingsPanel /> | ||
| 1221 | </AppAtomRegistryProvider>, | ||
| 1222 | ); | ||
| 1223 | |||
| 1224 | await page.getByLabelText("Toggle OpenCode details").click(); | ||
| 1225 | |||
| 1226 | // The unified provider-instance card renders field labels without a | ||
| 1227 | // driver-name prefix (the driver name is already shown in the card | ||
| 1228 | // header), so the labels read "Server URL" / "Server password" | ||
| 1229 | // rather than the old "OpenCode server URL" / "OpenCode server password". | ||
| 1230 | await expect.element(page.getByText("Server URL")).toBeInTheDocument(); | ||
| 1231 | await expect.element(page.getByPlaceholder("http://127.0.0.1:4096")).toBeInTheDocument(); | ||
| 1232 | await expect.element(page.getByText("Server password")).toBeInTheDocument(); | ||
| 1233 | await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); | ||
| 1234 | }); | ||
| 1235 | |||
| 1236 | it("runs one-click provider updates from the provider card", async () => { | ||
| 1237 | const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({ | ||
| 1238 | providers: [createOutdatedProvider("codex")], | ||
| 1239 | }); | ||
| 1240 | window.nativeApi = { | ||
| 1241 | persistence: { | ||
| 1242 | getClientSettings: vi.fn().mockResolvedValue(null), | ||
| 1243 | setClientSettings: vi.fn().mockResolvedValue(undefined), | ||
| 1244 | }, | ||
| 1245 | server: { | ||
| 1246 | updateProvider, | ||
| 1247 | }, | ||
| 1248 | } as unknown as LocalApi; | ||
| 1249 | |||
| 1250 | setServerConfigSnapshot({ | ||
| 1251 | ...createBaseServerConfig(), | ||
| 1252 | providers: [createOutdatedProvider("codex")], | ||
| 1253 | }); | ||
| 1254 | |||
| 1255 | mounted = await render( | ||
| 1256 | <AppAtomRegistryProvider> | ||
| 1257 | <ProviderSettingsPanel /> | ||
| 1258 | </AppAtomRegistryProvider>, | ||
| 1259 | ); | ||
| 1260 | |||
| 1261 | await page.getByRole("button", { name: "Update available — view details" }).click(); | ||
| 1262 | await expect.element(page.getByRole("button", { name: "Update now" })).toBeInTheDocument(); | ||
| 1263 | await page.getByRole("button", { name: "Update now" }).click(); | ||
| 1264 | |||
| 1265 | expect(updateProvider).toHaveBeenCalledWith({ | ||
| 1266 | provider: ProviderDriverKind.make("codex"), | ||
| 1267 | instanceId: ProviderInstanceId.make("codex"), | ||
| 1268 | }); | ||
| 1269 | }); | ||
| 1270 | |||
| 1271 | it("keeps long provider update commands inside the fixed-width popover", async () => { | ||
| 1272 | const longUpdateCommand = | ||
| 1273 | "npm install -g @anthropic-ai/claude-code@latest --registry=https://registry.npmjs.org --cache=/tmp/t3code-provider-update-cache"; | ||
| 1274 | |||
| 1275 | setServerConfigSnapshot({ | ||
| 1276 | ...createBaseServerConfig(), | ||
| 1277 | providers: [createOutdatedProvider("codex", longUpdateCommand)], | ||
| 1278 | }); | ||
| 1279 | |||
| 1280 | mounted = await render( | ||
| 1281 | <AppAtomRegistryProvider> | ||
| 1282 | <ProviderSettingsPanel /> | ||
| 1283 | </AppAtomRegistryProvider>, | ||
| 1284 | ); | ||
| 1285 | |||
| 1286 | await page.getByRole("button", { name: "Update available — view details" }).click(); | ||
| 1287 | await expect.element(page.getByText(longUpdateCommand)).toBeInTheDocument(); | ||
| 1288 | |||
| 1289 | await vi.waitFor(() => { | ||
| 1290 | const popup = document.querySelector<HTMLElement>('[data-slot="popover-popup"]'); | ||
| 1291 | const commandCode = Array.from(document.querySelectorAll<HTMLElement>("code")).find( | ||
| 1292 | (element) => element.textContent === longUpdateCommand, | ||
| 1293 | ); | ||
| 1294 | const scrollViewport = commandCode?.closest<HTMLElement>( | ||
| 1295 | '[data-slot="scroll-area-viewport"]', | ||
| 1296 | ); | ||
| 1297 | |||
| 1298 | expect(popup).toBeTruthy(); | ||
| 1299 | expect(commandCode).toBeTruthy(); | ||
| 1300 | expect(scrollViewport).toBeTruthy(); | ||
| 1301 | |||
| 1302 | const popupRect = popup!.getBoundingClientRect(); | ||
| 1303 | const viewportRect = scrollViewport!.getBoundingClientRect(); | ||
| 1304 | |||
| 1305 | expect(popupRect.width).toBeGreaterThan(300); | ||
| 1306 | expect(popupRect.width).toBeLessThanOrEqual(337); | ||
| 1307 | expect(viewportRect.right).toBeLessThanOrEqual(popupRect.right + 0.5); | ||
| 1308 | expect(scrollViewport!.scrollWidth).toBeGreaterThan(scrollViewport!.clientWidth); | ||
| 1309 | }); | ||
| 1310 | }); | ||
| 1311 | }); | ||
| 1312 | |||
| 1313 | describe("SourceControlSettingsPanel discovery states", () => { | ||
| 1314 | let mounted: | ||
| 1315 | | (Awaited<ReturnType<typeof render>> & { | ||
| 1316 | cleanup?: () => Promise<void>; | ||
| 1317 | unmount?: () => Promise<void>; | ||
| 1318 | }) | ||
| 1319 | | null = null; | ||
| 1320 | |||
| 1321 | beforeEach(async () => { | ||
| 1322 | resetAppAtomRegistryForTests(); | ||
| 1323 | await __resetLocalApiForTests(); | ||
| 1324 | document.body.innerHTML = ""; | ||
| 1325 | }); | ||
| 1326 | |||
| 1327 | afterEach(async () => { | ||
| 1328 | if (mounted) { | ||
| 1329 | const teardown = mounted.cleanup ?? mounted.unmount; | ||
| 1330 | await teardown?.call(mounted).catch(() => {}); | ||
| 1331 | } | ||
| 1332 | mounted = null; | ||
| 1333 | Reflect.deleteProperty(window, "nativeApi"); | ||
| 1334 | document.body.innerHTML = ""; | ||
| 1335 | await __resetLocalApiForTests(); | ||
| 1336 | resetAppAtomRegistryForTests(); | ||
| 1337 | }); | ||
| 1338 | |||
| 1339 | function setSourceControlDiscoveryStub( | ||
| 1340 | discoverSourceControl: () => Promise<SourceControlDiscoveryResult>, | ||
| 1341 | ) { | ||
| 1342 | window.nativeApi = { | ||
| 1343 | server: { | ||
| 1344 | discoverSourceControl, | ||
| 1345 | }, | ||
| 1346 | } as LocalApi; | ||
| 1347 | } | ||
| 1348 | |||
| 1349 | it("shows skeleton sections while the first source control scan is pending", async () => { | ||
| 1350 | setSourceControlDiscoveryStub(() => new Promise(() => {})); | ||
| 1351 | |||
| 1352 | mounted = await render( | ||
| 1353 | <AppAtomRegistryProvider> | ||
| 1354 | <SourceControlSettingsPanel /> | ||
| 1355 | </AppAtomRegistryProvider>, | ||
| 1356 | ); | ||
| 1357 | |||
| 1358 | await expect.element(page.getByText("Version Control")).toBeInTheDocument(); | ||
| 1359 | await expect.element(page.getByText("Source Control Providers")).toBeInTheDocument(); | ||
| 1360 | await expect | ||
| 1361 | .element(page.getByRole("button", { name: "Rescan server environment" })) | ||
| 1362 | .toBeDisabled(); | ||
| 1363 | await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); | ||
| 1364 | }); | ||
| 1365 | |||
| 1366 | it("uses the shared empty state when discovery completes without tools", async () => { | ||
| 1367 | setSourceControlDiscoveryStub(async () => ({ | ||
| 1368 | versionControlSystems: [], | ||
| 1369 | sourceControlProviders: [], | ||
| 1370 | })); | ||
| 1371 | |||
| 1372 | mounted = await render( | ||
| 1373 | <AppAtomRegistryProvider> | ||
| 1374 | <SourceControlSettingsPanel /> | ||
| 1375 | </AppAtomRegistryProvider>, | ||
| 1376 | ); | ||
| 1377 | |||
| 1378 | await expect.element(page.getByText("Nothing detected yet")).toBeInTheDocument(); | ||
| 1379 | await expect | ||
| 1380 | .element( | ||
| 1381 | page.getByText( | ||
| 1382 | "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan.", | ||
| 1383 | ), | ||
| 1384 | ) | ||
| 1385 | .toBeInTheDocument(); | ||
| 1386 | await expect.element(page.getByRole("button", { name: "Scan" })).toBeInTheDocument(); | ||
| 1387 | }); | ||
| 1388 | |||
| 1389 | it("keeps discovered rows instead of showing the empty state", async () => { | ||
| 1390 | setSourceControlDiscoveryStub(async () => ({ | ||
| 1391 | versionControlSystems: [ | ||
| 1392 | { | ||
| 1393 | kind: "git", | ||
| 1394 | label: "Git", | ||
| 1395 | executable: "git", | ||
| 1396 | implemented: true, | ||
| 1397 | status: "available", | ||
| 1398 | version: Option.some("git version 2.50.0"), | ||
| 1399 | installHint: "Install Git.", | ||
| 1400 | detail: Option.none(), | ||
| 1401 | }, | ||
| 1402 | ], | ||
| 1403 | sourceControlProviders: [], | ||
| 1404 | })); | ||
| 1405 | |||
| 1406 | mounted = await render( | ||
| 1407 | <AppAtomRegistryProvider> | ||
| 1408 | <SourceControlSettingsPanel /> | ||
| 1409 | </AppAtomRegistryProvider>, | ||
| 1410 | ); | ||
| 1411 | |||
| 1412 | await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); | ||
| 1413 | await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); | ||
| 1414 | }); | ||
| 1415 | |||
| 1416 | it("shows unauthenticated API providers as available but not enabled", async () => { | ||
| 1417 | setSourceControlDiscoveryStub(async () => ({ | ||
| 1418 | versionControlSystems: [], | ||
| 1419 | sourceControlProviders: [ | ||
| 1420 | { | ||
| 1421 | kind: "bitbucket", | ||
| 1422 | label: "Bitbucket", | ||
| 1423 | status: "available", | ||
| 1424 | version: Option.none(), | ||
| 1425 | installHint: | ||
| 1426 | "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", | ||
| 1427 | detail: Option.none(), | ||
| 1428 | auth: { | ||
| 1429 | status: "unauthenticated", | ||
| 1430 | account: Option.none(), | ||
| 1431 | host: Option.some("bitbucket.org"), | ||
| 1432 | detail: Option.some( | ||
| 1433 | "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", | ||
| 1434 | ), | ||
| 1435 | }, | ||
| 1436 | }, | ||
| 1437 | ], | ||
| 1438 | })); | ||
| 1439 | |||
| 1440 | mounted = await render( | ||
| 1441 | <AppAtomRegistryProvider> | ||
| 1442 | <SourceControlSettingsPanel /> | ||
| 1443 | </AppAtomRegistryProvider>, | ||
| 1444 | ); | ||
| 1445 | |||
| 1446 | const bitbucketSwitch = page.getByRole("switch", { name: "Bitbucket availability" }); | ||
| 1447 | |||
| 1448 | await expect.element(page.getByText("Not authenticated")).toBeInTheDocument(); | ||
| 1449 | await expect | ||
| 1450 | .element( | ||
| 1451 | page.getByText( | ||
| 1452 | "Available. Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", | ||
| 1453 | ), | ||
| 1454 | ) | ||
| 1455 | .toBeInTheDocument(); | ||
| 1456 | await expect.element(bitbucketSwitch).toBeDisabled(); | ||
| 1457 | await expect.element(bitbucketSwitch).not.toBeChecked(); | ||
| 1458 | }); | ||
| 1459 | |||
| 1460 | it("shows Git fetch interval settings inside the Git details dropdown", async () => { | ||
| 1461 | setSourceControlDiscoveryStub(async () => ({ | ||
| 1462 | versionControlSystems: [ | ||
| 1463 | { | ||
| 1464 | kind: "git", | ||
| 1465 | label: "Git", | ||
| 1466 | executable: "git", | ||
| 1467 | implemented: true, | ||
| 1468 | status: "available", | ||
| 1469 | version: Option.some("git version 2.50.0"), | ||
| 1470 | installHint: "Install Git.", | ||
| 1471 | detail: Option.none(), | ||
| 1472 | }, | ||
| 1473 | ], | ||
| 1474 | sourceControlProviders: [], | ||
| 1475 | })); | ||
| 1476 | |||
| 1477 | mounted = await render( | ||
| 1478 | <AppAtomRegistryProvider> | ||
| 1479 | <SourceControlSettingsPanel /> | ||
| 1480 | </AppAtomRegistryProvider>, | ||
| 1481 | ); | ||
| 1482 | |||
| 1483 | const toggle = page.getByRole("button", { name: "Toggle Git details" }); | ||
| 1484 | await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); | ||
| 1485 | |||
| 1486 | await toggle.click(); | ||
| 1487 | |||
| 1488 | await expect.element(toggle).toHaveAttribute("aria-expanded", "true"); | ||
| 1489 | await expect | ||
| 1490 | .element(page.getByLabelText("Automatic Git fetch interval in seconds")) | ||
| 1491 | .toBeVisible(); | ||
| 1492 | await expect | ||
| 1493 | .element(page.getByText("Automatic Git fetches run every 30 seconds")) | ||
| 1494 | .not.toBeInTheDocument(); | ||
| 1495 | }); | ||
| 1496 | |||
| 1497 | it("does not rescan on remount while the discovery atom is fresh", async () => { | ||
| 1498 | let calls = 0; | ||
| 1499 | setSourceControlDiscoveryStub(async () => { | ||
| 1500 | calls += 1; | ||
| 1501 | return { | ||
| 1502 | versionControlSystems: [ | ||
| 1503 | { | ||
| 1504 | kind: "git", | ||
| 1505 | label: "Git", | ||
| 1506 | executable: "git", | ||
| 1507 | implemented: true, | ||
| 1508 | status: "available", | ||
| 1509 | version: Option.some("git version 2.50.0"), | ||
| 1510 | installHint: "Install Git.", | ||
| 1511 | detail: Option.none(), | ||
| 1512 | }, | ||
| 1513 | ], | ||
| 1514 | sourceControlProviders: [], | ||
| 1515 | }; | ||
| 1516 | }); | ||
| 1517 | |||
| 1518 | mounted = await render( | ||
| 1519 | <AppAtomRegistryProvider> | ||
| 1520 | <SourceControlSettingsPanel /> | ||
| 1521 | </AppAtomRegistryProvider>, | ||
| 1522 | ); | ||
| 1523 | |||
| 1524 | await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); | ||
| 1525 | expect(calls).toBe(1); | ||
| 1526 | |||
| 1527 | const teardown = mounted.cleanup ?? mounted.unmount; | ||
| 1528 | await teardown?.call(mounted).catch(() => {}); | ||
| 1529 | mounted = null; | ||
| 1530 | document.body.innerHTML = ""; | ||
| 1531 | |||
| 1532 | mounted = await render( | ||
| 1533 | <AppAtomRegistryProvider> | ||
| 1534 | <SourceControlSettingsPanel /> | ||
| 1535 | </AppAtomRegistryProvider>, | ||
| 1536 | ); | ||
| 1537 | |||
| 1538 | await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); | ||
| 1539 | expect(calls).toBe(1); | ||
| 1540 | }); | ||
| 1541 | }); | ||
apps/web/src/components/settings/SettingsPanels.tsx+160-115| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; | 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | import { useQueryClient } from "@tanstack/react-query"; | ||
| 3 | import { Link } from "@tanstack/react-router"; | 2 | import { Link } from "@tanstack/react-router"; |
| 4 | import { useCallback, useMemo, useRef, useState } from "react"; | 3 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 4 | import { useAtomValue } from "@effect/atom-react"; | ||
| 5 | import { | 5 | import { |
| 6 | defaultInstanceIdForDriver, | 6 | defaultInstanceIdForDriver, |
| 7 | type DesktopUpdateChannel, | 7 | type DesktopUpdateChannel, |
| ... | @@ -11,7 +11,12 @@ import { | ... | @@ -11,7 +11,12 @@ import { |
| 11 | type ProviderInstanceId, | 11 | type ProviderInstanceId, |
| 12 | type ScopedThreadRef, | 12 | type ScopedThreadRef, |
| 13 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 14 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 14 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 15 | import { | ||
| 16 | isAtomCommandInterrupted, | ||
| 17 | settlePromise, | ||
| 18 | squashAtomCommandFailure, | ||
| 19 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 15 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 20 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 16 | import { createModelSelection } from "@t3tools/shared/model"; | 21 | import { createModelSelection } from "@t3tools/shared/model"; |
| 17 | import * as Arr from "effect/Array"; | 22 | import * as Arr from "effect/Array"; |
| ... | @@ -33,10 +38,7 @@ import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hos | ... | @@ -33,10 +38,7 @@ import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hos |
| 33 | import { useTheme } from "../../hooks/useTheme"; | 38 | import { useTheme } from "../../hooks/useTheme"; |
| 34 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; | 39 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; |
| 35 | import { useThreadActions } from "../../hooks/useThreadActions"; | 40 | import { useThreadActions } from "../../hooks/useThreadActions"; |
| 36 | import { | 41 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 37 | setDesktopUpdateStateQueryData, | ||
| 38 | useDesktopUpdateState, | ||
| 39 | } from "../../lib/desktopUpdateReactQuery"; | ||
| 40 | import { | 42 | import { |
| 41 | getCustomModelOptionsByInstance, | 43 | getCustomModelOptionsByInstance, |
| 42 | resolveAppModelSelectionState, | 44 | resolveAppModelSelectionState, |
| ... | @@ -46,8 +48,13 @@ import { | ... | @@ -46,8 +48,13 @@ import { |
| 46 | sortProviderInstanceEntries, | 48 | sortProviderInstanceEntries, |
| 47 | } from "../../providerInstances"; | 49 | } from "../../providerInstances"; |
| 48 | import { ensureLocalApi, readLocalApi } from "../../localApi"; | 50 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 49 | import { useShallow } from "zustand/react/shallow"; | 51 | import { |
| 50 | import { selectProjectsAcrossEnvironments, useStore } from "../../store"; | 52 | primaryServerObservabilityAtom, |
| 53 | primaryServerProvidersAtom, | ||
| 54 | serverEnvironment, | ||
| 55 | } from "../../state/server"; | ||
| 56 | import { usePrimaryEnvironment } from "../../state/environments"; | ||
| 57 | import { useProjects } from "../../state/entities"; | ||
| 51 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | 58 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 52 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; | 59 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; |
| 53 | import { Button } from "../ui/button"; | 60 | import { Button } from "../ui/button"; |
| ... | @@ -78,7 +85,7 @@ import { | ... | @@ -78,7 +85,7 @@ import { |
| 78 | useRelativeTimeTick, | 85 | useRelativeTimeTick, |
| 79 | } from "./settingsLayout"; | 86 | } from "./settingsLayout"; |
| 80 | import { ProjectFavicon } from "../ProjectFavicon"; | 87 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 81 | import { useServerObservability, useServerProviders } from "../../rpc/serverState"; | 88 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 82 | 89 | ||
| 83 | const THEME_OPTIONS = [ | 90 | const THEME_OPTIONS = [ |
| 84 | { | 91 | { |
| ... | @@ -155,11 +162,9 @@ function AboutVersionTitle() { | ... | @@ -155,11 +162,9 @@ function AboutVersionTitle() { |
| 155 | } | 162 | } |
| 156 | 163 | ||
| 157 | function AboutVersionSection() { | 164 | function AboutVersionSection() { |
| 158 | const queryClient = useQueryClient(); | 165 | const updateState = useDesktopUpdateState(); |
| 159 | const updateStateQuery = useDesktopUpdateState(); | ||
| 160 | const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); | 166 | const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); |
| 161 | 167 | ||
| 162 | const updateState = updateStateQuery.data ?? null; | ||
| 163 | const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); | 168 | const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); |
| 164 | const selectedUpdateChannel = updateState?.channel ?? "latest"; | 169 | const selectedUpdateChannel = updateState?.channel ?? "latest"; |
| 165 | const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; | 170 | const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; |
| ... | @@ -178,9 +183,6 @@ function AboutVersionSection() { | ... | @@ -178,9 +183,6 @@ function AboutVersionSection() { |
| 178 | setIsChangingUpdateChannel(true); | 183 | setIsChangingUpdateChannel(true); |
| 179 | void bridge | 184 | void bridge |
| 180 | .setUpdateChannel(channel) | 185 | .setUpdateChannel(channel) |
| 181 | .then((state) => { | ||
| 182 | setDesktopUpdateStateQueryData(queryClient, state); | ||
| 183 | }) | ||
| 184 | .catch((error: unknown) => { | 186 | .catch((error: unknown) => { |
| 185 | toastManager.add( | 187 | toastManager.add( |
| 186 | stackedThreadToast({ | 188 | stackedThreadToast({ |
| ... | @@ -194,7 +196,7 @@ function AboutVersionSection() { | ... | @@ -194,7 +196,7 @@ function AboutVersionSection() { |
| 194 | setIsChangingUpdateChannel(false); | 196 | setIsChangingUpdateChannel(false); |
| 195 | }); | 197 | }); |
| 196 | }, | 198 | }, |
| 197 | [queryClient, selectedUpdateChannel], | 199 | [selectedUpdateChannel], |
| 198 | ); | 200 | ); |
| 199 | 201 | ||
| 200 | const handleButtonClick = useCallback(() => { | 202 | const handleButtonClick = useCallback(() => { |
| ... | @@ -204,20 +206,15 @@ function AboutVersionSection() { | ... | @@ -204,20 +206,15 @@ function AboutVersionSection() { |
| 204 | const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; | 206 | const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; |
| 205 | 207 | ||
| 206 | if (action === "download") { | 208 | if (action === "download") { |
| 207 | void bridge | 209 | void bridge.downloadUpdate().catch((error: unknown) => { |
| 208 | .downloadUpdate() | 210 | toastManager.add( |
| 209 | .then((result) => { | 211 | stackedThreadToast({ |
| 210 | setDesktopUpdateStateQueryData(queryClient, result.state); | 212 | type: "error", |
| 211 | }) | 213 | title: "Could not download update", |
| 212 | .catch((error: unknown) => { | 214 | description: error instanceof Error ? error.message : "Download failed.", |
| 213 | toastManager.add( | 215 | }), |
| 214 | stackedThreadToast({ | 216 | ); |
| 215 | type: "error", | 217 | }); |
| 216 | title: "Could not download update", | ||
| 217 | description: error instanceof Error ? error.message : "Download failed.", | ||
| 218 | }), | ||
| 219 | ); | ||
| 220 | }); | ||
| 221 | return; | 218 | return; |
| 222 | } | 219 | } |
| 223 | 220 | ||
| ... | @@ -228,20 +225,15 @@ function AboutVersionSection() { | ... | @@ -228,20 +225,15 @@ function AboutVersionSection() { |
| 228 | ), | 225 | ), |
| 229 | ); | 226 | ); |
| 230 | if (!confirmed) return; | 227 | if (!confirmed) return; |
| 231 | void bridge | 228 | void bridge.installUpdate().catch((error: unknown) => { |
| 232 | .installUpdate() | 229 | toastManager.add( |
| 233 | .then((result) => { | 230 | stackedThreadToast({ |
| 234 | setDesktopUpdateStateQueryData(queryClient, result.state); | 231 | type: "error", |
| 235 | }) | 232 | title: "Could not install update", |
| 236 | .catch((error: unknown) => { | 233 | description: error instanceof Error ? error.message : "Install failed.", |
| 237 | toastManager.add( | 234 | }), |
| 238 | stackedThreadToast({ | 235 | ); |
| 239 | type: "error", | 236 | }); |
| 240 | title: "Could not install update", | ||
| 241 | description: error instanceof Error ? error.message : "Install failed.", | ||
| 242 | }), | ||
| 243 | ); | ||
| 244 | }); | ||
| 245 | return; | 237 | return; |
| 246 | } | 238 | } |
| 247 | 239 | ||
| ... | @@ -249,7 +241,6 @@ function AboutVersionSection() { | ... | @@ -249,7 +241,6 @@ function AboutVersionSection() { |
| 249 | void bridge | 241 | void bridge |
| 250 | .checkForUpdate() | 242 | .checkForUpdate() |
| 251 | .then((result) => { | 243 | .then((result) => { |
| 252 | setDesktopUpdateStateQueryData(queryClient, result.state); | ||
| 253 | if (!result.checked) { | 244 | if (!result.checked) { |
| 254 | toastManager.add( | 245 | toastManager.add( |
| 255 | stackedThreadToast({ | 246 | stackedThreadToast({ |
| ... | @@ -270,7 +261,7 @@ function AboutVersionSection() { | ... | @@ -270,7 +261,7 @@ function AboutVersionSection() { |
| 270 | }), | 261 | }), |
| 271 | ); | 262 | ); |
| 272 | }); | 263 | }); |
| 273 | }, [queryClient, updateState]); | 264 | }, [updateState]); |
| 274 | 265 | ||
| 275 | const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; | 266 | const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; |
| 276 | const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; | 267 | const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; |
| ... | @@ -382,7 +373,7 @@ function AboutVersionSection() { | ... | @@ -382,7 +373,7 @@ function AboutVersionSection() { |
| 382 | export function useSettingsRestore(onRestored?: () => void) { | 373 | export function useSettingsRestore(onRestored?: () => void) { |
| 383 | const { theme, setTheme } = useTheme(); | 374 | const { theme, setTheme } = useTheme(); |
| 384 | const settings = useSettings(); | 375 | const settings = useSettings(); |
| 385 | const { updateSettings } = useUpdateSettings(); | 376 | const updateSettings = useUpdateSettings(); |
| 386 | 377 | ||
| 387 | const isGitWritingModelDirty = !Equal.equals( | 378 | const isGitWritingModelDirty = !Equal.equals( |
| 388 | settings.textGenerationModelSelection ?? null, | 379 | settings.textGenerationModelSelection ?? null, |
| ... | @@ -482,9 +473,9 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -482,9 +473,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 482 | export function GeneralSettingsPanel() { | 473 | export function GeneralSettingsPanel() { |
| 483 | const { theme, setTheme } = useTheme(); | 474 | const { theme, setTheme } = useTheme(); |
| 484 | const settings = useSettings(); | 475 | const settings = useSettings(); |
| 485 | const { updateSettings } = useUpdateSettings(); | 476 | const updateSettings = useUpdateSettings(); |
| 486 | const observability = useServerObservability(); | 477 | const observability = useAtomValue(primaryServerObservabilityAtom); |
| 487 | const serverProviders = useServerProviders(); | 478 | const serverProviders = useAtomValue(primaryServerProvidersAtom); |
| 488 | const diagnosticsDescription = formatDiagnosticsDescription({ | 479 | const diagnosticsDescription = formatDiagnosticsDescription({ |
| 489 | localTracingEnabled: observability?.localTracingEnabled ?? false, | 480 | localTracingEnabled: observability?.localTracingEnabled ?? false, |
| 490 | otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, | 481 | otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, |
| ... | @@ -918,8 +909,15 @@ export function GeneralSettingsPanel() { | ... | @@ -918,8 +909,15 @@ export function GeneralSettingsPanel() { |
| 918 | 909 | ||
| 919 | export function ProviderSettingsPanel() { | 910 | export function ProviderSettingsPanel() { |
| 920 | const settings = useSettings(); | 911 | const settings = useSettings(); |
| 921 | const { updateSettings } = useUpdateSettings(); | 912 | const updateSettings = useUpdateSettings(); |
| 922 | const serverProviders = useServerProviders(); | 913 | const serverProviders = useAtomValue(primaryServerProvidersAtom); |
| 914 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 915 | const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { | ||
| 916 | reportFailure: false, | ||
| 917 | }); | ||
| 918 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { | ||
| 919 | reportFailure: false, | ||
| 920 | }); | ||
| 923 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); | 921 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); |
| 924 | const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); | 922 | const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); |
| 925 | const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< | 923 | const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< |
| ... | @@ -958,49 +956,61 @@ export function ProviderSettingsPanel() { | ... | @@ -958,49 +956,61 @@ export function ProviderSettingsPanel() { |
| 958 | if (refreshingRef.current) return; | 956 | if (refreshingRef.current) return; |
| 959 | refreshingRef.current = true; | 957 | refreshingRef.current = true; |
| 960 | setIsRefreshingProviders(true); | 958 | setIsRefreshingProviders(true); |
| 961 | void ensureLocalApi() | 959 | if (!primaryEnvironment) { |
| 962 | .server.refreshProviders() | 960 | refreshingRef.current = false; |
| 963 | .catch((error: unknown) => { | 961 | setIsRefreshingProviders(false); |
| 964 | console.warn("Failed to refresh providers", error); | 962 | return; |
| 965 | }) | 963 | } |
| 966 | .finally(() => { | 964 | void (async () => { |
| 967 | refreshingRef.current = false; | 965 | const result = await refreshServerProviders({ |
| 968 | setIsRefreshingProviders(false); | 966 | environmentId: primaryEnvironment.environmentId, |
| 967 | input: {}, | ||
| 969 | }); | 968 | }); |
| 970 | }, []); | 969 | refreshingRef.current = false; |
| 970 | setIsRefreshingProviders(false); | ||
| 971 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 972 | console.warn("Failed to refresh providers", squashAtomCommandFailure(result)); | ||
| 973 | } | ||
| 974 | })(); | ||
| 975 | }, [primaryEnvironment, refreshServerProviders]); | ||
| 971 | 976 | ||
| 972 | const runProviderUpdate = useCallback(async (candidate: ProviderUpdateCandidate) => { | 977 | const runProviderUpdate = useCallback( |
| 973 | let started = false; | 978 | async (candidate: ProviderUpdateCandidate) => { |
| 974 | setUpdatingProviderDrivers((previous) => { | 979 | if (!primaryEnvironment) return; |
| 975 | if (previous.has(candidate.driver)) { | 980 | let started = false; |
| 976 | return previous; | 981 | setUpdatingProviderDrivers((previous) => { |
| 982 | if (previous.has(candidate.driver)) { | ||
| 983 | return previous; | ||
| 984 | } | ||
| 985 | started = true; | ||
| 986 | const next = new Set(previous); | ||
| 987 | next.add(candidate.driver); | ||
| 988 | return next; | ||
| 989 | }); | ||
| 990 | if (!started) { | ||
| 991 | return; | ||
| 977 | } | 992 | } |
| 978 | started = true; | ||
| 979 | const next = new Set(previous); | ||
| 980 | next.add(candidate.driver); | ||
| 981 | return next; | ||
| 982 | }); | ||
| 983 | if (!started) { | ||
| 984 | return; | ||
| 985 | } | ||
| 986 | 993 | ||
| 987 | try { | 994 | const result = await updateProvider({ |
| 988 | await ensureLocalApi().server.updateProvider({ | 995 | environmentId: primaryEnvironment.environmentId, |
| 989 | provider: candidate.driver, | 996 | input: { |
| 990 | instanceId: candidate.instanceId, | 997 | provider: candidate.driver, |
| 998 | instanceId: candidate.instanceId, | ||
| 999 | }, | ||
| 991 | }); | 1000 | }); |
| 992 | } catch (error) { | 1001 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 993 | toastManager.add( | 1002 | const error = squashAtomCommandFailure(result); |
| 994 | stackedThreadToast({ | 1003 | toastManager.add( |
| 995 | type: "error", | 1004 | stackedThreadToast({ |
| 996 | title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, | 1005 | type: "error", |
| 997 | description: | 1006 | title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, |
| 998 | error instanceof Error | 1007 | description: |
| 999 | ? error.message | 1008 | error instanceof Error |
| 1000 | : "The provider update command could not be started.", | 1009 | ? error.message |
| 1001 | }), | 1010 | : "The provider update command could not be started.", |
| 1002 | ); | 1011 | }), |
| 1003 | } finally { | 1012 | ); |
| 1013 | } | ||
| 1004 | setUpdatingProviderDrivers((previous) => { | 1014 | setUpdatingProviderDrivers((previous) => { |
| 1005 | if (!previous.has(candidate.driver)) { | 1015 | if (!previous.has(candidate.driver)) { |
| 1006 | return previous; | 1016 | return previous; |
| ... | @@ -1009,8 +1019,9 @@ export function ProviderSettingsPanel() { | ... | @@ -1009,8 +1019,9 @@ export function ProviderSettingsPanel() { |
| 1009 | next.delete(candidate.driver); | 1019 | next.delete(candidate.driver); |
| 1010 | return next; | 1020 | return next; |
| 1011 | }); | 1021 | }); |
| 1012 | } | 1022 | }, |
| 1013 | }, []); | 1023 | [primaryEnvironment, updateProvider], |
| 1024 | ); | ||
| 1014 | 1025 | ||
| 1015 | interface InstanceRow { | 1026 | interface InstanceRow { |
| 1016 | readonly instanceId: ProviderInstanceId; | 1027 | readonly instanceId: ProviderInstanceId; |
| ... | @@ -1330,16 +1341,15 @@ export function ProviderSettingsPanel() { | ... | @@ -1330,16 +1341,15 @@ export function ProviderSettingsPanel() { |
| 1330 | })} | 1341 | })} |
| 1331 | </SettingsSection> | 1342 | </SettingsSection> |
| 1332 | 1343 | ||
| 1333 | <AddProviderInstanceDialog | 1344 | {isAddInstanceDialogOpen ? ( |
| 1334 | open={isAddInstanceDialogOpen} | 1345 | <AddProviderInstanceDialog open onOpenChange={setIsAddInstanceDialogOpen} /> |
| 1335 | onOpenChange={setIsAddInstanceDialogOpen} | 1346 | ) : null} |
| 1336 | /> | ||
| 1337 | </SettingsPageContainer> | 1347 | </SettingsPageContainer> |
| 1338 | ); | 1348 | ); |
| 1339 | } | 1349 | } |
| 1340 | 1350 | ||
| 1341 | export function ArchivedThreadsPanel() { | 1351 | export function ArchivedThreadsPanel() { |
| 1342 | const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); | 1352 | const projects = useProjects(); |
| 1343 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); | 1353 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); |
| 1344 | const environmentIds = useMemo( | 1354 | const environmentIds = useMemo( |
| 1345 | () => [...new Set(projects.map((project) => project.environmentId))], | 1355 | () => [...new Set(projects.map((project) => project.environmentId))], |
| ... | @@ -1415,10 +1425,11 @@ export function ArchivedThreadsPanel() { | ... | @@ -1415,10 +1425,11 @@ export function ArchivedThreadsPanel() { |
| 1415 | ); | 1425 | ); |
| 1416 | 1426 | ||
| 1417 | if (clicked === "unarchive") { | 1427 | if (clicked === "unarchive") { |
| 1418 | try { | 1428 | const result = await unarchiveThread(threadRef); |
| 1419 | await unarchiveThread(threadRef); | 1429 | if (result._tag === "Success") { |
| 1420 | refreshArchivedThreads(); | 1430 | refreshArchivedThreads(); |
| 1421 | } catch (error) { | 1431 | } else if (!isAtomCommandInterrupted(result)) { |
| 1432 | const error = squashAtomCommandFailure(result); | ||
| 1422 | toastManager.add( | 1433 | toastManager.add( |
| 1423 | stackedThreadToast({ | 1434 | stackedThreadToast({ |
| 1424 | type: "error", | 1435 | type: "error", |
| ... | @@ -1431,8 +1442,19 @@ export function ArchivedThreadsPanel() { | ... | @@ -1431,8 +1442,19 @@ export function ArchivedThreadsPanel() { |
| 1431 | } | 1442 | } |
| 1432 | 1443 | ||
| 1433 | if (clicked === "delete") { | 1444 | if (clicked === "delete") { |
| 1434 | await confirmAndDeleteThread(threadRef); | 1445 | const result = await confirmAndDeleteThread(threadRef); |
| 1435 | refreshArchivedThreads(); | 1446 | if (result._tag === "Success") { |
| 1447 | refreshArchivedThreads(); | ||
| 1448 | } else if (!isAtomCommandInterrupted(result)) { | ||
| 1449 | const error = squashAtomCommandFailure(result); | ||
| 1450 | toastManager.add( | ||
| 1451 | stackedThreadToast({ | ||
| 1452 | type: "error", | ||
| 1453 | title: "Failed to delete thread", | ||
| 1454 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1455 | }), | ||
| 1456 | ); | ||
| 1457 | } | ||
| 1436 | } | 1458 | } |
| 1437 | }, | 1459 | }, |
| 1438 | [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], | 1460 | [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], |
| ... | @@ -1476,13 +1498,28 @@ export function ArchivedThreadsPanel() { | ... | @@ -1476,13 +1498,28 @@ export function ArchivedThreadsPanel() { |
| 1476 | key={thread.id} | 1498 | key={thread.id} |
| 1477 | onContextMenu={(event) => { | 1499 | onContextMenu={(event) => { |
| 1478 | event.preventDefault(); | 1500 | event.preventDefault(); |
| 1479 | void handleArchivedThreadContextMenu( | 1501 | void (async () => { |
| 1480 | scopeThreadRef(thread.environmentId, thread.id), | 1502 | const result = await settlePromise(() => |
| 1481 | { | 1503 | handleArchivedThreadContextMenu( |
| 1482 | x: event.clientX, | 1504 | scopeThreadRef(thread.environmentId, thread.id), |
| 1483 | y: event.clientY, | 1505 | { |
| 1484 | }, | 1506 | x: event.clientX, |
| 1485 | ); | 1507 | y: event.clientY, |
| 1508 | }, | ||
| 1509 | ), | ||
| 1510 | ); | ||
| 1511 | if (result._tag === "Failure") { | ||
| 1512 | const error = squashAtomCommandFailure(result); | ||
| 1513 | toastManager.add( | ||
| 1514 | stackedThreadToast({ | ||
| 1515 | type: "error", | ||
| 1516 | title: "Archived thread action failed", | ||
| 1517 | description: | ||
| 1518 | error instanceof Error ? error.message : "An error occurred.", | ||
| 1519 | }), | ||
| 1520 | ); | ||
| 1521 | } | ||
| 1522 | })(); | ||
| 1486 | }} | 1523 | }} |
| 1487 | title={thread.title} | 1524 | title={thread.title} |
| 1488 | description={ | 1525 | description={ |
| ... | @@ -1498,10 +1535,17 @@ export function ArchivedThreadsPanel() { | ... | @@ -1498,10 +1535,17 @@ export function ArchivedThreadsPanel() { |
| 1498 | variant="outline" | 1535 | variant="outline" |
| 1499 | size="sm" | 1536 | size="sm" |
| 1500 | className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" | 1537 | className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" |
| 1501 | onClick={() => | 1538 | onClick={() => { |
| 1502 | void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)) | 1539 | void (async () => { |
| 1503 | .then(() => refreshArchivedThreads()) | 1540 | const result = await unarchiveThread( |
| 1504 | .catch((error) => { | 1541 | scopeThreadRef(thread.environmentId, thread.id), |
| 1542 | ); | ||
| 1543 | if (result._tag === "Success") { | ||
| 1544 | refreshArchivedThreads(); | ||
| 1545 | return; | ||
| 1546 | } | ||
| 1547 | if (!isAtomCommandInterrupted(result)) { | ||
| 1548 | const error = squashAtomCommandFailure(result); | ||
| 1505 | toastManager.add( | 1549 | toastManager.add( |
| 1506 | stackedThreadToast({ | 1550 | stackedThreadToast({ |
| 1507 | type: "error", | 1551 | type: "error", |
| ... | @@ -1510,8 +1554,9 @@ export function ArchivedThreadsPanel() { | ... | @@ -1510,8 +1554,9 @@ export function ArchivedThreadsPanel() { |
| 1510 | error instanceof Error ? error.message : "An error occurred.", | 1554 | error instanceof Error ? error.message : "An error occurred.", |
| 1511 | }), | 1555 | }), |
| 1512 | ); | 1556 | ); |
| 1513 | }) | 1557 | } |
| 1514 | } | 1558 | })(); |
| 1559 | }} | ||
| 1515 | > | 1560 | > |
| 1516 | <ArchiveX className="size-3.5" /> | 1561 | <ArchiveX className="size-3.5" /> |
| 1517 | <span>Unarchive</span> | 1562 | <span>Unarchive</span> |
apps/web/src/components/settings/SourceControlSettings.tsx+14-7| ... | @@ -14,10 +14,9 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | ... | @@ -14,10 +14,9 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 14 | 14 | ||
| 15 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; | 15 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; |
| 16 | import { cn } from "../../lib/utils"; | 16 | import { cn } from "../../lib/utils"; |
| 17 | import { | 17 | import { usePrimaryEnvironment } from "../../state/environments"; |
| 18 | refreshSourceControlDiscovery, | 18 | import { useEnvironmentQuery } from "../../state/query"; |
| 19 | useSourceControlDiscovery, | 19 | import { sourceControlEnvironment } from "../../state/sourceControl"; |
| 20 | } from "../../lib/sourceControlDiscoveryState"; | ||
| 21 | import { Badge } from "../ui/badge"; | 20 | import { Badge } from "../ui/badge"; |
| 22 | import { Button } from "../ui/button"; | 21 | import { Button } from "../ui/button"; |
| 23 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; | 22 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; |
| ... | @@ -293,7 +292,7 @@ function DiscoveryItemRow({ | ... | @@ -293,7 +292,7 @@ function DiscoveryItemRow({ |
| 293 | 292 | ||
| 294 | function GitFetchIntervalSettings() { | 293 | function GitFetchIntervalSettings() { |
| 295 | const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval); | 294 | const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval); |
| 296 | const { updateSettings } = useUpdateSettings(); | 295 | const updateSettings = useUpdateSettings(); |
| 297 | const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); | 296 | const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); |
| 298 | const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( | 297 | const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( |
| 299 | DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | 298 | DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, |
| ... | @@ -439,13 +438,21 @@ function EmptySourceControlDiscovery({ | ... | @@ -439,13 +438,21 @@ function EmptySourceControlDiscovery({ |
| 439 | } | 438 | } |
| 440 | 439 | ||
| 441 | export function SourceControlSettingsPanel() { | 440 | export function SourceControlSettingsPanel() { |
| 442 | const discovery = useSourceControlDiscovery(); | 441 | const environmentId = usePrimaryEnvironment()?.environmentId ?? null; |
| 442 | const discovery = useEnvironmentQuery( | ||
| 443 | environmentId === null | ||
| 444 | ? null | ||
| 445 | : sourceControlEnvironment.discovery({ | ||
| 446 | environmentId, | ||
| 447 | input: {}, | ||
| 448 | }), | ||
| 449 | ); | ||
| 443 | const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; | 450 | const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; |
| 444 | const hasDiscoveryItems = | 451 | const hasDiscoveryItems = |
| 445 | result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; | 452 | result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; |
| 446 | const isInitialScanPending = discovery.isPending && discovery.data === null; | 453 | const isInitialScanPending = discovery.isPending && discovery.data === null; |
| 447 | const handleScan = () => { | 454 | const handleScan = () => { |
| 448 | void refreshSourceControlDiscovery(); | 455 | discovery.refresh(); |
| 449 | }; | 456 | }; |
| 450 | const scanButton = ( | 457 | const scanButton = ( |
| 451 | <Tooltip> | 458 | <Tooltip> |
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx+3-2| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { useNavigate } from "@tanstack/react-router"; | 1 | import { useNavigate } from "@tanstack/react-router"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { ServerProvider } from "@t3tools/contracts"; | 3 | import type { ServerProvider } from "@t3tools/contracts"; |
| 3 | import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; | 4 | import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; |
| 4 | import { useCallback, useEffect, useState, type CSSProperties } from "react"; | 5 | import { useCallback, useEffect, useState, type CSSProperties } from "react"; |
| 5 | 6 | ||
| 6 | import { useServerProviders } from "../../rpc/serverState"; | 7 | import { primaryServerProvidersAtom } from "../../state/server"; |
| 7 | import { | 8 | import { |
| 8 | getProviderUpdateSidebarPillView, | 9 | getProviderUpdateSidebarPillView, |
| 9 | type ProviderUpdateSidebarPillView, | 10 | type ProviderUpdateSidebarPillView, |
| ... | @@ -39,7 +40,7 @@ function latestProviderCheckedAt( | ... | @@ -39,7 +40,7 @@ function latestProviderCheckedAt( |
| 39 | 40 | ||
| 40 | export function SidebarProviderUpdatePill() { | 41 | export function SidebarProviderUpdatePill() { |
| 41 | const navigate = useNavigate(); | 42 | const navigate = useNavigate(); |
| 42 | const providers = useServerProviders(); | 43 | const providers = useAtomValue(primaryServerProvidersAtom); |
| 43 | const [dismissedKeys, setDismissedKeys] = useState<ReadonlySet<string>>(() => new Set()); | 44 | const [dismissedKeys, setDismissedKeys] = useState<ReadonlySet<string>>(() => new Set()); |
| 44 | const [renderedView, setRenderedView] = useState<ProviderUpdateSidebarPillView | null>(null); | 45 | const [renderedView, setRenderedView] = useState<ProviderUpdateSidebarPillView | null>(null); |
| 45 | const [pendingView, setPendingView] = useState<ProviderUpdateSidebarPillView | null>(null); | 46 | const [pendingView, setPendingView] = useState<ProviderUpdateSidebarPillView | null>(null); |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+3-10| ... | @@ -1,11 +1,7 @@ | ... | @@ -1,11 +1,7 @@ |
| 1 | import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; | 1 | import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; |
| 2 | import { useQueryClient } from "@tanstack/react-query"; | ||
| 3 | import { useCallback, useState } from "react"; | 2 | import { useCallback, useState } from "react"; |
| 4 | import { isElectron } from "../../env"; | 3 | import { isElectron } from "../../env"; |
| 5 | import { | 4 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 6 | setDesktopUpdateStateQueryData, | ||
| 7 | useDesktopUpdateState, | ||
| 8 | } from "../../lib/desktopUpdateReactQuery"; | ||
| 9 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 5 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 10 | import { | 6 | import { |
| 11 | getArm64IntelBuildWarningDescription, | 7 | getArm64IntelBuildWarningDescription, |
| ... | @@ -22,8 +18,7 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | ... | @@ -22,8 +18,7 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 18 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 23 | 19 | ||
| 24 | export function SidebarUpdatePill() { | 20 | export function SidebarUpdatePill() { |
| 25 | const queryClient = useQueryClient(); | 21 | const state = useDesktopUpdateState(); |
| 26 | const state = useDesktopUpdateState().data ?? null; | ||
| 27 | const [dismissed, setDismissed] = useState(false); | 22 | const [dismissed, setDismissed] = useState(false); |
| 28 | 23 | ||
| 29 | const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; | 24 | const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; |
| ... | @@ -44,7 +39,6 @@ export function SidebarUpdatePill() { | ... | @@ -44,7 +39,6 @@ export function SidebarUpdatePill() { |
| 44 | void bridge | 39 | void bridge |
| 45 | .downloadUpdate() | 40 | .downloadUpdate() |
| 46 | .then((result) => { | 41 | .then((result) => { |
| 47 | setDesktopUpdateStateQueryData(queryClient, result.state); | ||
| 48 | if (result.completed) { | 42 | if (result.completed) { |
| 49 | toastManager.add({ | 43 | toastManager.add({ |
| 50 | type: "success", | 44 | type: "success", |
| ... | @@ -81,7 +75,6 @@ export function SidebarUpdatePill() { | ... | @@ -81,7 +75,6 @@ export function SidebarUpdatePill() { |
| 81 | void bridge | 75 | void bridge |
| 82 | .installUpdate() | 76 | .installUpdate() |
| 83 | .then((result) => { | 77 | .then((result) => { |
| 84 | setDesktopUpdateStateQueryData(queryClient, result.state); | ||
| 85 | if (!shouldToastDesktopUpdateActionResult(result)) return; | 78 | if (!shouldToastDesktopUpdateActionResult(result)) return; |
| 86 | const actionError = getDesktopUpdateActionError(result); | 79 | const actionError = getDesktopUpdateActionError(result); |
| 87 | if (!actionError) return; | 80 | if (!actionError) return; |
| ... | @@ -103,7 +96,7 @@ export function SidebarUpdatePill() { | ... | @@ -103,7 +96,7 @@ export function SidebarUpdatePill() { |
| 103 | ); | 96 | ); |
| 104 | }); | 97 | }); |
| 105 | } | 98 | } |
| 106 | }, [action, disabled, queryClient, state]); | 99 | }, [action, disabled, state]); |
| 107 | 100 | ||
| 108 | if (!visible && !showArm64Warning) return null; | 101 | if (!visible && !showArm64Warning) return null; |
| 109 | 102 |
apps/web/src/composerDraftStore.test.ts+48-1| ... | @@ -3,7 +3,7 @@ import { | ... | @@ -3,7 +3,7 @@ import { |
| 3 | scopedThreadKey, | 3 | scopedThreadKey, |
| 4 | scopeProjectRef, | 4 | scopeProjectRef, |
| 5 | scopeThreadRef, | 5 | scopeThreadRef, |
| 6 | } from "@t3tools/client-runtime"; | 6 | } from "@t3tools/client-runtime/environment"; |
| 7 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 8 | import { | 8 | import { |
| 9 | defaultInstanceIdForDriver, | 9 | defaultInstanceIdForDriver, |
| ... | @@ -59,6 +59,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" | ... | @@ -59,6 +59,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" |
| 59 | 59 | ||
| 60 | import { | 60 | import { |
| 61 | COMPOSER_DRAFT_STORAGE_KEY, | 61 | COMPOSER_DRAFT_STORAGE_KEY, |
| 62 | clearComposerDraftsEnvironment, | ||
| 62 | finalizePromotedDraftThreadByRef, | 63 | finalizePromotedDraftThreadByRef, |
| 63 | markPromotedDraftThread, | 64 | markPromotedDraftThread, |
| 64 | markPromotedDraftThreadByRef, | 65 | markPromotedDraftThreadByRef, |
| ... | @@ -696,6 +697,40 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -696,6 +697,40 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 696 | resetComposerDraftStore(); | 697 | resetComposerDraftStore(); |
| 697 | }); | 698 | }); |
| 698 | 699 | ||
| 700 | it("clears composer data for one environment without touching another", () => { | ||
| 701 | const store = useComposerDraftStore.getState(); | ||
| 702 | const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 703 | const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, otherThreadId); | ||
| 704 | const originalRevokeObjectUrl = URL.revokeObjectURL; | ||
| 705 | const revokeSpy = vi.fn<(url: string) => void>(); | ||
| 706 | URL.revokeObjectURL = revokeSpy; | ||
| 707 | |||
| 708 | try { | ||
| 709 | store.setProjectDraftThreadId(projectRef, localDraftId, { threadId }); | ||
| 710 | store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { | ||
| 711 | threadId: otherThreadId, | ||
| 712 | }); | ||
| 713 | store.setPrompt(localDraftId, "local draft"); | ||
| 714 | store.setPrompt(remoteDraftId, "remote draft"); | ||
| 715 | store.addImage(localDraftId, makeImage({ id: "img-local", previewUrl: "blob:local-draft" })); | ||
| 716 | store.setPrompt(localThreadRef, "local thread draft"); | ||
| 717 | store.setPrompt(remoteThreadRef, "remote thread draft"); | ||
| 718 | |||
| 719 | clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID); | ||
| 720 | |||
| 721 | const next = useComposerDraftStore.getState(); | ||
| 722 | expect(next.getDraftThreadByProjectRef(projectRef)).toBeNull(); | ||
| 723 | expect(next.getDraftThreadByProjectRef(remoteProjectRef)).not.toBeNull(); | ||
| 724 | expect(next.getComposerDraft(localDraftId)).toBeNull(); | ||
| 725 | expect(next.getComposerDraft(remoteDraftId)?.prompt).toBe("remote thread draft"); | ||
| 726 | expect(next.getComposerDraft(localThreadRef)).toBeNull(); | ||
| 727 | expect(next.getComposerDraft(remoteThreadRef)?.prompt).toBe("remote thread draft"); | ||
| 728 | expect(revokeSpy).toHaveBeenCalledWith("blob:local-draft"); | ||
| 729 | } finally { | ||
| 730 | URL.revokeObjectURL = originalRevokeObjectUrl; | ||
| 731 | } | ||
| 732 | }); | ||
| 733 | |||
| 699 | it("stores and reads project draft thread ids via actions", () => { | 734 | it("stores and reads project draft thread ids via actions", () => { |
| 700 | const store = useComposerDraftStore.getState(); | 735 | const store = useComposerDraftStore.getState(); |
| 701 | expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); | 736 | expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); |
| ... | @@ -965,6 +1000,18 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -965,6 +1000,18 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 965 | expect(draftByKey(draftId)).toBeUndefined(); | 1000 | expect(draftByKey(draftId)).toBeUndefined(); |
| 966 | }); | 1001 | }); |
| 967 | 1002 | ||
| 1003 | it("finalizes a matching materialized draft even when promotion was not pre-marked", () => { | ||
| 1004 | const store = useComposerDraftStore.getState(); | ||
| 1005 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | ||
| 1006 | store.setPrompt(draftId, "promote me"); | ||
| 1007 | |||
| 1008 | finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); | ||
| 1009 | |||
| 1010 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | ||
| 1011 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); | ||
| 1012 | expect(draftByKey(draftId)).toBeUndefined(); | ||
| 1013 | }); | ||
| 1014 | |||
| 968 | it("updates branch context on an existing draft thread", () => { | 1015 | it("updates branch context on an existing draft thread", () => { |
| 969 | const store = useComposerDraftStore.getState(); | 1016 | const store = useComposerDraftStore.getState(); |
| 970 | store.setProjectDraftThreadId(projectRef, draftId, { | 1017 | store.setProjectDraftThreadId(projectRef, draftId, { |
apps/web/src/composerDraftStore.ts+65-7| ... | @@ -24,7 +24,7 @@ import { | ... | @@ -24,7 +24,7 @@ import { |
| 24 | scopeProjectRef, | 24 | scopeProjectRef, |
| 25 | scopedThreadKey, | 25 | scopedThreadKey, |
| 26 | scopeThreadRef, | 26 | scopeThreadRef, |
| 27 | } from "@t3tools/client-runtime"; | 27 | } from "@t3tools/client-runtime/environment"; |
| 28 | import * as Schema from "effect/Schema"; | 28 | import * as Schema from "effect/Schema"; |
| 29 | import * as Equal from "effect/Equal"; | 29 | import * as Equal from "effect/Equal"; |
| 30 | import { DeepMutable } from "effect/Types"; | 30 | import { DeepMutable } from "effect/Types"; |
| ... | @@ -3347,6 +3347,60 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -3347,6 +3347,60 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3347 | 3347 | ||
| 3348 | export const useComposerDraftStore = composerDraftStore; | 3348 | export const useComposerDraftStore = composerDraftStore; |
| 3349 | 3349 | ||
| 3350 | export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { | ||
| 3351 | useComposerDraftStore.setState((state) => { | ||
| 3352 | const removedThreadKeys = new Set<string>(); | ||
| 3353 | |||
| 3354 | for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { | ||
| 3355 | if (draftThread.environmentId === environmentId) { | ||
| 3356 | removedThreadKeys.add(threadKey); | ||
| 3357 | } | ||
| 3358 | } | ||
| 3359 | for (const threadKey of Object.keys(state.draftsByThreadKey)) { | ||
| 3360 | if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) { | ||
| 3361 | removedThreadKeys.add(threadKey); | ||
| 3362 | } | ||
| 3363 | } | ||
| 3364 | for (const [logicalProjectKey, threadKey] of Object.entries( | ||
| 3365 | state.logicalProjectDraftThreadKeyByLogicalProjectKey, | ||
| 3366 | )) { | ||
| 3367 | if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) { | ||
| 3368 | removedThreadKeys.add(threadKey); | ||
| 3369 | } | ||
| 3370 | } | ||
| 3371 | |||
| 3372 | const nextLogicalMappings = Object.fromEntries( | ||
| 3373 | Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( | ||
| 3374 | ([logicalProjectKey, threadKey]) => | ||
| 3375 | parseScopedProjectKey(logicalProjectKey)?.environmentId !== environmentId && | ||
| 3376 | !removedThreadKeys.has(threadKey), | ||
| 3377 | ), | ||
| 3378 | ) as Record<string, string>; | ||
| 3379 | const nextDraftThreads = Object.fromEntries( | ||
| 3380 | Object.entries(state.draftThreadsByThreadKey).filter( | ||
| 3381 | ([threadKey, draftThread]) => | ||
| 3382 | draftThread.environmentId !== environmentId && !removedThreadKeys.has(threadKey), | ||
| 3383 | ), | ||
| 3384 | ) as Record<string, DraftThreadState>; | ||
| 3385 | const nextDrafts = Object.fromEntries( | ||
| 3386 | Object.entries(state.draftsByThreadKey).filter(([threadKey, draft]) => { | ||
| 3387 | if (!removedThreadKeys.has(threadKey)) { | ||
| 3388 | return true; | ||
| 3389 | } | ||
| 3390 | revokeDraftThreadPreviewUrls(draft); | ||
| 3391 | return false; | ||
| 3392 | }), | ||
| 3393 | ) as Record<string, ComposerThreadDraftState>; | ||
| 3394 | |||
| 3395 | return { | ||
| 3396 | draftsByThreadKey: nextDrafts, | ||
| 3397 | draftThreadsByThreadKey: nextDraftThreads, | ||
| 3398 | logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, | ||
| 3399 | }; | ||
| 3400 | }); | ||
| 3401 | composerDebouncedStorage.flush(); | ||
| 3402 | } | ||
| 3403 | |||
| 3350 | export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState { | 3404 | export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState { |
| 3351 | return useComposerDraftStore((state) => { | 3405 | return useComposerDraftStore((state) => { |
| 3352 | return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT; | 3406 | return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT; |
| ... | @@ -3459,12 +3513,16 @@ export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable<ScopedT | ... | @@ -3459,12 +3513,16 @@ export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable<ScopedT |
| 3459 | export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { | 3513 | export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { |
| 3460 | const draftStore = useComposerDraftStore.getState(); | 3514 | const draftStore = useComposerDraftStore.getState(); |
| 3461 | for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { | 3515 | for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { |
| 3462 | if ( | 3516 | const promotedRef = draftThread.promotedTo; |
| 3463 | draftThread.promotedTo && | 3517 | const matches = promotedRef |
| 3464 | draftThread.promotedTo.environmentId === threadRef.environmentId && | 3518 | ? promotedRef.environmentId === threadRef.environmentId && |
| 3465 | draftThread.promotedTo.threadId === threadRef.threadId | 3519 | promotedRef.threadId === threadRef.threadId |
| 3466 | ) { | 3520 | : draftThread.environmentId === threadRef.environmentId && |
| 3467 | draftStore.finalizePromotedDraftThread(DraftId.make(draftId)); | 3521 | draftThread.threadId === threadRef.threadId; |
| 3522 | if (matches) { | ||
| 3523 | const target = DraftId.make(draftId); | ||
| 3524 | draftStore.markDraftThreadPromoting(target, threadRef); | ||
| 3525 | draftStore.finalizePromotedDraftThread(target); | ||
| 3468 | } | 3526 | } |
| 3469 | } | 3527 | } |
| 3470 | } | 3528 | } |
apps/web/src/connection/catalog.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "./runtime"; | ||
| 4 | |||
| 5 | export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime); | ||
apps/web/src/connection/onboarding.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import { ConnectionOnboarding } from "@t3tools/client-runtime/connection"; | ||
| 2 | import { | ||
| 3 | createAtomCommandScheduler, | ||
| 4 | createRuntimeCommand, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 6 | import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | |||
| 9 | import { connectionAtomRuntime } from "./runtime"; | ||
| 10 | |||
| 11 | const onboardingScheduler = createAtomCommandScheduler(); | ||
| 12 | |||
| 13 | export const connectPairing = createRuntimeCommand(connectionAtomRuntime, { | ||
| 14 | label: "web:connection:connect-pairing", | ||
| 15 | scheduler: onboardingScheduler, | ||
| 16 | concurrency: { | ||
| 17 | mode: "singleFlight", | ||
| 18 | key: (input: { pairingUrl?: string; host?: string; pairingCode?: string }) => | ||
| 19 | JSON.stringify(input), | ||
| 20 | }, | ||
| 21 | execute: (input: { | ||
| 22 | readonly pairingUrl?: string; | ||
| 23 | readonly host?: string; | ||
| 24 | readonly pairingCode?: string; | ||
| 25 | }) => | ||
| 26 | ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.registerPairing(input))), | ||
| 27 | }); | ||
| 28 | |||
| 29 | export const connectSshEnvironment = createRuntimeCommand(connectionAtomRuntime, { | ||
| 30 | label: "web:connection:connect-ssh", | ||
| 31 | scheduler: onboardingScheduler, | ||
| 32 | concurrency: { | ||
| 33 | mode: "serial", | ||
| 34 | key: (input: { readonly target: DesktopSshEnvironmentTarget }) => JSON.stringify(input.target), | ||
| 35 | }, | ||
| 36 | execute: (input: { readonly target: DesktopSshEnvironmentTarget; readonly label?: string }) => | ||
| 37 | ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.registerSsh(input))), | ||
| 38 | }); | ||
apps/web/src/connection/platform.test.ts created+88| ... | @@ -0,0 +1,88 @@ | ||
| 1 | import { | ||
| 2 | AuthStandardClientScopes, | ||
| 3 | EnvironmentId, | ||
| 4 | type DesktopBridge, | ||
| 5 | type DesktopSshEnvironmentTarget, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | |||
| 10 | import { provisionDesktopSshEnvironment } from "./platform.ts"; | ||
| 11 | |||
| 12 | const TARGET: DesktopSshEnvironmentTarget = { | ||
| 13 | alias: "devbox", | ||
| 14 | hostname: "devbox.example.test", | ||
| 15 | username: "developer", | ||
| 16 | port: 22, | ||
| 17 | }; | ||
| 18 | |||
| 19 | function makeBridge( | ||
| 20 | calls: string[], | ||
| 21 | options?: { readonly failDescriptor?: boolean }, | ||
| 22 | ): DesktopBridge { | ||
| 23 | return { | ||
| 24 | ensureSshEnvironment: async (target: DesktopSshEnvironmentTarget) => { | ||
| 25 | calls.push("ensure"); | ||
| 26 | return { | ||
| 27 | target, | ||
| 28 | httpBaseUrl: "http://127.0.0.1:3201/", | ||
| 29 | wsBaseUrl: "ws://127.0.0.1:3201/", | ||
| 30 | pairingToken: "pairing-token", | ||
| 31 | }; | ||
| 32 | }, | ||
| 33 | fetchSshEnvironmentDescriptor: async () => { | ||
| 34 | calls.push("descriptor"); | ||
| 35 | if (options?.failDescriptor === true) { | ||
| 36 | throw new Error("descriptor unavailable"); | ||
| 37 | } | ||
| 38 | return { | ||
| 39 | environmentId: EnvironmentId.make("environment-ssh"), | ||
| 40 | label: "SSH environment", | ||
| 41 | platform: { | ||
| 42 | os: "linux", | ||
| 43 | arch: "x64", | ||
| 44 | }, | ||
| 45 | serverVersion: "0.0.0-test", | ||
| 46 | capabilities: { | ||
| 47 | repositoryIdentity: true, | ||
| 48 | }, | ||
| 49 | }; | ||
| 50 | }, | ||
| 51 | bootstrapSshBearerSession: async () => { | ||
| 52 | calls.push("token"); | ||
| 53 | return { | ||
| 54 | access_token: "bearer-token", | ||
| 55 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 56 | token_type: "Bearer", | ||
| 57 | expires_in: 3_600, | ||
| 58 | scope: AuthStandardClientScopes.join(" "), | ||
| 59 | }; | ||
| 60 | }, | ||
| 61 | } as unknown as DesktopBridge; | ||
| 62 | } | ||
| 63 | |||
| 64 | describe("desktop SSH pairing", () => { | ||
| 65 | it.effect("fetches the descriptor before consuming the one-time credential", () => | ||
| 66 | Effect.gen(function* () { | ||
| 67 | const calls: string[] = []; | ||
| 68 | |||
| 69 | const provisioned = yield* provisionDesktopSshEnvironment(makeBridge(calls), TARGET); | ||
| 70 | |||
| 71 | expect(provisioned.environmentId).toBe(EnvironmentId.make("environment-ssh")); | ||
| 72 | expect(calls).toEqual(["ensure", "descriptor", "token"]); | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | |||
| 76 | it.effect("does not consume the credential when descriptor discovery fails", () => | ||
| 77 | Effect.gen(function* () { | ||
| 78 | const calls: string[] = []; | ||
| 79 | |||
| 80 | yield* provisionDesktopSshEnvironment( | ||
| 81 | makeBridge(calls, { failDescriptor: true }), | ||
| 82 | TARGET, | ||
| 83 | ).pipe(Effect.flip); | ||
| 84 | |||
| 85 | expect(calls).toEqual(["ensure", "descriptor"]); | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | }); | ||
apps/web/src/connection/platform.ts created+352| ... | @@ -0,0 +1,352 @@ | ||
| 1 | import { | ||
| 2 | ClientPresentation, | ||
| 3 | CloudSession, | ||
| 4 | EnvironmentOwnedDataCleanup, | ||
| 5 | PlatformConnectionSource, | ||
| 6 | RelayDeviceIdentity, | ||
| 7 | SshEnvironmentGateway, | ||
| 8 | } from "@t3tools/client-runtime/platform"; | ||
| 9 | import { | ||
| 10 | ConnectionBlockedError, | ||
| 11 | ConnectionTransientError, | ||
| 12 | ConnectionWakeups, | ||
| 13 | Connectivity, | ||
| 14 | mapRemoteEnvironmentError, | ||
| 15 | PrimaryConnectionRegistration, | ||
| 16 | PrimaryConnectionTarget, | ||
| 17 | } from "@t3tools/client-runtime/connection"; | ||
| 18 | import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; | ||
| 19 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; | ||
| 20 | import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; | ||
| 21 | import { | ||
| 22 | AuthStandardClientScopes, | ||
| 23 | type DesktopBridge, | ||
| 24 | type DesktopSshEnvironmentTarget, | ||
| 25 | } from "@t3tools/contracts"; | ||
| 26 | import * as Context from "effect/Context"; | ||
| 27 | import * as Effect from "effect/Effect"; | ||
| 28 | import * as Layer from "effect/Layer"; | ||
| 29 | import * as Option from "effect/Option"; | ||
| 30 | import * as Queue from "effect/Queue"; | ||
| 31 | import * as Schedule from "effect/Schedule"; | ||
| 32 | import * as Stream from "effect/Stream"; | ||
| 33 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | ||
| 34 | |||
| 35 | import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; | ||
| 36 | import { readPrimaryEnvironmentTarget } from "../environments/primary/target"; | ||
| 37 | import { clearComposerDraftsEnvironment } from "../composerDraftStore"; | ||
| 38 | import { isHostedStaticApp } from "../hostedPairing"; | ||
| 39 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 40 | import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; | ||
| 41 | import { connectionStorageLayer } from "./storage"; | ||
| 42 | |||
| 43 | let nextObservedRpcRequestId = 0; | ||
| 44 | |||
| 45 | function currentNetworkStatus(): "unknown" | "offline" | "online" { | ||
| 46 | if (typeof navigator === "undefined") { | ||
| 47 | return "unknown"; | ||
| 48 | } | ||
| 49 | return navigator.onLine ? "online" : "offline"; | ||
| 50 | } | ||
| 51 | |||
| 52 | const connectivityLayer = Layer.succeed( | ||
| 53 | Connectivity, | ||
| 54 | Connectivity.of({ | ||
| 55 | status: Effect.sync(currentNetworkStatus), | ||
| 56 | changes: Stream.callback((queue) => | ||
| 57 | Effect.acquireRelease( | ||
| 58 | Effect.sync(() => { | ||
| 59 | const online = () => Queue.offerUnsafe(queue, "online"); | ||
| 60 | const offline = () => Queue.offerUnsafe(queue, "offline"); | ||
| 61 | window.addEventListener("online", online); | ||
| 62 | window.addEventListener("offline", offline); | ||
| 63 | return { online, offline }; | ||
| 64 | }), | ||
| 65 | ({ online, offline }) => | ||
| 66 | Effect.sync(() => { | ||
| 67 | window.removeEventListener("online", online); | ||
| 68 | window.removeEventListener("offline", offline); | ||
| 69 | }), | ||
| 70 | ).pipe(Effect.asVoid), | ||
| 71 | ), | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | |||
| 75 | const wakeupsLayer = Layer.succeed( | ||
| 76 | ConnectionWakeups, | ||
| 77 | ConnectionWakeups.of({ | ||
| 78 | changes: Stream.merge( | ||
| 79 | Stream.callback<"application-active">((queue) => | ||
| 80 | Effect.acquireRelease( | ||
| 81 | Effect.sync(() => { | ||
| 82 | const listener = () => { | ||
| 83 | if (document.visibilityState === "visible") { | ||
| 84 | Queue.offerUnsafe(queue, "application-active"); | ||
| 85 | } | ||
| 86 | }; | ||
| 87 | document.addEventListener("visibilitychange", listener); | ||
| 88 | return listener; | ||
| 89 | }), | ||
| 90 | (listener) => | ||
| 91 | Effect.sync(() => { | ||
| 92 | document.removeEventListener("visibilitychange", listener); | ||
| 93 | }), | ||
| 94 | ).pipe(Effect.asVoid), | ||
| 95 | ), | ||
| 96 | managedRelayAccountChanges(appAtomRegistry).pipe( | ||
| 97 | Stream.map(() => "credentials-changed" as const), | ||
| 98 | ), | ||
| 99 | ), | ||
| 100 | }), | ||
| 101 | ); | ||
| 102 | |||
| 103 | function clientMetadata() { | ||
| 104 | const desktop = window.desktopBridge !== undefined; | ||
| 105 | const platform = navigator.platform.trim(); | ||
| 106 | return { | ||
| 107 | label: desktop ? "T3 Code Desktop" : "T3 Code Web", | ||
| 108 | deviceType: "desktop" as const, | ||
| 109 | ...(platform === "" ? {} : { os: platform }), | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | function sshPreparationError(cause: unknown) { | ||
| 114 | const message = cause instanceof Error ? cause.message : String(cause); | ||
| 115 | if (message.toLowerCase().includes("cancel")) { | ||
| 116 | return new ConnectionBlockedError({ | ||
| 117 | reason: "authentication", | ||
| 118 | message, | ||
| 119 | }); | ||
| 120 | } | ||
| 121 | return new ConnectionTransientError({ | ||
| 122 | reason: "remote-unavailable", | ||
| 123 | message: `Could not prepare the SSH environment: ${message}`, | ||
| 124 | }); | ||
| 125 | } | ||
| 126 | |||
| 127 | export const provisionDesktopSshEnvironment = Effect.fn( | ||
| 128 | "web.connectionPlatform.ssh.provisionDesktop", | ||
| 129 | )(function* (bridge: DesktopBridge, target: DesktopSshEnvironmentTarget) { | ||
| 130 | const bootstrap = yield* Effect.tryPromise({ | ||
| 131 | try: () => | ||
| 132 | bridge.ensureSshEnvironment(target, { | ||
| 133 | issuePairingToken: true, | ||
| 134 | }), | ||
| 135 | catch: sshPreparationError, | ||
| 136 | }); | ||
| 137 | const pairingToken = bootstrap.pairingToken; | ||
| 138 | if (pairingToken === null) { | ||
| 139 | return yield* new ConnectionBlockedError({ | ||
| 140 | reason: "authentication", | ||
| 141 | message: "The SSH environment did not issue a pairing credential.", | ||
| 142 | }); | ||
| 143 | } | ||
| 144 | const descriptor = yield* Effect.tryPromise({ | ||
| 145 | try: () => bridge.fetchSshEnvironmentDescriptor(bootstrap.httpBaseUrl), | ||
| 146 | catch: sshPreparationError, | ||
| 147 | }); | ||
| 148 | const access = yield* Effect.tryPromise({ | ||
| 149 | try: () => bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, pairingToken), | ||
| 150 | catch: sshPreparationError, | ||
| 151 | }); | ||
| 152 | return { | ||
| 153 | environmentId: descriptor.environmentId, | ||
| 154 | label: descriptor.label, | ||
| 155 | bootstrap, | ||
| 156 | bearerToken: access.access_token, | ||
| 157 | }; | ||
| 158 | }); | ||
| 159 | |||
| 160 | const capabilitiesLayer = Layer.effectContext( | ||
| 161 | Effect.sync(() => { | ||
| 162 | const presentation = ClientPresentation.of({ | ||
| 163 | metadata: clientMetadata(), | ||
| 164 | scopes: AuthStandardClientScopes, | ||
| 165 | }); | ||
| 166 | const cloudSession = CloudSession.of({ | ||
| 167 | clerkToken: Effect.gen(function* () { | ||
| 168 | const session = appAtomRegistry.get(managedRelaySessionAtom); | ||
| 169 | if (session === null) { | ||
| 170 | return yield* new ConnectionBlockedError({ | ||
| 171 | reason: "authentication", | ||
| 172 | message: "Sign in to T3 Cloud to connect this environment.", | ||
| 173 | }); | ||
| 174 | } | ||
| 175 | const token = yield* session.readClerkToken().pipe( | ||
| 176 | Effect.mapError( | ||
| 177 | (error) => | ||
| 178 | new ConnectionTransientError({ | ||
| 179 | reason: "network", | ||
| 180 | message: error.message, | ||
| 181 | }), | ||
| 182 | ), | ||
| 183 | ); | ||
| 184 | if (token === null) { | ||
| 185 | return yield* new ConnectionBlockedError({ | ||
| 186 | reason: "authentication", | ||
| 187 | message: "The T3 Cloud session is unavailable.", | ||
| 188 | }); | ||
| 189 | } | ||
| 190 | return token; | ||
| 191 | }), | ||
| 192 | }); | ||
| 193 | const identity = RelayDeviceIdentity.of({ | ||
| 194 | deviceId: Effect.succeed(Option.none()), | ||
| 195 | }); | ||
| 196 | const ssh = SshEnvironmentGateway.of({ | ||
| 197 | provision: Effect.fn("web.connectionPlatform.ssh.provision")(function* (target) { | ||
| 198 | const bridge = window.desktopBridge; | ||
| 199 | if (bridge === undefined) { | ||
| 200 | return yield* new ConnectionBlockedError({ | ||
| 201 | reason: "unsupported", | ||
| 202 | message: "SSH environments are only available in the desktop app.", | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | return yield* provisionDesktopSshEnvironment(bridge, target); | ||
| 206 | }), | ||
| 207 | prepare: Effect.fn("web.connectionPlatform.ssh.prepare")(function* (input) { | ||
| 208 | const bridge = window.desktopBridge; | ||
| 209 | if (bridge === undefined) { | ||
| 210 | return yield* new ConnectionBlockedError({ | ||
| 211 | reason: "unsupported", | ||
| 212 | message: "SSH environments are only available in the desktop app.", | ||
| 213 | }); | ||
| 214 | } | ||
| 215 | const bootstrap = yield* Effect.tryPromise({ | ||
| 216 | try: () => | ||
| 217 | bridge.ensureSshEnvironment(input.target, { | ||
| 218 | issuePairingToken: true, | ||
| 219 | }), | ||
| 220 | catch: sshPreparationError, | ||
| 221 | }); | ||
| 222 | if (bootstrap.pairingToken === null) { | ||
| 223 | return yield* new ConnectionBlockedError({ | ||
| 224 | reason: "authentication", | ||
| 225 | message: "The SSH environment did not issue a pairing credential.", | ||
| 226 | }); | ||
| 227 | } | ||
| 228 | const access = yield* Effect.tryPromise({ | ||
| 229 | try: () => | ||
| 230 | bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, bootstrap.pairingToken!), | ||
| 231 | catch: sshPreparationError, | ||
| 232 | }); | ||
| 233 | return { | ||
| 234 | bootstrap, | ||
| 235 | bearerToken: access.access_token, | ||
| 236 | }; | ||
| 237 | }), | ||
| 238 | disconnect: Effect.fn("web.connectionPlatform.ssh.disconnect")(function* (target) { | ||
| 239 | const bridge = window.desktopBridge; | ||
| 240 | if (bridge === undefined) { | ||
| 241 | return; | ||
| 242 | } | ||
| 243 | yield* Effect.tryPromise({ | ||
| 244 | try: () => bridge.disconnectSshEnvironment(target), | ||
| 245 | catch: (cause) => | ||
| 246 | new ConnectionTransientError({ | ||
| 247 | reason: "remote-unavailable", | ||
| 248 | message: `Could not disconnect the SSH environment: ${String(cause)}`, | ||
| 249 | }), | ||
| 250 | }); | ||
| 251 | }), | ||
| 252 | }); | ||
| 253 | |||
| 254 | return Context.make(CloudSession, cloudSession).pipe( | ||
| 255 | Context.add(RelayDeviceIdentity, identity), | ||
| 256 | Context.add(ClientPresentation, presentation), | ||
| 257 | Context.add(SshEnvironmentGateway, ssh), | ||
| 258 | ); | ||
| 259 | }), | ||
| 260 | ); | ||
| 261 | |||
| 262 | const loadPrimaryConnectionRegistration = Effect.fn( | ||
| 263 | "web.connectionPlatform.loadPrimaryConnectionRegistration", | ||
| 264 | )(function* () { | ||
| 265 | const resolved = readPrimaryEnvironmentTarget(); | ||
| 266 | if (resolved === null) { | ||
| 267 | return yield* new ConnectionBlockedError({ | ||
| 268 | reason: "configuration", | ||
| 269 | message: "Unable to resolve the primary environment endpoint.", | ||
| 270 | }); | ||
| 271 | } | ||
| 272 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 273 | httpBaseUrl: resolved.target.httpBaseUrl, | ||
| 274 | }).pipe( | ||
| 275 | Effect.provideService(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), | ||
| 276 | Effect.mapError(mapRemoteEnvironmentError), | ||
| 277 | ); | ||
| 278 | return new PrimaryConnectionRegistration({ | ||
| 279 | target: new PrimaryConnectionTarget({ | ||
| 280 | environmentId: descriptor.environmentId, | ||
| 281 | label: descriptor.label, | ||
| 282 | httpBaseUrl: resolved.target.httpBaseUrl, | ||
| 283 | wsBaseUrl: resolved.target.wsBaseUrl, | ||
| 284 | }), | ||
| 285 | }); | ||
| 286 | }); | ||
| 287 | |||
| 288 | const primaryRegistrationRetrySchedule = Schedule.exponential("1 second").pipe( | ||
| 289 | Schedule.either(Schedule.spaced("16 seconds")), | ||
| 290 | ); | ||
| 291 | |||
| 292 | const platformConnectionSourceLayer = Layer.effect( | ||
| 293 | PlatformConnectionSource, | ||
| 294 | Effect.gen(function* () { | ||
| 295 | if (isHostedStaticApp()) { | ||
| 296 | return PlatformConnectionSource.of({ | ||
| 297 | registrations: Stream.empty, | ||
| 298 | }); | ||
| 299 | } | ||
| 300 | const httpClient = yield* HttpClient.HttpClient; | ||
| 301 | return PlatformConnectionSource.of({ | ||
| 302 | registrations: Stream.fromEffect( | ||
| 303 | loadPrimaryConnectionRegistration().pipe( | ||
| 304 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 305 | ), | ||
| 306 | ).pipe( | ||
| 307 | Stream.tapError((error) => | ||
| 308 | Effect.logWarning("Could not discover the primary environment.", { | ||
| 309 | error, | ||
| 310 | }), | ||
| 311 | ), | ||
| 312 | Stream.retry(primaryRegistrationRetrySchedule), | ||
| 313 | Stream.catchCause(() => Stream.empty), | ||
| 314 | ), | ||
| 315 | }); | ||
| 316 | }), | ||
| 317 | ); | ||
| 318 | |||
| 319 | const environmentOwnedDataCleanupLayer = Layer.succeed( | ||
| 320 | EnvironmentOwnedDataCleanup, | ||
| 321 | EnvironmentOwnedDataCleanup.of({ | ||
| 322 | clear: (environmentId) => | ||
| 323 | Effect.sync(() => { | ||
| 324 | clearComposerDraftsEnvironment(environmentId); | ||
| 325 | }), | ||
| 326 | }), | ||
| 327 | ); | ||
| 328 | |||
| 329 | const rpcRequestObserverLayer = Layer.succeed( | ||
| 330 | EnvironmentRpcRequestObserver, | ||
| 331 | EnvironmentRpcRequestObserver.of({ | ||
| 332 | observe: ({ environmentId, method }) => | ||
| 333 | Effect.sync(() => { | ||
| 334 | nextObservedRpcRequestId += 1; | ||
| 335 | const requestId = `${environmentId}:${nextObservedRpcRequestId}`; | ||
| 336 | trackRpcRequestSent(requestId, `${method} · ${environmentId}`); | ||
| 337 | return Effect.sync(() => { | ||
| 338 | acknowledgeRpcRequest(requestId); | ||
| 339 | }); | ||
| 340 | }), | ||
| 341 | }), | ||
| 342 | ); | ||
| 343 | |||
| 344 | export const connectionPlatformLayer = Layer.mergeAll( | ||
| 345 | connectionStorageLayer, | ||
| 346 | connectivityLayer, | ||
| 347 | wakeupsLayer, | ||
| 348 | capabilitiesLayer, | ||
| 349 | platformConnectionSourceLayer, | ||
| 350 | environmentOwnedDataCleanupLayer, | ||
| 351 | rpcRequestObserverLayer, | ||
| 352 | ); | ||
apps/web/src/connection/runtime.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { runtimeContextLayer } from "../lib/runtime"; | ||
| 6 | import { connectionPlatformLayer } from "./platform"; | ||
| 7 | |||
| 8 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( | ||
| 9 | Layer.provide(runtimeContextLayer), | ||
| 10 | ); | ||
| 11 | |||
| 12 | export const connectionLayer = clientConnectionLayer.pipe( | ||
| 13 | Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), | ||
| 14 | ); | ||
| 15 | |||
| 16 | export const connectionAtomRuntime = Atom.runtime(connectionLayer); | ||
apps/web/src/connection/storage.test.ts created+77| ... | @@ -0,0 +1,77 @@ | ||
| 1 | import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; | ||
| 2 | import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import { afterEach, vi } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import { makeCatalogBackend, makeCatalogStore } from "./storage"; | ||
| 9 | |||
| 10 | const emptyCatalog = { | ||
| 11 | schemaVersion: 1, | ||
| 12 | targets: [], | ||
| 13 | profiles: [], | ||
| 14 | credentials: [], | ||
| 15 | remoteDpopTokens: [], | ||
| 16 | } as const; | ||
| 17 | const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument)); | ||
| 18 | |||
| 19 | afterEach(() => { | ||
| 20 | vi.unstubAllGlobals(); | ||
| 21 | vi.restoreAllMocks(); | ||
| 22 | }); | ||
| 23 | |||
| 24 | describe("makeCatalogStore", () => { | ||
| 25 | it.effect("quarantines malformed catalogs and starts from an empty document", () => | ||
| 26 | Effect.gen(function* () { | ||
| 27 | const writes: string[] = []; | ||
| 28 | const quarantined: string[] = []; | ||
| 29 | const store = yield* makeCatalogStore({ | ||
| 30 | read: Effect.succeed("{not-json"), | ||
| 31 | write: (raw) => Effect.sync(() => writes.push(raw)), | ||
| 32 | quarantine: (raw) => Effect.sync(() => quarantined.push(raw)), | ||
| 33 | }); | ||
| 34 | |||
| 35 | expect(yield* store.read).toEqual(emptyCatalog); | ||
| 36 | expect(quarantined).toEqual(["{not-json"]); | ||
| 37 | expect(writes).toHaveLength(1); | ||
| 38 | expect(decodeCatalog(writes[0]!)).toEqual(emptyCatalog); | ||
| 39 | }), | ||
| 40 | ); | ||
| 41 | |||
| 42 | it.effect("does not hide catalog read failures", () => | ||
| 43 | Effect.gen(function* () { | ||
| 44 | const failure = new ConnectionTransientError({ | ||
| 45 | reason: "remote-unavailable", | ||
| 46 | message: "permission denied", | ||
| 47 | }); | ||
| 48 | const store = yield* makeCatalogStore({ | ||
| 49 | read: Effect.fail(failure), | ||
| 50 | write: () => Effect.void, | ||
| 51 | }); | ||
| 52 | |||
| 53 | expect(yield* Effect.flip(store.read)).toBe(failure); | ||
| 54 | }), | ||
| 55 | ); | ||
| 56 | }); | ||
| 57 | |||
| 58 | describe("makeCatalogBackend", () => { | ||
| 59 | it.effect("fails writes when desktop secure storage declines the catalog", () => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const setConnectionCatalog = vi.fn().mockResolvedValue(false); | ||
| 62 | vi.stubGlobal("window", { | ||
| 63 | desktopBridge: { | ||
| 64 | getConnectionCatalog: vi.fn().mockResolvedValue(null), | ||
| 65 | setConnectionCatalog, | ||
| 66 | }, | ||
| 67 | }); | ||
| 68 | const backend = makeCatalogBackend({} as IDBDatabase); | ||
| 69 | |||
| 70 | const error = yield* backend.write("{}").pipe(Effect.flip); | ||
| 71 | |||
| 72 | expect(error).toBeInstanceOf(ConnectionTransientError); | ||
| 73 | expect(error.message).toContain("Desktop secure storage is unavailable"); | ||
| 74 | expect(setConnectionCatalog).toHaveBeenCalledWith("{}"); | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | }); | ||
apps/web/src/connection/storage.ts created+536| ... | @@ -0,0 +1,536 @@ | ||
| 1 | import { | ||
| 2 | ConnectionCatalogDocument, | ||
| 3 | type ConnectionCatalogDocument as ConnectionCatalogDocumentType, | ||
| 4 | ConnectionPersistenceError, | ||
| 5 | ConnectionRegistrationStore, | ||
| 6 | ConnectionTargetStore, | ||
| 7 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 8 | EnvironmentCacheStore, | ||
| 9 | registerConnectionInCatalog, | ||
| 10 | removeCatalogValue, | ||
| 11 | removeConnectionFromCatalog, | ||
| 12 | replaceCatalogValue, | ||
| 13 | } from "@t3tools/client-runtime/platform"; | ||
| 14 | import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; | ||
| 15 | import { | ||
| 16 | ConnectionCredentialStore, | ||
| 17 | ConnectionProfileStore, | ||
| 18 | ConnectionTransientError, | ||
| 19 | } from "@t3tools/client-runtime/connection"; | ||
| 20 | import { | ||
| 21 | EnvironmentId, | ||
| 22 | OrchestrationShellSnapshot, | ||
| 23 | OrchestrationThread, | ||
| 24 | ThreadId, | ||
| 25 | } from "@t3tools/contracts"; | ||
| 26 | import * as Context from "effect/Context"; | ||
| 27 | import * as Effect from "effect/Effect"; | ||
| 28 | import * as Layer from "effect/Layer"; | ||
| 29 | import * as Option from "effect/Option"; | ||
| 30 | import * as Ref from "effect/Ref"; | ||
| 31 | import * as Schema from "effect/Schema"; | ||
| 32 | import * as Semaphore from "effect/Semaphore"; | ||
| 33 | |||
| 34 | const DATABASE_NAME = "t3code:connection-runtime"; | ||
| 35 | const DATABASE_VERSION = 2; | ||
| 36 | const CATALOG_STORE_NAME = "catalog"; | ||
| 37 | const SHELL_STORE_NAME = "shell"; | ||
| 38 | const THREAD_STORE_NAME = "thread"; | ||
| 39 | const CATALOG_KEY = "document"; | ||
| 40 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | ||
| 41 | |||
| 42 | const StoredShellSnapshot = Schema.Struct({ | ||
| 43 | schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), | ||
| 44 | environmentId: EnvironmentId, | ||
| 45 | snapshot: OrchestrationShellSnapshot, | ||
| 46 | }); | ||
| 47 | const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); | ||
| 48 | const StoredThreadSnapshot = Schema.Struct({ | ||
| 49 | schemaVersion: Schema.Literal(1), | ||
| 50 | environmentId: EnvironmentId, | ||
| 51 | threadId: ThreadId, | ||
| 52 | thread: OrchestrationThread, | ||
| 53 | }); | ||
| 54 | const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); | ||
| 55 | const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); | ||
| 56 | const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); | ||
| 57 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); | ||
| 58 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); | ||
| 59 | const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); | ||
| 60 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); | ||
| 61 | const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); | ||
| 62 | |||
| 63 | function catalogError(operation: string, cause: unknown) { | ||
| 64 | return new ConnectionTransientError({ | ||
| 65 | reason: "remote-unavailable", | ||
| 66 | message: `Could not ${operation} the local connection catalog: ${String(cause)}`, | ||
| 67 | }); | ||
| 68 | } | ||
| 69 | |||
| 70 | function persistenceError( | ||
| 71 | operation: | ||
| 72 | | "list-targets" | ||
| 73 | | "register-connection" | ||
| 74 | | "remove-connection" | ||
| 75 | | "load-shell" | ||
| 76 | | "save-shell" | ||
| 77 | | "load-thread" | ||
| 78 | | "save-thread" | ||
| 79 | | "remove-thread" | ||
| 80 | | "clear-environment", | ||
| 81 | cause: unknown, | ||
| 82 | ) { | ||
| 83 | return new ConnectionPersistenceError({ | ||
| 84 | operation, | ||
| 85 | message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | |||
| 89 | const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* () { | ||
| 90 | return yield* Effect.callback<IDBDatabase, ConnectionTransientError>((resume) => { | ||
| 91 | if (typeof indexedDB === "undefined") { | ||
| 92 | resume( | ||
| 93 | Effect.fail(catalogError("open", "IndexedDB is unavailable in this browser context.")), | ||
| 94 | ); | ||
| 95 | return; | ||
| 96 | } | ||
| 97 | const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); | ||
| 98 | request.addEventListener("upgradeneeded", () => { | ||
| 99 | if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) { | ||
| 100 | request.result.createObjectStore(CATALOG_STORE_NAME); | ||
| 101 | } | ||
| 102 | if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) { | ||
| 103 | request.result.createObjectStore(SHELL_STORE_NAME); | ||
| 104 | } | ||
| 105 | if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { | ||
| 106 | request.result.createObjectStore(THREAD_STORE_NAME); | ||
| 107 | } | ||
| 108 | }); | ||
| 109 | request.addEventListener("error", () => { | ||
| 110 | resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); | ||
| 111 | }); | ||
| 112 | request.addEventListener("success", () => { | ||
| 113 | resume(Effect.succeed(request.result)); | ||
| 114 | }); | ||
| 115 | }); | ||
| 116 | }); | ||
| 117 | |||
| 118 | function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { | ||
| 119 | return Effect.callback<unknown, ConnectionTransientError>((resume) => { | ||
| 120 | const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); | ||
| 121 | request.addEventListener("error", () => { | ||
| 122 | resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error"))); | ||
| 123 | }); | ||
| 124 | request.addEventListener("success", () => { | ||
| 125 | resume(Effect.succeed(request.result)); | ||
| 126 | }); | ||
| 127 | }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue")); | ||
| 128 | } | ||
| 129 | |||
| 130 | function writeDatabaseValue( | ||
| 131 | database: IDBDatabase, | ||
| 132 | storeName: string, | ||
| 133 | key: IDBValidKey, | ||
| 134 | value: unknown, | ||
| 135 | ) { | ||
| 136 | return Effect.callback<void, ConnectionTransientError>((resume) => { | ||
| 137 | const transaction = database.transaction(storeName, "readwrite"); | ||
| 138 | transaction.addEventListener("error", () => { | ||
| 139 | resume( | ||
| 140 | Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), | ||
| 141 | ); | ||
| 142 | }); | ||
| 143 | transaction.addEventListener("complete", () => { | ||
| 144 | resume(Effect.void); | ||
| 145 | }); | ||
| 146 | transaction.objectStore(storeName).put(value, key); | ||
| 147 | }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue")); | ||
| 148 | } | ||
| 149 | |||
| 150 | function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { | ||
| 151 | return Effect.callback<void, ConnectionTransientError>((resume) => { | ||
| 152 | const transaction = database.transaction(storeName, "readwrite"); | ||
| 153 | transaction.addEventListener("error", () => { | ||
| 154 | resume( | ||
| 155 | Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error")), | ||
| 156 | ); | ||
| 157 | }); | ||
| 158 | transaction.addEventListener("complete", () => { | ||
| 159 | resume(Effect.void); | ||
| 160 | }); | ||
| 161 | transaction.objectStore(storeName).delete(key); | ||
| 162 | }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValue")); | ||
| 163 | } | ||
| 164 | |||
| 165 | function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, range: IDBKeyRange) { | ||
| 166 | return Effect.callback<void, ConnectionTransientError>((resume) => { | ||
| 167 | const transaction = database.transaction(storeName, "readwrite"); | ||
| 168 | transaction.addEventListener("error", () => { | ||
| 169 | resume( | ||
| 170 | Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error")), | ||
| 171 | ); | ||
| 172 | }); | ||
| 173 | transaction.addEventListener("complete", () => { | ||
| 174 | resume(Effect.void); | ||
| 175 | }); | ||
| 176 | const request = transaction.objectStore(storeName).openCursor(range); | ||
| 177 | request.addEventListener("error", () => { | ||
| 178 | resume( | ||
| 179 | Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")), | ||
| 180 | ); | ||
| 181 | }); | ||
| 182 | request.addEventListener("success", () => { | ||
| 183 | const cursor = request.result; | ||
| 184 | if (cursor === null) { | ||
| 185 | return; | ||
| 186 | } | ||
| 187 | cursor.delete(); | ||
| 188 | cursor.continue(); | ||
| 189 | }); | ||
| 190 | }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange")); | ||
| 191 | } | ||
| 192 | |||
| 193 | function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { | ||
| 194 | return `${environmentId}:${threadId}`; | ||
| 195 | } | ||
| 196 | |||
| 197 | const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) { | ||
| 198 | return yield* decodeConnectionCatalogDocument(raw).pipe( | ||
| 199 | Effect.mapError((cause) => catalogError("decode", cause)), | ||
| 200 | ); | ||
| 201 | }); | ||
| 202 | |||
| 203 | const encodeCatalog = Effect.fn("web.connectionStorage.encodeCatalog")(function* ( | ||
| 204 | catalog: ConnectionCatalogDocumentType, | ||
| 205 | ) { | ||
| 206 | return yield* encodeConnectionCatalogDocument(catalog).pipe( | ||
| 207 | Effect.mapError((cause) => catalogError("encode", cause)), | ||
| 208 | ); | ||
| 209 | }); | ||
| 210 | |||
| 211 | export interface CatalogBackend { | ||
| 212 | readonly read: Effect.Effect<string | null, ConnectionTransientError>; | ||
| 213 | readonly write: (raw: string) => Effect.Effect<void, ConnectionTransientError>; | ||
| 214 | readonly quarantine?: (raw: string) => Effect.Effect<void, ConnectionTransientError>; | ||
| 215 | } | ||
| 216 | |||
| 217 | export function makeCatalogBackend(database: IDBDatabase): CatalogBackend { | ||
| 218 | const bridge = window.desktopBridge; | ||
| 219 | if (bridge?.getConnectionCatalog !== undefined && bridge.setConnectionCatalog !== undefined) { | ||
| 220 | return { | ||
| 221 | read: Effect.tryPromise({ | ||
| 222 | try: () => bridge.getConnectionCatalog!(), | ||
| 223 | catch: (cause) => catalogError("load", cause), | ||
| 224 | }), | ||
| 225 | write: (raw) => | ||
| 226 | Effect.tryPromise({ | ||
| 227 | try: () => bridge.setConnectionCatalog!(raw), | ||
| 228 | catch: (cause) => catalogError("save", cause), | ||
| 229 | }).pipe( | ||
| 230 | Effect.flatMap((stored) => | ||
| 231 | stored | ||
| 232 | ? Effect.void | ||
| 233 | : Effect.fail( | ||
| 234 | catalogError( | ||
| 235 | "save", | ||
| 236 | "Desktop secure storage is unavailable in this system context.", | ||
| 237 | ), | ||
| 238 | ), | ||
| 239 | ), | ||
| 240 | ), | ||
| 241 | }; | ||
| 242 | } | ||
| 243 | |||
| 244 | return { | ||
| 245 | read: readDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY).pipe( | ||
| 246 | Effect.map((value) => (typeof value === "string" ? value : null)), | ||
| 247 | ), | ||
| 248 | write: (raw) => writeDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY, raw), | ||
| 249 | quarantine: (raw) => | ||
| 250 | writeDatabaseValue(database, CATALOG_STORE_NAME, `${CATALOG_KEY}:corrupt:${Date.now()}`, raw), | ||
| 251 | }; | ||
| 252 | } | ||
| 253 | |||
| 254 | interface CatalogStore { | ||
| 255 | readonly read: Effect.Effect<ConnectionCatalogDocumentType, ConnectionTransientError>; | ||
| 256 | readonly update: ( | ||
| 257 | transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, | ||
| 258 | ) => Effect.Effect<void, ConnectionTransientError>; | ||
| 259 | } | ||
| 260 | |||
| 261 | export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStore")(function* ( | ||
| 262 | backend: CatalogBackend, | ||
| 263 | ) { | ||
| 264 | const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none()); | ||
| 265 | const lock = yield* Semaphore.make(1); | ||
| 266 | |||
| 267 | const loadUnlocked = Effect.fn("web.connectionStorage.loadCatalog")(function* () { | ||
| 268 | const cached = yield* Ref.get(state); | ||
| 269 | if (Option.isSome(cached)) { | ||
| 270 | return cached.value; | ||
| 271 | } | ||
| 272 | const raw = yield* backend.read; | ||
| 273 | let catalog = EMPTY_CONNECTION_CATALOG_DOCUMENT; | ||
| 274 | if (raw !== null && raw.trim() !== "") { | ||
| 275 | catalog = yield* decodeCatalog(raw).pipe( | ||
| 276 | Effect.catch((error) => | ||
| 277 | Effect.gen(function* () { | ||
| 278 | yield* Effect.logWarning("Discarding a corrupt web connection catalog.", { | ||
| 279 | error: error.message, | ||
| 280 | }); | ||
| 281 | if (backend.quarantine !== undefined) { | ||
| 282 | yield* backend.quarantine(raw).pipe( | ||
| 283 | Effect.catch((cause) => | ||
| 284 | Effect.logWarning("Could not quarantine the corrupt web connection catalog.", { | ||
| 285 | error: cause.message, | ||
| 286 | }), | ||
| 287 | ), | ||
| 288 | ); | ||
| 289 | } | ||
| 290 | const encoded = yield* encodeCatalog(EMPTY_CONNECTION_CATALOG_DOCUMENT); | ||
| 291 | yield* backend.write(encoded).pipe( | ||
| 292 | Effect.catch((cause) => | ||
| 293 | Effect.logWarning("Could not persist the recovered web connection catalog.", { | ||
| 294 | error: cause.message, | ||
| 295 | }), | ||
| 296 | ), | ||
| 297 | ); | ||
| 298 | return EMPTY_CONNECTION_CATALOG_DOCUMENT; | ||
| 299 | }), | ||
| 300 | ), | ||
| 301 | ); | ||
| 302 | } | ||
| 303 | yield* Ref.set(state, Option.some(catalog)); | ||
| 304 | return catalog; | ||
| 305 | }); | ||
| 306 | |||
| 307 | const read = lock.withPermits(1)(loadUnlocked()); | ||
| 308 | const update: CatalogStore["update"] = Effect.fn("web.connectionStorage.updateCatalog")( | ||
| 309 | function* (transform) { | ||
| 310 | yield* lock.withPermits(1)( | ||
| 311 | Effect.gen(function* () { | ||
| 312 | const next = transform(yield* loadUnlocked()); | ||
| 313 | yield* backend.write(yield* encodeCatalog(next)); | ||
| 314 | yield* Ref.set(state, Option.some(next)); | ||
| 315 | }), | ||
| 316 | ); | ||
| 317 | }, | ||
| 318 | ); | ||
| 319 | |||
| 320 | return { read, update } satisfies CatalogStore; | ||
| 321 | }); | ||
| 322 | |||
| 323 | export const connectionStorageLayer = Layer.effectContext( | ||
| 324 | Effect.gen(function* () { | ||
| 325 | const database = yield* Effect.acquireRelease(openDatabase(), (database) => | ||
| 326 | Effect.sync(() => database.close()), | ||
| 327 | ); | ||
| 328 | const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); | ||
| 329 | |||
| 330 | const targetStore = ConnectionTargetStore.of({ | ||
| 331 | list: catalog.read.pipe( | ||
| 332 | Effect.map((document) => document.targets), | ||
| 333 | Effect.mapError((cause) => persistenceError("list-targets", cause)), | ||
| 334 | ), | ||
| 335 | }); | ||
| 336 | const registrationStore = ConnectionRegistrationStore.of({ | ||
| 337 | register: (registration) => | ||
| 338 | catalog | ||
| 339 | .update((document) => registerConnectionInCatalog(document, registration)) | ||
| 340 | .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))), | ||
| 341 | remove: (target) => | ||
| 342 | catalog | ||
| 343 | .update((document) => removeConnectionFromCatalog(document, target)) | ||
| 344 | .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), | ||
| 345 | }); | ||
| 346 | const profileStore = ConnectionProfileStore.of({ | ||
| 347 | get: (connectionId) => | ||
| 348 | catalog.read.pipe( | ||
| 349 | Effect.map((document) => | ||
| 350 | Option.fromUndefinedOr( | ||
| 351 | document.profiles.find((profile) => profile.connectionId === connectionId), | ||
| 352 | ), | ||
| 353 | ), | ||
| 354 | ), | ||
| 355 | put: (profile) => | ||
| 356 | catalog.update((document) => ({ | ||
| 357 | ...document, | ||
| 358 | profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile), | ||
| 359 | })), | ||
| 360 | remove: (connectionId) => | ||
| 361 | catalog.update((document) => ({ | ||
| 362 | ...document, | ||
| 363 | profiles: removeCatalogValue( | ||
| 364 | document.profiles, | ||
| 365 | (value) => value.connectionId, | ||
| 366 | connectionId, | ||
| 367 | ), | ||
| 368 | })), | ||
| 369 | }); | ||
| 370 | const credentialStore = ConnectionCredentialStore.of({ | ||
| 371 | get: (connectionId) => | ||
| 372 | catalog.read.pipe( | ||
| 373 | Effect.map((document) => | ||
| 374 | Option.fromUndefinedOr( | ||
| 375 | document.credentials.find((entry) => entry.connectionId === connectionId)?.credential, | ||
| 376 | ), | ||
| 377 | ), | ||
| 378 | ), | ||
| 379 | put: (connectionId, credential) => | ||
| 380 | catalog.update((document) => ({ | ||
| 381 | ...document, | ||
| 382 | credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, { | ||
| 383 | connectionId, | ||
| 384 | credential, | ||
| 385 | }), | ||
| 386 | })), | ||
| 387 | remove: (connectionId) => | ||
| 388 | catalog.update((document) => ({ | ||
| 389 | ...document, | ||
| 390 | credentials: removeCatalogValue( | ||
| 391 | document.credentials, | ||
| 392 | (value) => value.connectionId, | ||
| 393 | connectionId, | ||
| 394 | ), | ||
| 395 | })), | ||
| 396 | }); | ||
| 397 | const remoteTokenStore = RemoteDpopAccessTokenStore.of({ | ||
| 398 | get: (environmentId) => | ||
| 399 | catalog.read.pipe( | ||
| 400 | Effect.map((document) => | ||
| 401 | Option.fromUndefinedOr( | ||
| 402 | document.remoteDpopTokens.find((token) => token.environmentId === environmentId), | ||
| 403 | ), | ||
| 404 | ), | ||
| 405 | ), | ||
| 406 | put: (token) => | ||
| 407 | catalog.update((document) => ({ | ||
| 408 | ...document, | ||
| 409 | remoteDpopTokens: replaceCatalogValue( | ||
| 410 | document.remoteDpopTokens, | ||
| 411 | (value) => value.environmentId, | ||
| 412 | token, | ||
| 413 | ), | ||
| 414 | })), | ||
| 415 | remove: (environmentId) => | ||
| 416 | catalog.update((document) => ({ | ||
| 417 | ...document, | ||
| 418 | remoteDpopTokens: removeCatalogValue( | ||
| 419 | document.remoteDpopTokens, | ||
| 420 | (value) => value.environmentId, | ||
| 421 | environmentId, | ||
| 422 | ), | ||
| 423 | })), | ||
| 424 | }); | ||
| 425 | const cacheStore = EnvironmentCacheStore.of({ | ||
| 426 | loadShell: (environmentId) => | ||
| 427 | readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( | ||
| 428 | Effect.flatMap((raw) => { | ||
| 429 | if (typeof raw !== "string") { | ||
| 430 | return Effect.succeed(Option.none()); | ||
| 431 | } | ||
| 432 | return decodeStoredShellSnapshot(raw).pipe( | ||
| 433 | Effect.mapError((cause) => persistenceError("load-shell", cause)), | ||
| 434 | Effect.map((stored) => | ||
| 435 | stored.environmentId === environmentId | ||
| 436 | ? Option.some(stored.snapshot) | ||
| 437 | : Option.none(), | ||
| 438 | ), | ||
| 439 | ); | ||
| 440 | }), | ||
| 441 | Effect.mapError((cause) => | ||
| 442 | cause._tag === "ConnectionPersistenceError" | ||
| 443 | ? cause | ||
| 444 | : persistenceError("load-shell", cause), | ||
| 445 | ), | ||
| 446 | ), | ||
| 447 | saveShell: (environmentId, snapshot) => | ||
| 448 | Effect.gen(function* () { | ||
| 449 | const encoded = yield* encodeStoredShellSnapshot({ | ||
| 450 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | ||
| 451 | environmentId, | ||
| 452 | snapshot, | ||
| 453 | }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); | ||
| 454 | yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); | ||
| 455 | }).pipe( | ||
| 456 | Effect.mapError((cause) => | ||
| 457 | cause._tag === "ConnectionPersistenceError" | ||
| 458 | ? cause | ||
| 459 | : persistenceError("save-shell", cause), | ||
| 460 | ), | ||
| 461 | ), | ||
| 462 | loadThread: (environmentId, threadId) => | ||
| 463 | readDatabaseValue( | ||
| 464 | database, | ||
| 465 | THREAD_STORE_NAME, | ||
| 466 | threadCacheKey(environmentId, threadId), | ||
| 467 | ).pipe( | ||
| 468 | Effect.flatMap((raw) => { | ||
| 469 | if (typeof raw !== "string") { | ||
| 470 | return Effect.succeed(Option.none()); | ||
| 471 | } | ||
| 472 | return decodeStoredThreadSnapshot(raw).pipe( | ||
| 473 | Effect.mapError((cause) => persistenceError("load-thread", cause)), | ||
| 474 | Effect.map((stored) => | ||
| 475 | stored.environmentId === environmentId && stored.threadId === threadId | ||
| 476 | ? Option.some(stored.thread) | ||
| 477 | : Option.none(), | ||
| 478 | ), | ||
| 479 | ); | ||
| 480 | }), | ||
| 481 | Effect.mapError((cause) => | ||
| 482 | cause._tag === "ConnectionPersistenceError" | ||
| 483 | ? cause | ||
| 484 | : persistenceError("load-thread", cause), | ||
| 485 | ), | ||
| 486 | ), | ||
| 487 | saveThread: (environmentId, thread) => | ||
| 488 | Effect.gen(function* () { | ||
| 489 | const encoded = yield* encodeStoredThreadSnapshot({ | ||
| 490 | schemaVersion: 1, | ||
| 491 | environmentId, | ||
| 492 | threadId: thread.id, | ||
| 493 | thread, | ||
| 494 | }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); | ||
| 495 | yield* writeDatabaseValue( | ||
| 496 | database, | ||
| 497 | THREAD_STORE_NAME, | ||
| 498 | threadCacheKey(environmentId, thread.id), | ||
| 499 | encoded, | ||
| 500 | ); | ||
| 501 | }).pipe( | ||
| 502 | Effect.mapError((cause) => | ||
| 503 | cause._tag === "ConnectionPersistenceError" | ||
| 504 | ? cause | ||
| 505 | : persistenceError("save-thread", cause), | ||
| 506 | ), | ||
| 507 | ), | ||
| 508 | removeThread: (environmentId, threadId) => | ||
| 509 | removeDatabaseValue( | ||
| 510 | database, | ||
| 511 | THREAD_STORE_NAME, | ||
| 512 | threadCacheKey(environmentId, threadId), | ||
| 513 | ).pipe(Effect.mapError((cause) => persistenceError("remove-thread", cause))), | ||
| 514 | clear: (environmentId) => | ||
| 515 | Effect.all( | ||
| 516 | [ | ||
| 517 | removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), | ||
| 518 | removeDatabaseValuesInRange( | ||
| 519 | database, | ||
| 520 | THREAD_STORE_NAME, | ||
| 521 | IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), | ||
| 522 | ), | ||
| 523 | ], | ||
| 524 | { concurrency: "unbounded", discard: true }, | ||
| 525 | ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), | ||
| 526 | }); | ||
| 527 | |||
| 528 | return Context.make(ConnectionTargetStore, targetStore).pipe( | ||
| 529 | Context.add(ConnectionRegistrationStore, registrationStore), | ||
| 530 | Context.add(ConnectionProfileStore, profileStore), | ||
| 531 | Context.add(ConnectionCredentialStore, credentialStore), | ||
| 532 | Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), | ||
| 533 | Context.add(EnvironmentCacheStore, cacheStore), | ||
| 534 | ); | ||
| 535 | }), | ||
| 536 | ); | ||
apps/web/src/diffFileActions.test.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 |
apps/web/src/editorPreferences.ts+63-8| ... | @@ -1,9 +1,25 @@ | ... | @@ -1,9 +1,25 @@ |
| 1 | import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; | 1 | import { EDITORS, EditorId, type EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { | ||
| 3 | mapAtomCommandResult, | ||
| 4 | type AtomCommandFailure, | ||
| 5 | type AtomCommandResult, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import * as Cause from "effect/Cause"; | ||
| 8 | import * as Data from "effect/Data"; | ||
| 9 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 2 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; | 10 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; |
| 3 | import { useMemo } from "react"; | 11 | import { useCallback, useMemo } from "react"; |
| 12 | import { shellEnvironment } from "./state/shell"; | ||
| 13 | import { useAtomCommand } from "./state/use-atom-command"; | ||
| 4 | 14 | ||
| 5 | const LAST_EDITOR_KEY = "t3code:last-editor"; | 15 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 6 | 16 | ||
| 17 | export class PreferredEditorUnavailableError extends Data.TaggedError( | ||
| 18 | "PreferredEditorUnavailableError", | ||
| 19 | )<{ | ||
| 20 | readonly message: string; | ||
| 21 | }> {} | ||
| 22 | |||
| 7 | export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) { | 23 | export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) { |
| 8 | const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); | 24 | const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); |
| 9 | 25 | ||
| ... | @@ -26,10 +42,49 @@ export function resolveAndPersistPreferredEditor( | ... | @@ -26,10 +42,49 @@ export function resolveAndPersistPreferredEditor( |
| 26 | return editor ?? null; | 42 | return editor ?? null; |
| 27 | } | 43 | } |
| 28 | 44 | ||
| 29 | export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise<EditorId> { | 45 | export function useOpenInPreferredEditor( |
| 30 | const { availableEditors } = await api.server.getConfig(); | 46 | environmentId: EnvironmentId | null, |
| 31 | const editor = resolveAndPersistPreferredEditor(availableEditors); | 47 | availableEditors: readonly EditorId[], |
| 32 | if (!editor) throw new Error("No available editors found."); | 48 | ) { |
| 33 | await api.shell.openInEditor(targetPath, editor); | 49 | const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { |
| 34 | return editor; | 50 | reportFailure: false, |
| 51 | }); | ||
| 52 | type OpenInEditorError = AtomCommandFailure<Awaited<ReturnType<typeof openInEditor>>>; | ||
| 53 | |||
| 54 | return useCallback( | ||
| 55 | async ( | ||
| 56 | targetPath: string, | ||
| 57 | ): Promise< | ||
| 58 | AtomCommandResult<EditorId, OpenInEditorError | PreferredEditorUnavailableError> | ||
| 59 | > => { | ||
| 60 | if (environmentId === null) { | ||
| 61 | return AsyncResult.failure( | ||
| 62 | Cause.fail( | ||
| 63 | new PreferredEditorUnavailableError({ | ||
| 64 | message: "No environment is selected.", | ||
| 65 | }), | ||
| 66 | ), | ||
| 67 | ); | ||
| 68 | } | ||
| 69 | const editor = resolveAndPersistPreferredEditor(availableEditors); | ||
| 70 | if (!editor) { | ||
| 71 | return AsyncResult.failure( | ||
| 72 | Cause.fail( | ||
| 73 | new PreferredEditorUnavailableError({ | ||
| 74 | message: "No available editors found.", | ||
| 75 | }), | ||
| 76 | ), | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | const result = await openInEditor({ | ||
| 80 | environmentId, | ||
| 81 | input: { | ||
| 82 | cwd: targetPath, | ||
| 83 | editor, | ||
| 84 | }, | ||
| 85 | }); | ||
| 86 | return mapAtomCommandResult(result, () => editor); | ||
| 87 | }, | ||
| 88 | [availableEditors, environmentId, openInEditor], | ||
| 89 | ); | ||
| 35 | } | 90 | } |
apps/web/src/environmentApi.ts deleted-121| ... | @@ -1,121 +0,0 @@ | ||
| 1 | import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import type { WsRpcClient } from "@t3tools/client-runtime"; | ||
| 4 | import { readEnvironmentConnection } from "./environments/runtime"; | ||
| 5 | |||
| 6 | const environmentApiOverridesForTests = new Map<EnvironmentId, EnvironmentApi>(); | ||
| 7 | |||
| 8 | export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { | ||
| 9 | return { | ||
| 10 | terminal: { | ||
| 11 | open: (input) => rpcClient.terminal.open(input as never), | ||
| 12 | attach: (input, callback, options) => | ||
| 13 | rpcClient.terminal.attach(input as never, callback, options), | ||
| 14 | write: (input) => rpcClient.terminal.write(input as never), | ||
| 15 | resize: (input) => rpcClient.terminal.resize(input as never), | ||
| 16 | clear: (input) => rpcClient.terminal.clear(input as never), | ||
| 17 | restart: (input) => rpcClient.terminal.restart(input as never), | ||
| 18 | close: (input) => rpcClient.terminal.close(input as never), | ||
| 19 | onMetadata: (callback, options) => rpcClient.terminal.onMetadata(callback, options), | ||
| 20 | }, | ||
| 21 | projects: { | ||
| 22 | listEntries: rpcClient.projects.listEntries, | ||
| 23 | readFile: rpcClient.projects.readFile, | ||
| 24 | searchEntries: rpcClient.projects.searchEntries, | ||
| 25 | writeFile: rpcClient.projects.writeFile, | ||
| 26 | }, | ||
| 27 | filesystem: { | ||
| 28 | browse: rpcClient.filesystem.browse, | ||
| 29 | }, | ||
| 30 | assets: { | ||
| 31 | createUrl: rpcClient.assets.createUrl, | ||
| 32 | }, | ||
| 33 | sourceControl: { | ||
| 34 | lookupRepository: rpcClient.sourceControl.lookupRepository, | ||
| 35 | cloneRepository: rpcClient.sourceControl.cloneRepository, | ||
| 36 | publishRepository: rpcClient.sourceControl.publishRepository, | ||
| 37 | }, | ||
| 38 | vcs: { | ||
| 39 | pull: rpcClient.vcs.pull, | ||
| 40 | refreshStatus: rpcClient.vcs.refreshStatus, | ||
| 41 | onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options), | ||
| 42 | listRefs: rpcClient.vcs.listRefs, | ||
| 43 | createWorktree: rpcClient.vcs.createWorktree, | ||
| 44 | removeWorktree: rpcClient.vcs.removeWorktree, | ||
| 45 | createRef: rpcClient.vcs.createRef, | ||
| 46 | switchRef: rpcClient.vcs.switchRef, | ||
| 47 | init: rpcClient.vcs.init, | ||
| 48 | }, | ||
| 49 | git: { | ||
| 50 | resolvePullRequest: rpcClient.git.resolvePullRequest, | ||
| 51 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, | ||
| 52 | }, | ||
| 53 | review: { | ||
| 54 | getDiffPreview: rpcClient.review.getDiffPreview, | ||
| 55 | }, | ||
| 56 | orchestration: { | ||
| 57 | dispatchCommand: rpcClient.orchestration.dispatchCommand, | ||
| 58 | getTurnDiff: rpcClient.orchestration.getTurnDiff, | ||
| 59 | getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, | ||
| 60 | getArchivedShellSnapshot: rpcClient.orchestration.getArchivedShellSnapshot, | ||
| 61 | subscribeShell: (callback, options) => | ||
| 62 | rpcClient.orchestration.subscribeShell(callback, options), | ||
| 63 | subscribeThread: (input, callback, options) => | ||
| 64 | rpcClient.orchestration.subscribeThread(input, callback, options), | ||
| 65 | }, | ||
| 66 | preview: { | ||
| 67 | open: (input) => rpcClient.preview.open(input as never), | ||
| 68 | navigate: (input) => rpcClient.preview.navigate(input as never), | ||
| 69 | refresh: (input) => rpcClient.preview.refresh(input as never), | ||
| 70 | close: (input) => rpcClient.preview.close(input as never), | ||
| 71 | list: (input) => rpcClient.preview.list(input as never), | ||
| 72 | reportStatus: (input) => rpcClient.preview.reportStatus(input as never), | ||
| 73 | automation: { | ||
| 74 | connect: (input, callback, options) => | ||
| 75 | rpcClient.preview.automation.connect(input as never, callback, options), | ||
| 76 | respond: (response) => rpcClient.preview.automation.respond(response as never), | ||
| 77 | reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never), | ||
| 78 | clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never), | ||
| 79 | }, | ||
| 80 | onEvent: (callback, options) => rpcClient.preview.onEvent(callback, options), | ||
| 81 | subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), | ||
| 82 | }, | ||
| 83 | }; | ||
| 84 | } | ||
| 85 | |||
| 86 | export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | undefined { | ||
| 87 | if (typeof window === "undefined") { | ||
| 88 | return undefined; | ||
| 89 | } | ||
| 90 | |||
| 91 | if (!environmentId) { | ||
| 92 | return undefined; | ||
| 93 | } | ||
| 94 | |||
| 95 | const overriddenApi = environmentApiOverridesForTests.get(environmentId); | ||
| 96 | if (overriddenApi) { | ||
| 97 | return overriddenApi; | ||
| 98 | } | ||
| 99 | |||
| 100 | const connection = readEnvironmentConnection(environmentId); | ||
| 101 | return connection ? createEnvironmentApi(connection.client) : undefined; | ||
| 102 | } | ||
| 103 | |||
| 104 | export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { | ||
| 105 | const api = readEnvironmentApi(environmentId); | ||
| 106 | if (!api) { | ||
| 107 | throw new Error(`Environment API not found for environment ${environmentId}`); | ||
| 108 | } | ||
| 109 | return api; | ||
| 110 | } | ||
| 111 | |||
| 112 | export function __setEnvironmentApiOverrideForTests( | ||
| 113 | environmentId: EnvironmentId, | ||
| 114 | api: EnvironmentApi, | ||
| 115 | ): void { | ||
| 116 | environmentApiOverridesForTests.set(environmentId, api); | ||
| 117 | } | ||
| 118 | |||
| 119 | export function __resetEnvironmentApiOverridesForTests(): void { | ||
| 120 | environmentApiOverridesForTests.clear(); | ||
| 121 | } | ||
apps/web/src/environmentGrouping.test.ts+82-574| ... | @@ -1,54 +1,40 @@ | ... | @@ -1,54 +1,40 @@ |
| 1 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { scopeProjectRef } from "@t3tools/client-runtime"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 3 | ||
| 5 | import { | ||
| 6 | selectProjectsAcrossEnvironments, | ||
| 7 | selectSidebarThreadsAcrossEnvironments, | ||
| 8 | selectSidebarThreadsForProjectRef, | ||
| 9 | selectSidebarThreadsForProjectRefs, | ||
| 10 | type AppState, | ||
| 11 | type EnvironmentState, | ||
| 12 | } from "./store"; | ||
| 13 | import { | 4 | import { |
| 14 | deriveLogicalProjectKey, | 5 | deriveLogicalProjectKey, |
| 15 | deriveLogicalProjectKeyFromSettings, | 6 | deriveLogicalProjectKeyFromSettings, |
| 16 | derivePhysicalProjectKey, | 7 | derivePhysicalProjectKey, |
| 17 | resolveProjectGroupingMode, | 8 | resolveProjectGroupingMode, |
| 18 | } from "./logicalProject"; | 9 | } from "./logicalProject"; |
| 19 | import type { Project, SidebarThreadSummary } from "./types"; | 10 | import type { Project } from "./types"; |
| 20 | import { DEFAULT_INTERACTION_MODE } from "./types"; | 11 | |
| 21 | 12 | const primaryEnvironmentId = EnvironmentId.make("env-primary"); | |
| 22 | // ── Fixture Identifiers ────────────────────────────────────────────── | 13 | const remoteEnvironmentId = EnvironmentId.make("env-remote"); |
| 23 | 14 | const repositoryIdentity = { | |
| 24 | const primaryEnvId = EnvironmentId.make("env-primary"); | 15 | canonicalKey: "github.com/example/shared-repo", |
| 25 | const remoteEnvId = EnvironmentId.make("env-remote"); | 16 | locator: { |
| 26 | 17 | source: "git-remote" as const, | |
| 27 | const sharedProjectPrimaryId = ProjectId.make("shared-proj-primary"); | 18 | remoteName: "origin", |
| 28 | const sharedProjectRemoteId = ProjectId.make("shared-proj-remote"); | 19 | remoteUrl: "https://github.com/example/shared-repo.git", |
| 29 | const localOnlyProjectId = ProjectId.make("local-only-proj"); | 20 | }, |
| 30 | const remoteOnlyProjectId = ProjectId.make("remote-only-proj"); | 21 | }; |
| 31 | 22 | const defaultGroupingSettings = { | |
| 32 | const threadP1 = ThreadId.make("thread-shared-primary-1"); | ||
| 33 | const threadP2 = ThreadId.make("thread-shared-primary-2"); | ||
| 34 | const threadR1 = ThreadId.make("thread-shared-remote-1"); | ||
| 35 | const threadL1 = ThreadId.make("thread-local-only-1"); | ||
| 36 | const threadRO1 = ThreadId.make("thread-remote-only-1"); | ||
| 37 | |||
| 38 | const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; | ||
| 39 | const DEFAULT_GROUPING_SETTINGS = { | ||
| 40 | sidebarProjectGroupingMode: "repository" as const, | 23 | sidebarProjectGroupingMode: "repository" as const, |
| 41 | sidebarProjectGroupingOverrides: {}, | 24 | sidebarProjectGroupingOverrides: {}, |
| 42 | }; | 25 | }; |
| 43 | 26 | ||
| 44 | // ── Factory Helpers ────────────────────────────────────────────────── | 27 | function makeProject(overrides: Partial<Project> = {}): Project { |
| 45 | |||
| 46 | function makeProject( | ||
| 47 | overrides: Partial<Project> & Pick<Project, "id" | "environmentId" | "name">, | ||
| 48 | ): Project { | ||
| 49 | return { | 28 | return { |
| 50 | cwd: `/tmp/${overrides.name}`, | 29 | id: ProjectId.make("project-1"), |
| 51 | defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, | 30 | environmentId: primaryEnvironmentId, |
| 31 | title: "shared-repo", | ||
| 32 | workspaceRoot: "/tmp/shared-repo", | ||
| 33 | repositoryIdentity: null, | ||
| 34 | defaultModelSelection: { | ||
| 35 | instanceId: ProviderInstanceId.make("codex"), | ||
| 36 | model: "gpt-5-codex", | ||
| 37 | }, | ||
| 52 | createdAt: "2026-01-01T00:00:00.000Z", | 38 | createdAt: "2026-01-01T00:00:00.000Z", |
| 53 | updatedAt: "2026-01-01T00:00:00.000Z", | 39 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 54 | scripts: [], | 40 | scripts: [], |
| ... | @@ -56,559 +42,81 @@ function makeProject( | ... | @@ -56,559 +42,81 @@ function makeProject( |
| 56 | }; | 42 | }; |
| 57 | } | 43 | } |
| 58 | 44 | ||
| 59 | function makeSidebarThreadSummary( | ||
| 60 | overrides: Partial<SidebarThreadSummary> & | ||
| 61 | Pick<SidebarThreadSummary, "id" | "environmentId" | "projectId" | "title">, | ||
| 62 | ): SidebarThreadSummary { | ||
| 63 | return { | ||
| 64 | interactionMode: DEFAULT_INTERACTION_MODE, | ||
| 65 | session: null, | ||
| 66 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 67 | archivedAt: null, | ||
| 68 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 69 | latestTurn: null, | ||
| 70 | branch: null, | ||
| 71 | worktreePath: null, | ||
| 72 | latestUserMessageAt: null, | ||
| 73 | hasPendingApprovals: false, | ||
| 74 | hasPendingUserInput: false, | ||
| 75 | hasActionableProposedPlan: false, | ||
| 76 | ...overrides, | ||
| 77 | }; | ||
| 78 | } | ||
| 79 | |||
| 80 | function makeEmptyEnvironmentState(): EnvironmentState { | ||
| 81 | return { | ||
| 82 | projectIds: [], | ||
| 83 | projectById: {}, | ||
| 84 | threadIds: [], | ||
| 85 | threadIdsByProjectId: {}, | ||
| 86 | threadShellById: {}, | ||
| 87 | threadSessionById: {}, | ||
| 88 | threadTurnStateById: {}, | ||
| 89 | messageIdsByThreadId: {}, | ||
| 90 | messageByThreadId: {}, | ||
| 91 | activityIdsByThreadId: {}, | ||
| 92 | activityByThreadId: {}, | ||
| 93 | proposedPlanIdsByThreadId: {}, | ||
| 94 | proposedPlanByThreadId: {}, | ||
| 95 | turnDiffIdsByThreadId: {}, | ||
| 96 | turnDiffSummaryByThreadId: {}, | ||
| 97 | sidebarThreadSummaryById: {}, | ||
| 98 | bootstrapComplete: true, | ||
| 99 | }; | ||
| 100 | } | ||
| 101 | |||
| 102 | // ── Fixture: Two environments, shared + local-only + remote-only projects ── | ||
| 103 | |||
| 104 | function makeFixtureState(): AppState { | ||
| 105 | // Shared project: same repo in both envs | ||
| 106 | const sharedProjectPrimary = makeProject({ | ||
| 107 | id: sharedProjectPrimaryId, | ||
| 108 | environmentId: primaryEnvId, | ||
| 109 | name: "shared-repo", | ||
| 110 | repositoryIdentity: { | ||
| 111 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 112 | locator: { | ||
| 113 | source: "git-remote", | ||
| 114 | remoteName: "origin", | ||
| 115 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 116 | }, | ||
| 117 | }, | ||
| 118 | }); | ||
| 119 | const sharedProjectRemote = makeProject({ | ||
| 120 | id: sharedProjectRemoteId, | ||
| 121 | environmentId: remoteEnvId, | ||
| 122 | name: "shared-repo", | ||
| 123 | repositoryIdentity: { | ||
| 124 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 125 | locator: { | ||
| 126 | source: "git-remote", | ||
| 127 | remoteName: "origin", | ||
| 128 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 129 | }, | ||
| 130 | }, | ||
| 131 | }); | ||
| 132 | // Local-only project | ||
| 133 | const localOnlyProject = makeProject({ | ||
| 134 | id: localOnlyProjectId, | ||
| 135 | environmentId: primaryEnvId, | ||
| 136 | name: "local-only", | ||
| 137 | }); | ||
| 138 | // Remote-only project | ||
| 139 | const remoteOnlyProject = makeProject({ | ||
| 140 | id: remoteOnlyProjectId, | ||
| 141 | environmentId: remoteEnvId, | ||
| 142 | name: "remote-only", | ||
| 143 | }); | ||
| 144 | |||
| 145 | // Threads | ||
| 146 | const summaryP1 = makeSidebarThreadSummary({ | ||
| 147 | id: threadP1, | ||
| 148 | environmentId: primaryEnvId, | ||
| 149 | projectId: sharedProjectPrimaryId, | ||
| 150 | title: "Shared primary thread 1", | ||
| 151 | }); | ||
| 152 | const summaryP2 = makeSidebarThreadSummary({ | ||
| 153 | id: threadP2, | ||
| 154 | environmentId: primaryEnvId, | ||
| 155 | projectId: sharedProjectPrimaryId, | ||
| 156 | title: "Shared primary thread 2", | ||
| 157 | }); | ||
| 158 | const summaryR1 = makeSidebarThreadSummary({ | ||
| 159 | id: threadR1, | ||
| 160 | environmentId: remoteEnvId, | ||
| 161 | projectId: sharedProjectRemoteId, | ||
| 162 | title: "Shared remote thread 1", | ||
| 163 | }); | ||
| 164 | const summaryL1 = makeSidebarThreadSummary({ | ||
| 165 | id: threadL1, | ||
| 166 | environmentId: primaryEnvId, | ||
| 167 | projectId: localOnlyProjectId, | ||
| 168 | title: "Local only thread 1", | ||
| 169 | }); | ||
| 170 | const summaryRO1 = makeSidebarThreadSummary({ | ||
| 171 | id: threadRO1, | ||
| 172 | environmentId: remoteEnvId, | ||
| 173 | projectId: remoteOnlyProjectId, | ||
| 174 | title: "Remote only thread 1", | ||
| 175 | }); | ||
| 176 | |||
| 177 | const primaryEnvState: EnvironmentState = { | ||
| 178 | ...makeEmptyEnvironmentState(), | ||
| 179 | projectIds: [sharedProjectPrimaryId, localOnlyProjectId], | ||
| 180 | projectById: { | ||
| 181 | [sharedProjectPrimaryId]: sharedProjectPrimary, | ||
| 182 | [localOnlyProjectId]: localOnlyProject, | ||
| 183 | }, | ||
| 184 | threadIds: [threadP1, threadP2, threadL1], | ||
| 185 | threadIdsByProjectId: { | ||
| 186 | [sharedProjectPrimaryId]: [threadP1, threadP2], | ||
| 187 | [localOnlyProjectId]: [threadL1], | ||
| 188 | }, | ||
| 189 | sidebarThreadSummaryById: { | ||
| 190 | [threadP1]: summaryP1, | ||
| 191 | [threadP2]: summaryP2, | ||
| 192 | [threadL1]: summaryL1, | ||
| 193 | }, | ||
| 194 | }; | ||
| 195 | |||
| 196 | const remoteEnvState: EnvironmentState = { | ||
| 197 | ...makeEmptyEnvironmentState(), | ||
| 198 | projectIds: [sharedProjectRemoteId, remoteOnlyProjectId], | ||
| 199 | projectById: { | ||
| 200 | [sharedProjectRemoteId]: sharedProjectRemote, | ||
| 201 | [remoteOnlyProjectId]: remoteOnlyProject, | ||
| 202 | }, | ||
| 203 | threadIds: [threadR1, threadRO1], | ||
| 204 | threadIdsByProjectId: { | ||
| 205 | [sharedProjectRemoteId]: [threadR1], | ||
| 206 | [remoteOnlyProjectId]: [threadRO1], | ||
| 207 | }, | ||
| 208 | sidebarThreadSummaryById: { | ||
| 209 | [threadR1]: summaryR1, | ||
| 210 | [threadRO1]: summaryRO1, | ||
| 211 | }, | ||
| 212 | }; | ||
| 213 | |||
| 214 | return { | ||
| 215 | activeEnvironmentId: primaryEnvId, | ||
| 216 | environmentStateById: { | ||
| 217 | [primaryEnvId]: primaryEnvState, | ||
| 218 | [remoteEnvId]: remoteEnvState, | ||
| 219 | }, | ||
| 220 | }; | ||
| 221 | } | ||
| 222 | |||
| 223 | // ── Tests ──────────────────────────────────────────────────────────── | ||
| 224 | |||
| 225 | describe("environment grouping", () => { | 45 | describe("environment grouping", () => { |
| 226 | describe("deriveLogicalProjectKey", () => { | 46 | it("groups matching repository identities across environments", () => { |
| 227 | it("uses repositoryIdentity.canonicalKey when present", () => { | 47 | const primary = makeProject({ repositoryIdentity }); |
| 228 | const project = makeProject({ | 48 | const remote = makeProject({ |
| 229 | id: sharedProjectPrimaryId, | 49 | id: ProjectId.make("project-remote"), |
| 230 | environmentId: primaryEnvId, | 50 | environmentId: remoteEnvironmentId, |
| 231 | name: "shared-repo", | 51 | repositoryIdentity, |
| 232 | repositoryIdentity: { | ||
| 233 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 234 | locator: { | ||
| 235 | source: "git-remote", | ||
| 236 | remoteName: "origin", | ||
| 237 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 238 | }, | ||
| 239 | }, | ||
| 240 | }); | ||
| 241 | expect(deriveLogicalProjectKey(project)).toBe(SHARED_REPO_CANONICAL_KEY); | ||
| 242 | }); | ||
| 243 | |||
| 244 | it("falls back to scoped project key when no repositoryIdentity", () => { | ||
| 245 | const project = makeProject({ | ||
| 246 | id: localOnlyProjectId, | ||
| 247 | environmentId: primaryEnvId, | ||
| 248 | name: "local-only", | ||
| 249 | }); | ||
| 250 | expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project)); | ||
| 251 | }); | ||
| 252 | |||
| 253 | it("groups projects from different environments that share the same canonical key", () => { | ||
| 254 | const primary = makeProject({ | ||
| 255 | id: sharedProjectPrimaryId, | ||
| 256 | environmentId: primaryEnvId, | ||
| 257 | name: "shared-repo", | ||
| 258 | repositoryIdentity: { | ||
| 259 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 260 | locator: { | ||
| 261 | source: "git-remote", | ||
| 262 | remoteName: "origin", | ||
| 263 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 264 | }, | ||
| 265 | }, | ||
| 266 | }); | ||
| 267 | const remote = makeProject({ | ||
| 268 | id: sharedProjectRemoteId, | ||
| 269 | environmentId: remoteEnvId, | ||
| 270 | name: "shared-repo", | ||
| 271 | repositoryIdentity: { | ||
| 272 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 273 | locator: { | ||
| 274 | source: "git-remote", | ||
| 275 | remoteName: "origin", | ||
| 276 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 277 | }, | ||
| 278 | }, | ||
| 279 | }); | ||
| 280 | expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); | ||
| 281 | }); | ||
| 282 | |||
| 283 | it("groups repo root and nested projects from the same repository by default", () => { | ||
| 284 | const rootProject = makeProject({ | ||
| 285 | id: sharedProjectPrimaryId, | ||
| 286 | environmentId: primaryEnvId, | ||
| 287 | name: "shared-repo", | ||
| 288 | cwd: "/workspace/repo", | ||
| 289 | repositoryIdentity: { | ||
| 290 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 291 | rootPath: "/workspace/repo", | ||
| 292 | locator: { | ||
| 293 | source: "git-remote", | ||
| 294 | remoteName: "origin", | ||
| 295 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 296 | }, | ||
| 297 | }, | ||
| 298 | }); | ||
| 299 | const nestedProject = makeProject({ | ||
| 300 | id: localOnlyProjectId, | ||
| 301 | environmentId: primaryEnvId, | ||
| 302 | name: "web", | ||
| 303 | cwd: "/workspace/repo/apps/web", | ||
| 304 | repositoryIdentity: { | ||
| 305 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 306 | rootPath: "/workspace/repo", | ||
| 307 | locator: { | ||
| 308 | source: "git-remote", | ||
| 309 | remoteName: "origin", | ||
| 310 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 311 | }, | ||
| 312 | }, | ||
| 313 | }); | ||
| 314 | |||
| 315 | expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); | ||
| 316 | expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY); | ||
| 317 | }); | ||
| 318 | |||
| 319 | it("uses repository path grouping when requested", () => { | ||
| 320 | const rootProject = makeProject({ | ||
| 321 | id: sharedProjectPrimaryId, | ||
| 322 | environmentId: primaryEnvId, | ||
| 323 | name: "shared-repo", | ||
| 324 | cwd: "/workspace/repo", | ||
| 325 | repositoryIdentity: { | ||
| 326 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 327 | rootPath: "/workspace/repo", | ||
| 328 | locator: { | ||
| 329 | source: "git-remote", | ||
| 330 | remoteName: "origin", | ||
| 331 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 332 | }, | ||
| 333 | }, | ||
| 334 | }); | ||
| 335 | const nestedProject = makeProject({ | ||
| 336 | id: localOnlyProjectId, | ||
| 337 | environmentId: primaryEnvId, | ||
| 338 | name: "web", | ||
| 339 | cwd: "/workspace/repo/apps/web", | ||
| 340 | repositoryIdentity: { | ||
| 341 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 342 | rootPath: "/workspace/repo", | ||
| 343 | locator: { | ||
| 344 | source: "git-remote", | ||
| 345 | remoteName: "origin", | ||
| 346 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 347 | }, | ||
| 348 | }, | ||
| 349 | }); | ||
| 350 | |||
| 351 | expect( | ||
| 352 | deriveLogicalProjectKey(rootProject, { | ||
| 353 | groupingMode: "repository_path", | ||
| 354 | }), | ||
| 355 | ).toBe(SHARED_REPO_CANONICAL_KEY); | ||
| 356 | expect( | ||
| 357 | deriveLogicalProjectKey(nestedProject, { | ||
| 358 | groupingMode: "repository_path", | ||
| 359 | }), | ||
| 360 | ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); | ||
| 361 | }); | ||
| 362 | |||
| 363 | it("groups matching nested project paths across environments when repo roots differ", () => { | ||
| 364 | const primary = makeProject({ | ||
| 365 | id: sharedProjectPrimaryId, | ||
| 366 | environmentId: primaryEnvId, | ||
| 367 | name: "web", | ||
| 368 | cwd: "/workspace/repo/apps/web", | ||
| 369 | repositoryIdentity: { | ||
| 370 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 371 | rootPath: "/workspace/repo", | ||
| 372 | locator: { | ||
| 373 | source: "git-remote", | ||
| 374 | remoteName: "origin", | ||
| 375 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 376 | }, | ||
| 377 | }, | ||
| 378 | }); | ||
| 379 | const remote = makeProject({ | ||
| 380 | id: sharedProjectRemoteId, | ||
| 381 | environmentId: remoteEnvId, | ||
| 382 | name: "web", | ||
| 383 | cwd: "/srv/checkout/apps/web", | ||
| 384 | repositoryIdentity: { | ||
| 385 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 386 | rootPath: "/srv/checkout", | ||
| 387 | locator: { | ||
| 388 | source: "git-remote", | ||
| 389 | remoteName: "origin", | ||
| 390 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 391 | }, | ||
| 392 | }, | ||
| 393 | }); | ||
| 394 | |||
| 395 | expect( | ||
| 396 | deriveLogicalProjectKey(primary, { | ||
| 397 | groupingMode: "repository_path", | ||
| 398 | }), | ||
| 399 | ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); | ||
| 400 | expect( | ||
| 401 | deriveLogicalProjectKey(primary, { | ||
| 402 | groupingMode: "repository_path", | ||
| 403 | }), | ||
| 404 | ).toBe( | ||
| 405 | deriveLogicalProjectKey(remote, { | ||
| 406 | groupingMode: "repository_path", | ||
| 407 | }), | ||
| 408 | ); | ||
| 409 | }); | 52 | }); |
| 410 | 53 | ||
| 411 | it("does NOT group projects without shared canonical key", () => { | 54 | expect(deriveLogicalProjectKey(primary)).toBe(repositoryIdentity.canonicalKey); |
| 412 | const local = makeProject({ | 55 | expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey); |
| 413 | id: localOnlyProjectId, | ||
| 414 | environmentId: primaryEnvId, | ||
| 415 | name: "local-only", | ||
| 416 | }); | ||
| 417 | const remote = makeProject({ | ||
| 418 | id: remoteOnlyProjectId, | ||
| 419 | environmentId: remoteEnvId, | ||
| 420 | name: "remote-only", | ||
| 421 | }); | ||
| 422 | expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); | ||
| 423 | }); | ||
| 424 | |||
| 425 | it("uses per-project overrides from settings", () => { | ||
| 426 | const project = makeProject({ | ||
| 427 | id: sharedProjectPrimaryId, | ||
| 428 | environmentId: primaryEnvId, | ||
| 429 | name: "shared-repo", | ||
| 430 | repositoryIdentity: { | ||
| 431 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | ||
| 432 | locator: { | ||
| 433 | source: "git-remote", | ||
| 434 | remoteName: "origin", | ||
| 435 | remoteUrl: "https://github.com/example/shared-repo.git", | ||
| 436 | }, | ||
| 437 | }, | ||
| 438 | }); | ||
| 439 | |||
| 440 | expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository"); | ||
| 441 | expect( | ||
| 442 | deriveLogicalProjectKeyFromSettings(project, { | ||
| 443 | ...DEFAULT_GROUPING_SETTINGS, | ||
| 444 | sidebarProjectGroupingOverrides: { | ||
| 445 | [derivePhysicalProjectKey(project)]: "separate", | ||
| 446 | }, | ||
| 447 | }), | ||
| 448 | ).toBe(derivePhysicalProjectKey(project)); | ||
| 449 | }); | ||
| 450 | }); | 56 | }); |
| 451 | 57 | ||
| 452 | describe("selectProjectsAcrossEnvironments", () => { | 58 | it("keeps projects without repository identity physically scoped", () => { |
| 453 | it("returns all projects from all environments", () => { | 59 | const primary = makeProject(); |
| 454 | const state = makeFixtureState(); | 60 | const remote = makeProject({ |
| 455 | const projects = selectProjectsAcrossEnvironments(state); | 61 | id: ProjectId.make("project-remote"), |
| 456 | expect(projects).toHaveLength(4); | 62 | environmentId: remoteEnvironmentId, |
| 457 | const names = projects.map((p) => p.name).toSorted(); | ||
| 458 | expect(names).toEqual(["local-only", "remote-only", "shared-repo", "shared-repo"]); | ||
| 459 | }); | 63 | }); |
| 460 | }); | ||
| 461 | 64 | ||
| 462 | describe("selectSidebarThreadsAcrossEnvironments", () => { | 65 | expect(deriveLogicalProjectKey(primary)).toBe(derivePhysicalProjectKey(primary)); |
| 463 | it("returns all sidebar thread summaries from all environments", () => { | 66 | expect(deriveLogicalProjectKey(remote)).toBe(derivePhysicalProjectKey(remote)); |
| 464 | const state = makeFixtureState(); | 67 | expect(deriveLogicalProjectKey(primary)).not.toBe(deriveLogicalProjectKey(remote)); |
| 465 | const threads = selectSidebarThreadsAcrossEnvironments(state); | ||
| 466 | expect(threads).toHaveLength(5); | ||
| 467 | const ids = new Set(threads.map((t) => t.id)); | ||
| 468 | expect(ids).toContain(threadP1); | ||
| 469 | expect(ids).toContain(threadP2); | ||
| 470 | expect(ids).toContain(threadR1); | ||
| 471 | expect(ids).toContain(threadL1); | ||
| 472 | expect(ids).toContain(threadRO1); | ||
| 473 | }); | ||
| 474 | }); | 68 | }); |
| 475 | 69 | ||
| 476 | describe("selectSidebarThreadsForProjectRef", () => { | 70 | it("uses the physical key when repository grouping is disabled", () => { |
| 477 | it("returns threads for a single project ref", () => { | 71 | const project = makeProject({ repositoryIdentity }); |
| 478 | const state = makeFixtureState(); | ||
| 479 | const ref = scopeProjectRef(primaryEnvId, sharedProjectPrimaryId); | ||
| 480 | const threads = selectSidebarThreadsForProjectRef(state, ref); | ||
| 481 | expect(threads).toHaveLength(2); | ||
| 482 | expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); | ||
| 483 | }); | ||
| 484 | |||
| 485 | it("returns empty array for null ref", () => { | ||
| 486 | const state = makeFixtureState(); | ||
| 487 | expect(selectSidebarThreadsForProjectRef(state, null)).toEqual([]); | ||
| 488 | }); | ||
| 489 | 72 | ||
| 490 | it("returns empty array for nonexistent environment", () => { | 73 | expect( |
| 491 | const state = makeFixtureState(); | 74 | deriveLogicalProjectKeyFromSettings(project, { |
| 492 | const ref = scopeProjectRef(EnvironmentId.make("nonexistent"), sharedProjectPrimaryId); | 75 | sidebarProjectGroupingMode: "separate", |
| 493 | expect(selectSidebarThreadsForProjectRef(state, ref)).toEqual([]); | 76 | sidebarProjectGroupingOverrides: {}, |
| 494 | }); | 77 | }), |
| 78 | ).toBe(derivePhysicalProjectKey(project)); | ||
| 495 | }); | 79 | }); |
| 496 | 80 | ||
| 497 | describe("selectSidebarThreadsForProjectRefs", () => { | 81 | it("allows a per-project override to separate an otherwise grouped repository", () => { |
| 498 | it("returns empty for empty refs", () => { | 82 | const project = makeProject({ repositoryIdentity }); |
| 499 | const state = makeFixtureState(); | 83 | const physicalKey = derivePhysicalProjectKey(project); |
| 500 | expect(selectSidebarThreadsForProjectRefs(state, [])).toEqual([]); | ||
| 501 | }); | ||
| 502 | |||
| 503 | it("returns threads for a single ref", () => { | ||
| 504 | const state = makeFixtureState(); | ||
| 505 | const refs = [scopeProjectRef(primaryEnvId, sharedProjectPrimaryId)]; | ||
| 506 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | ||
| 507 | expect(threads).toHaveLength(2); | ||
| 508 | expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); | ||
| 509 | }); | ||
| 510 | |||
| 511 | it("returns combined threads from multiple refs across environments", () => { | ||
| 512 | const state = makeFixtureState(); | ||
| 513 | const refs = [ | ||
| 514 | scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), | ||
| 515 | scopeProjectRef(remoteEnvId, sharedProjectRemoteId), | ||
| 516 | ]; | ||
| 517 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | ||
| 518 | expect(threads).toHaveLength(3); | ||
| 519 | const ids = new Set(threads.map((t) => t.id)); | ||
| 520 | expect(ids).toContain(threadP1); | ||
| 521 | expect(ids).toContain(threadP2); | ||
| 522 | expect(ids).toContain(threadR1); | ||
| 523 | }); | ||
| 524 | |||
| 525 | it("returns threads from remote-only project", () => { | ||
| 526 | const state = makeFixtureState(); | ||
| 527 | const refs = [scopeProjectRef(remoteEnvId, remoteOnlyProjectId)]; | ||
| 528 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | ||
| 529 | expect(threads).toHaveLength(1); | ||
| 530 | expect(threads[0]?.id).toBe(threadRO1); | ||
| 531 | }); | ||
| 532 | |||
| 533 | it("returns threads from local-only project", () => { | ||
| 534 | const state = makeFixtureState(); | ||
| 535 | const refs = [scopeProjectRef(primaryEnvId, localOnlyProjectId)]; | ||
| 536 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | ||
| 537 | expect(threads).toHaveLength(1); | ||
| 538 | expect(threads[0]?.id).toBe(threadL1); | ||
| 539 | }); | ||
| 540 | 84 | ||
| 541 | it("handles refs with nonexistent environment gracefully", () => { | 85 | expect( |
| 542 | const state = makeFixtureState(); | 86 | deriveLogicalProjectKeyFromSettings(project, { |
| 543 | const refs = [ | 87 | ...defaultGroupingSettings, |
| 544 | scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), | 88 | sidebarProjectGroupingOverrides: { |
| 545 | scopeProjectRef(EnvironmentId.make("nonexistent"), ProjectId.make("nope")), | 89 | [physicalKey]: "separate", |
| 546 | ]; | 90 | }, |
| 547 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | 91 | }), |
| 548 | // Only returns threads from the valid ref | 92 | ).toBe(physicalKey); |
| 549 | expect(threads).toHaveLength(2); | ||
| 550 | expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); | ||
| 551 | }); | ||
| 552 | }); | 93 | }); |
| 553 | 94 | ||
| 554 | describe("logical project grouping for sidebar", () => { | 95 | it("allows a per-project override to group a repository while the global mode is separate", () => { |
| 555 | it("computes correct logical key for grouped projects and aggregates threads", () => { | 96 | const project = makeProject({ repositoryIdentity }); |
| 556 | const state = makeFixtureState(); | ||
| 557 | const allProjects = selectProjectsAcrossEnvironments(state); | ||
| 558 | |||
| 559 | // Group by logical key | ||
| 560 | const groups = new Map<string, Project[]>(); | ||
| 561 | for (const project of allProjects) { | ||
| 562 | const key = deriveLogicalProjectKey(project); | ||
| 563 | const existing = groups.get(key) ?? []; | ||
| 564 | existing.push(project); | ||
| 565 | groups.set(key, existing); | ||
| 566 | } | ||
| 567 | |||
| 568 | // Shared project should be grouped | ||
| 569 | const sharedGroup = groups.get(SHARED_REPO_CANONICAL_KEY); | ||
| 570 | expect(sharedGroup).toBeDefined(); | ||
| 571 | expect(sharedGroup).toHaveLength(2); | ||
| 572 | expect(sharedGroup!.map((p) => p.environmentId).toSorted()).toEqual( | ||
| 573 | [primaryEnvId, remoteEnvId].toSorted(), | ||
| 574 | ); | ||
| 575 | |||
| 576 | // Build member refs for the grouped project and fetch threads | ||
| 577 | const memberRefs = sharedGroup!.map((p) => scopeProjectRef(p.environmentId, p.id)); | ||
| 578 | const threads = selectSidebarThreadsForProjectRefs(state, memberRefs); | ||
| 579 | expect(threads).toHaveLength(3); | ||
| 580 | const threadIds = threads.map((t) => t.id); | ||
| 581 | expect(threadIds).toContain(threadP1); | ||
| 582 | expect(threadIds).toContain(threadP2); | ||
| 583 | expect(threadIds).toContain(threadR1); | ||
| 584 | }); | ||
| 585 | 97 | ||
| 586 | it("local-only and remote-only projects remain ungrouped", () => { | 98 | expect( |
| 587 | const state = makeFixtureState(); | 99 | deriveLogicalProjectKeyFromSettings(project, { |
| 588 | const allProjects = selectProjectsAcrossEnvironments(state); | 100 | sidebarProjectGroupingMode: "separate", |
| 589 | 101 | sidebarProjectGroupingOverrides: { | |
| 590 | const groups = new Map<string, Project[]>(); | 102 | [derivePhysicalProjectKey(project)]: "repository", |
| 591 | for (const project of allProjects) { | 103 | }, |
| 592 | const key = deriveLogicalProjectKey(project); | 104 | }), |
| 593 | const existing = groups.get(key) ?? []; | 105 | ).toBe(repositoryIdentity.canonicalKey); |
| 594 | existing.push(project); | 106 | }); |
| 595 | groups.set(key, existing); | ||
| 596 | } | ||
| 597 | |||
| 598 | // Should have 3 groups total: shared, local-only, remote-only | ||
| 599 | expect(groups.size).toBe(3); | ||
| 600 | 107 | ||
| 601 | // Local-only group | 108 | it("reports the effective grouping mode after applying an override", () => { |
| 602 | const localKey = deriveLogicalProjectKey( | 109 | const project = makeProject({ repositoryIdentity }); |
| 603 | allProjects.find((p) => p.id === localOnlyProjectId)!, | 110 | const physicalKey = derivePhysicalProjectKey(project); |
| 604 | ); | ||
| 605 | expect(groups.get(localKey)).toHaveLength(1); | ||
| 606 | 111 | ||
| 607 | // Remote-only group | 112 | expect(resolveProjectGroupingMode(project, defaultGroupingSettings)).toBe("repository"); |
| 608 | const remoteKey = deriveLogicalProjectKey( | 113 | expect( |
| 609 | allProjects.find((p) => p.id === remoteOnlyProjectId)!, | 114 | resolveProjectGroupingMode(project, { |
| 610 | ); | 115 | ...defaultGroupingSettings, |
| 611 | expect(groups.get(remoteKey)).toHaveLength(1); | 116 | sidebarProjectGroupingOverrides: { |
| 612 | }); | 117 | [physicalKey]: "separate", |
| 118 | }, | ||
| 119 | }), | ||
| 120 | ).toBe("separate"); | ||
| 613 | }); | 121 | }); |
| 614 | }); | 122 | }); |
apps/web/src/environments/primary/context.ts+6-22| ... | @@ -2,11 +2,10 @@ import { | ... | @@ -2,11 +2,10 @@ import { |
| 2 | attachEnvironmentDescriptor, | 2 | attachEnvironmentDescriptor, |
| 3 | createKnownEnvironment, | 3 | createKnownEnvironment, |
| 4 | type KnownEnvironment, | 4 | type KnownEnvironment, |
| 5 | } from "@t3tools/client-runtime"; | 5 | } from "@t3tools/client-runtime/environment"; |
| 6 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | 6 | import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import { HttpClientError } from "effect/unstable/http"; | 8 | import { HttpClientError } from "effect/unstable/http"; |
| 9 | import { create } from "zustand"; | ||
| 10 | 9 | ||
| 11 | import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; | 10 | import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; |
| 12 | import { PrimaryEnvironmentHttpClient } from "./httpClient"; | 11 | import { PrimaryEnvironmentHttpClient } from "./httpClient"; |
| ... | @@ -14,18 +13,7 @@ import { PrimaryEnvironmentHttpClient } from "./httpClient"; | ... | @@ -14,18 +13,7 @@ import { PrimaryEnvironmentHttpClient } from "./httpClient"; |
| 14 | import { runPrimaryHttp } from "../../lib/runtime"; | 13 | import { runPrimaryHttp } from "../../lib/runtime"; |
| 15 | import { readPrimaryEnvironmentTarget } from "./target"; | 14 | import { readPrimaryEnvironmentTarget } from "./target"; |
| 16 | 15 | ||
| 17 | interface PrimaryEnvironmentBootstrapState { | 16 | let primaryEnvironmentDescriptor: ExecutionEnvironmentDescriptor | null = null; |
| 18 | readonly descriptor: ExecutionEnvironmentDescriptor | null; | ||
| 19 | readonly setDescriptor: (descriptor: ExecutionEnvironmentDescriptor | null) => void; | ||
| 20 | readonly reset: () => void; | ||
| 21 | } | ||
| 22 | |||
| 23 | const usePrimaryEnvironmentBootstrapStore = create<PrimaryEnvironmentBootstrapState>()((set) => ({ | ||
| 24 | descriptor: null, | ||
| 25 | setDescriptor: (descriptor) => set({ descriptor }), | ||
| 26 | reset: () => set({ descriptor: null }), | ||
| 27 | })); | ||
| 28 | |||
| 29 | let primaryEnvironmentDescriptorPromise: Promise<ExecutionEnvironmentDescriptor> | null = null; | 17 | let primaryEnvironmentDescriptorPromise: Promise<ExecutionEnvironmentDescriptor> | null = null; |
| 30 | 18 | ||
| 31 | function createPrimaryKnownEnvironment(input: { | 19 | function createPrimaryKnownEnvironment(input: { |
| ... | @@ -72,17 +60,13 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironment | ... | @@ -72,17 +60,13 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironment |
| 72 | } | 60 | } |
| 73 | 61 | ||
| 74 | export function readPrimaryEnvironmentDescriptor(): ExecutionEnvironmentDescriptor | null { | 62 | export function readPrimaryEnvironmentDescriptor(): ExecutionEnvironmentDescriptor | null { |
| 75 | return usePrimaryEnvironmentBootstrapStore.getState().descriptor; | 63 | return primaryEnvironmentDescriptor; |
| 76 | } | ||
| 77 | |||
| 78 | export function usePrimaryEnvironmentId(): EnvironmentId | null { | ||
| 79 | return usePrimaryEnvironmentBootstrapStore((state) => state.descriptor?.environmentId ?? null); | ||
| 80 | } | 64 | } |
| 81 | 65 | ||
| 82 | export function writePrimaryEnvironmentDescriptor( | 66 | export function writePrimaryEnvironmentDescriptor( |
| 83 | descriptor: ExecutionEnvironmentDescriptor | null, | 67 | descriptor: ExecutionEnvironmentDescriptor | null, |
| 84 | ): void { | 68 | ): void { |
| 85 | usePrimaryEnvironmentBootstrapStore.getState().setDescriptor(descriptor); | 69 | primaryEnvironmentDescriptor = descriptor; |
| 86 | } | 70 | } |
| 87 | 71 | ||
| 88 | export function getPrimaryKnownEnvironment(): KnownEnvironment | null { | 72 | export function getPrimaryKnownEnvironment(): KnownEnvironment | null { |
| ... | @@ -118,7 +102,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise<ExecutionE | ... | @@ -118,7 +102,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise<ExecutionE |
| 118 | 102 | ||
| 119 | export function __resetPrimaryEnvironmentBootstrapForTests(): void { | 103 | export function __resetPrimaryEnvironmentBootstrapForTests(): void { |
| 120 | primaryEnvironmentDescriptorPromise = null; | 104 | primaryEnvironmentDescriptorPromise = null; |
| 121 | usePrimaryEnvironmentBootstrapStore.getState().reset(); | 105 | primaryEnvironmentDescriptor = null; |
| 122 | } | 106 | } |
| 123 | 107 | ||
| 124 | export const resetPrimaryEnvironmentDescriptorForTests = __resetPrimaryEnvironmentBootstrapForTests; | 108 | export const resetPrimaryEnvironmentDescriptorForTests = __resetPrimaryEnvironmentBootstrapForTests; |
apps/web/src/environments/primary/httpClient.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; | 1 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; |
| 2 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
apps/web/src/environments/primary/index.ts-1| ... | @@ -3,7 +3,6 @@ export { | ... | @@ -3,7 +3,6 @@ export { |
| 3 | readPrimaryEnvironmentDescriptor, | 3 | readPrimaryEnvironmentDescriptor, |
| 4 | resetPrimaryEnvironmentDescriptorForTests, | 4 | resetPrimaryEnvironmentDescriptorForTests, |
| 5 | resolveInitialPrimaryEnvironmentDescriptor, | 5 | resolveInitialPrimaryEnvironmentDescriptor, |
| 6 | usePrimaryEnvironmentId, | ||
| 7 | writePrimaryEnvironmentDescriptor, | 6 | writePrimaryEnvironmentDescriptor, |
| 8 | __resetPrimaryEnvironmentBootstrapForTests, | 7 | __resetPrimaryEnvironmentBootstrapForTests, |
| 9 | __resetPrimaryEnvironmentDescriptorBootstrapForTests, | 8 | __resetPrimaryEnvironmentDescriptorBootstrapForTests, |
apps/web/src/environments/primary/target.ts+5-3| ... | @@ -1,9 +1,11 @@ | ... | @@ -1,9 +1,11 @@ |
| 1 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | 1 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; |
| 2 | import type { KnownEnvironment } from "@t3tools/client-runtime"; | ||
| 3 | 2 | ||
| 4 | export interface PrimaryEnvironmentTarget { | 3 | export interface PrimaryEnvironmentTarget { |
| 5 | readonly source: KnownEnvironment["source"]; | 4 | readonly source: "configured" | "window-origin" | "desktop-managed"; |
| 6 | readonly target: KnownEnvironment["target"]; | 5 | readonly target: { |
| 6 | readonly httpBaseUrl: string; | ||
| 7 | readonly wsBaseUrl: string; | ||
| 8 | }; | ||
| 7 | } | 9 | } |
| 8 | 10 | ||
| 9 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | 11 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
apps/web/src/environments/runtime/catalog.test.ts deleted-188| ... | @@ -1,188 +0,0 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | type LocalApi, | ||
| 4 | type PersistedSavedEnvironmentRecord, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | readSavedEnvironmentCredential, | ||
| 10 | resetSavedEnvironmentRegistryStoreForTests, | ||
| 11 | resetSavedEnvironmentRuntimeStoreForTests, | ||
| 12 | useSavedEnvironmentRegistryStore, | ||
| 13 | useSavedEnvironmentRuntimeStore, | ||
| 14 | waitForSavedEnvironmentRegistryHydration, | ||
| 15 | writeSavedEnvironmentCredential, | ||
| 16 | } from "./catalog"; | ||
| 17 | |||
| 18 | let resolveRegistryRead: () => void = () => { | ||
| 19 | throw new Error("Registry read resolver was not initialized."); | ||
| 20 | }; | ||
| 21 | |||
| 22 | describe("environment runtime catalog stores", () => { | ||
| 23 | beforeEach(async () => { | ||
| 24 | vi.stubGlobal("window", { | ||
| 25 | nativeApi: { | ||
| 26 | persistence: { | ||
| 27 | getClientSettings: async () => null, | ||
| 28 | setClientSettings: async () => undefined, | ||
| 29 | getSavedEnvironmentRegistry: async () => [], | ||
| 30 | setSavedEnvironmentRegistry: async () => undefined, | ||
| 31 | getSavedEnvironmentSecret: async () => null, | ||
| 32 | setSavedEnvironmentSecret: async () => true, | ||
| 33 | removeSavedEnvironmentSecret: async () => undefined, | ||
| 34 | }, | ||
| 35 | } satisfies Pick<LocalApi, "persistence">, | ||
| 36 | }); | ||
| 37 | const { __resetLocalApiForTests } = await import("../../localApi"); | ||
| 38 | await __resetLocalApiForTests(); | ||
| 39 | }); | ||
| 40 | |||
| 41 | afterEach(async () => { | ||
| 42 | resetSavedEnvironmentRegistryStoreForTests(); | ||
| 43 | resetSavedEnvironmentRuntimeStoreForTests(); | ||
| 44 | const { __resetLocalApiForTests } = await import("../../localApi"); | ||
| 45 | await __resetLocalApiForTests(); | ||
| 46 | vi.unstubAllGlobals(); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("resets the saved environment registry store state", () => { | ||
| 50 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 51 | |||
| 52 | useSavedEnvironmentRegistryStore.getState().upsert({ | ||
| 53 | environmentId, | ||
| 54 | label: "Remote environment", | ||
| 55 | httpBaseUrl: "https://remote.example.com/", | ||
| 56 | wsBaseUrl: "wss://remote.example.com/", | ||
| 57 | createdAt: "2026-04-09T00:00:00.000Z", | ||
| 58 | lastConnectedAt: null, | ||
| 59 | }); | ||
| 60 | |||
| 61 | expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toBeDefined(); | ||
| 62 | |||
| 63 | resetSavedEnvironmentRegistryStoreForTests(); | ||
| 64 | |||
| 65 | expect(useSavedEnvironmentRegistryStore.getState().byId).toEqual({}); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("resets the saved environment runtime store state", () => { | ||
| 69 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 70 | |||
| 71 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | ||
| 72 | connectionState: "connected", | ||
| 73 | connectedAt: "2026-04-09T00:00:00.000Z", | ||
| 74 | }); | ||
| 75 | |||
| 76 | expect(useSavedEnvironmentRuntimeStore.getState().byId[environmentId]).toBeDefined(); | ||
| 77 | |||
| 78 | resetSavedEnvironmentRuntimeStoreForTests(); | ||
| 79 | |||
| 80 | expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("decodes legacy bearer secrets and writes versioned DPoP credentials", async () => { | ||
| 84 | let storedSecret: string | null = "legacy-bearer-token"; | ||
| 85 | vi.stubGlobal("window", { | ||
| 86 | nativeApi: { | ||
| 87 | persistence: { | ||
| 88 | getClientSettings: async () => null, | ||
| 89 | setClientSettings: async () => undefined, | ||
| 90 | getSavedEnvironmentRegistry: async () => [], | ||
| 91 | setSavedEnvironmentRegistry: async () => undefined, | ||
| 92 | getSavedEnvironmentSecret: async () => storedSecret, | ||
| 93 | setSavedEnvironmentSecret: async (_environmentId, secret) => { | ||
| 94 | storedSecret = secret; | ||
| 95 | return true; | ||
| 96 | }, | ||
| 97 | removeSavedEnvironmentSecret: async () => undefined, | ||
| 98 | }, | ||
| 99 | } satisfies Pick<LocalApi, "persistence">, | ||
| 100 | }); | ||
| 101 | const { __resetLocalApiForTests } = await import("../../localApi"); | ||
| 102 | await __resetLocalApiForTests(); | ||
| 103 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 104 | |||
| 105 | await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ | ||
| 106 | version: 1, | ||
| 107 | method: "bearer", | ||
| 108 | token: "legacy-bearer-token", | ||
| 109 | }); | ||
| 110 | await expect( | ||
| 111 | writeSavedEnvironmentCredential(environmentId, { | ||
| 112 | version: 1, | ||
| 113 | method: "dpop", | ||
| 114 | accessToken: "managed-dpop-access-token", | ||
| 115 | }), | ||
| 116 | ).resolves.toBe(true); | ||
| 117 | await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ | ||
| 118 | version: 1, | ||
| 119 | method: "dpop", | ||
| 120 | accessToken: "managed-dpop-access-token", | ||
| 121 | }); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("does not throw when local api lookup fails during registry persistence", async () => { | ||
| 125 | vi.unstubAllGlobals(); | ||
| 126 | const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | ||
| 127 | const { __resetLocalApiForTests } = await import("../../localApi"); | ||
| 128 | await __resetLocalApiForTests(); | ||
| 129 | |||
| 130 | expect(() => | ||
| 131 | useSavedEnvironmentRegistryStore.getState().upsert({ | ||
| 132 | environmentId: EnvironmentId.make("environment-1"), | ||
| 133 | label: "Remote environment", | ||
| 134 | httpBaseUrl: "https://remote.example.com/", | ||
| 135 | wsBaseUrl: "wss://remote.example.com/", | ||
| 136 | createdAt: "2026-04-09T00:00:00.000Z", | ||
| 137 | lastConnectedAt: null, | ||
| 138 | }), | ||
| 139 | ).not.toThrow(); | ||
| 140 | |||
| 141 | expect(errorSpy).toHaveBeenCalledWith("[SAVED_ENVIRONMENTS] persist failed", expect.any(Error)); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("does not let stale hydration overwrite records added while hydration is in flight", async () => { | ||
| 145 | resolveRegistryRead = () => { | ||
| 146 | throw new Error("Registry read resolver was not initialized."); | ||
| 147 | }; | ||
| 148 | |||
| 149 | vi.stubGlobal("window", { | ||
| 150 | nativeApi: { | ||
| 151 | persistence: { | ||
| 152 | getClientSettings: async () => null, | ||
| 153 | setClientSettings: async () => undefined, | ||
| 154 | getSavedEnvironmentRegistry: () => | ||
| 155 | new Promise<readonly PersistedSavedEnvironmentRecord[]>((resolve) => { | ||
| 156 | resolveRegistryRead = () => resolve([]); | ||
| 157 | }), | ||
| 158 | setSavedEnvironmentRegistry: async () => undefined, | ||
| 159 | getSavedEnvironmentSecret: async () => null, | ||
| 160 | setSavedEnvironmentSecret: async () => true, | ||
| 161 | removeSavedEnvironmentSecret: async () => undefined, | ||
| 162 | }, | ||
| 163 | } satisfies Pick<LocalApi, "persistence">, | ||
| 164 | }); | ||
| 165 | |||
| 166 | const { __resetLocalApiForTests } = await import("../../localApi"); | ||
| 167 | await __resetLocalApiForTests(); | ||
| 168 | |||
| 169 | const hydrationPromise = waitForSavedEnvironmentRegistryHydration(); | ||
| 170 | |||
| 171 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 172 | const record = { | ||
| 173 | environmentId, | ||
| 174 | label: "Remote environment", | ||
| 175 | httpBaseUrl: "https://remote.example.com/", | ||
| 176 | wsBaseUrl: "wss://remote.example.com/", | ||
| 177 | createdAt: "2026-04-09T00:00:00.000Z", | ||
| 178 | lastConnectedAt: null, | ||
| 179 | } as const; | ||
| 180 | |||
| 181 | useSavedEnvironmentRegistryStore.getState().upsert(record); | ||
| 182 | |||
| 183 | resolveRegistryRead(); | ||
| 184 | await hydrationPromise; | ||
| 185 | |||
| 186 | expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toEqual(record); | ||
| 187 | }); | ||
| 188 | }); | ||
apps/web/src/environments/runtime/catalog.ts deleted-410| ... | @@ -1,410 +0,0 @@ | ||
| 1 | import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; | ||
| 2 | import type { | ||
| 3 | AuthEnvironmentScope, | ||
| 4 | EnvironmentId, | ||
| 5 | ExecutionEnvironmentDescriptor, | ||
| 6 | PersistedSavedEnvironmentRecord, | ||
| 7 | ServerConfig, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import { create } from "zustand"; | ||
| 12 | |||
| 13 | import { ensureLocalApi } from "../../localApi"; | ||
| 14 | import { getPrimaryKnownEnvironment } from "../primary"; | ||
| 15 | |||
| 16 | export interface SavedEnvironmentRecord { | ||
| 17 | readonly environmentId: EnvironmentId; | ||
| 18 | readonly label: string; | ||
| 19 | readonly wsBaseUrl: string; | ||
| 20 | readonly httpBaseUrl: string; | ||
| 21 | readonly createdAt: string; | ||
| 22 | readonly lastConnectedAt: string | null; | ||
| 23 | readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; | ||
| 24 | readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"]; | ||
| 25 | } | ||
| 26 | |||
| 27 | export const SavedEnvironmentCredential = Schema.Union([ | ||
| 28 | Schema.Struct({ | ||
| 29 | version: Schema.Literal(1), | ||
| 30 | method: Schema.Literal("bearer"), | ||
| 31 | token: Schema.String, | ||
| 32 | }), | ||
| 33 | Schema.Struct({ | ||
| 34 | version: Schema.Literal(1), | ||
| 35 | method: Schema.Literal("dpop"), | ||
| 36 | accessToken: Schema.String, | ||
| 37 | }), | ||
| 38 | ]); | ||
| 39 | export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type; | ||
| 40 | |||
| 41 | const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential); | ||
| 42 | const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption( | ||
| 43 | SavedEnvironmentCredentialJson, | ||
| 44 | ); | ||
| 45 | const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson); | ||
| 46 | |||
| 47 | interface SavedEnvironmentRegistryState { | ||
| 48 | readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>; | ||
| 49 | } | ||
| 50 | |||
| 51 | interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { | ||
| 52 | readonly upsert: (record: SavedEnvironmentRecord) => void; | ||
| 53 | readonly remove: (environmentId: EnvironmentId) => void; | ||
| 54 | readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void; | ||
| 55 | readonly rename: (environmentId: EnvironmentId, label: string) => void; | ||
| 56 | readonly reset: () => void; | ||
| 57 | } | ||
| 58 | |||
| 59 | let savedEnvironmentRegistryHydrated = false; | ||
| 60 | let savedEnvironmentRegistryHydrationPromise: Promise<void> | null = null; | ||
| 61 | |||
| 62 | export function toPersistedSavedEnvironmentRecord( | ||
| 63 | record: SavedEnvironmentRecord, | ||
| 64 | ): PersistedSavedEnvironmentRecord { | ||
| 65 | return { | ||
| 66 | environmentId: record.environmentId, | ||
| 67 | label: record.label, | ||
| 68 | httpBaseUrl: record.httpBaseUrl, | ||
| 69 | wsBaseUrl: record.wsBaseUrl, | ||
| 70 | createdAt: record.createdAt, | ||
| 71 | lastConnectedAt: record.lastConnectedAt, | ||
| 72 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 73 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 74 | }; | ||
| 75 | } | ||
| 76 | |||
| 77 | function valuesOfSavedEnvironmentRegistry( | ||
| 78 | byId: Record<EnvironmentId, SavedEnvironmentRecord>, | ||
| 79 | ): ReadonlyArray<SavedEnvironmentRecord> { | ||
| 80 | return Object.values(byId) as ReadonlyArray<SavedEnvironmentRecord>; | ||
| 81 | } | ||
| 82 | |||
| 83 | function persistSavedEnvironmentRegistryState( | ||
| 84 | byId: Record<EnvironmentId, SavedEnvironmentRecord>, | ||
| 85 | ): void { | ||
| 86 | try { | ||
| 87 | void ensureLocalApi() | ||
| 88 | .persistence.setSavedEnvironmentRegistry( | ||
| 89 | valuesOfSavedEnvironmentRegistry(byId).map((record) => | ||
| 90 | toPersistedSavedEnvironmentRecord(record), | ||
| 91 | ), | ||
| 92 | ) | ||
| 93 | .catch((error) => { | ||
| 94 | console.error("[SAVED_ENVIRONMENTS] persist failed", error); | ||
| 95 | }); | ||
| 96 | } catch (error) { | ||
| 97 | console.error("[SAVED_ENVIRONMENTS] persist failed", error); | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | function replaceSavedEnvironmentRegistryState( | ||
| 102 | records: ReadonlyArray<SavedEnvironmentRecord>, | ||
| 103 | ): void { | ||
| 104 | const currentById = useSavedEnvironmentRegistryStore.getState().byId; | ||
| 105 | const hydratedById = Object.fromEntries(records.map((record) => [record.environmentId, record])); | ||
| 106 | useSavedEnvironmentRegistryStore.setState({ | ||
| 107 | byId: { | ||
| 108 | ...hydratedById, | ||
| 109 | ...currentById, | ||
| 110 | }, | ||
| 111 | }); | ||
| 112 | } | ||
| 113 | |||
| 114 | async function hydrateSavedEnvironmentRegistry(): Promise<void> { | ||
| 115 | if (savedEnvironmentRegistryHydrated) { | ||
| 116 | return; | ||
| 117 | } | ||
| 118 | if (savedEnvironmentRegistryHydrationPromise) { | ||
| 119 | return savedEnvironmentRegistryHydrationPromise; | ||
| 120 | } | ||
| 121 | |||
| 122 | const nextHydration = (async () => { | ||
| 123 | try { | ||
| 124 | const persistedRecords = await ensureLocalApi().persistence.getSavedEnvironmentRegistry(); | ||
| 125 | replaceSavedEnvironmentRegistryState(persistedRecords); | ||
| 126 | } catch (error) { | ||
| 127 | console.error("[SAVED_ENVIRONMENTS] hydrate failed", error); | ||
| 128 | } finally { | ||
| 129 | savedEnvironmentRegistryHydrated = true; | ||
| 130 | } | ||
| 131 | })(); | ||
| 132 | |||
| 133 | const hydrationPromise = nextHydration.finally(() => { | ||
| 134 | if (savedEnvironmentRegistryHydrationPromise === hydrationPromise) { | ||
| 135 | savedEnvironmentRegistryHydrationPromise = null; | ||
| 136 | } | ||
| 137 | }); | ||
| 138 | savedEnvironmentRegistryHydrationPromise = hydrationPromise; | ||
| 139 | |||
| 140 | return savedEnvironmentRegistryHydrationPromise; | ||
| 141 | } | ||
| 142 | |||
| 143 | export const useSavedEnvironmentRegistryStore = create<SavedEnvironmentRegistryStore>()((set) => ({ | ||
| 144 | byId: {}, | ||
| 145 | upsert: (record) => | ||
| 146 | set((state) => { | ||
| 147 | const byId = { | ||
| 148 | ...state.byId, | ||
| 149 | [record.environmentId]: record, | ||
| 150 | }; | ||
| 151 | persistSavedEnvironmentRegistryState(byId); | ||
| 152 | return { byId }; | ||
| 153 | }), | ||
| 154 | remove: (environmentId) => | ||
| 155 | set((state) => { | ||
| 156 | const { [environmentId]: _removed, ...remaining } = state.byId; | ||
| 157 | persistSavedEnvironmentRegistryState(remaining); | ||
| 158 | return { | ||
| 159 | byId: remaining, | ||
| 160 | }; | ||
| 161 | }), | ||
| 162 | markConnected: (environmentId, connectedAt) => | ||
| 163 | set((state) => { | ||
| 164 | const existing = state.byId[environmentId]; | ||
| 165 | if (!existing) { | ||
| 166 | return state; | ||
| 167 | } | ||
| 168 | const byId = { | ||
| 169 | ...state.byId, | ||
| 170 | [environmentId]: { | ||
| 171 | ...existing, | ||
| 172 | lastConnectedAt: connectedAt, | ||
| 173 | }, | ||
| 174 | }; | ||
| 175 | persistSavedEnvironmentRegistryState(byId); | ||
| 176 | return { byId }; | ||
| 177 | }), | ||
| 178 | rename: (environmentId, label) => | ||
| 179 | set((state) => { | ||
| 180 | const existing = state.byId[environmentId]; | ||
| 181 | const nextLabel = label.trim(); | ||
| 182 | if (!existing || nextLabel.length === 0 || existing.label === nextLabel) { | ||
| 183 | return state; | ||
| 184 | } | ||
| 185 | const byId = { | ||
| 186 | ...state.byId, | ||
| 187 | [environmentId]: { | ||
| 188 | ...existing, | ||
| 189 | label: nextLabel, | ||
| 190 | }, | ||
| 191 | }; | ||
| 192 | persistSavedEnvironmentRegistryState(byId); | ||
| 193 | return { byId }; | ||
| 194 | }), | ||
| 195 | reset: () => { | ||
| 196 | persistSavedEnvironmentRegistryState({}); | ||
| 197 | set({ | ||
| 198 | byId: {}, | ||
| 199 | }); | ||
| 200 | }, | ||
| 201 | })); | ||
| 202 | |||
| 203 | export function hasSavedEnvironmentRegistryHydrated(): boolean { | ||
| 204 | return savedEnvironmentRegistryHydrated; | ||
| 205 | } | ||
| 206 | |||
| 207 | export function waitForSavedEnvironmentRegistryHydration(): Promise<void> { | ||
| 208 | if (hasSavedEnvironmentRegistryHydrated()) { | ||
| 209 | return Promise.resolve(); | ||
| 210 | } | ||
| 211 | |||
| 212 | return hydrateSavedEnvironmentRegistry(); | ||
| 213 | } | ||
| 214 | |||
| 215 | export function listSavedEnvironmentRecords(): ReadonlyArray<SavedEnvironmentRecord> { | ||
| 216 | return Object.values(useSavedEnvironmentRegistryStore.getState().byId).toSorted((left, right) => | ||
| 217 | left.label.localeCompare(right.label), | ||
| 218 | ); | ||
| 219 | } | ||
| 220 | |||
| 221 | export function getSavedEnvironmentRecord( | ||
| 222 | environmentId: EnvironmentId, | ||
| 223 | ): SavedEnvironmentRecord | null { | ||
| 224 | return useSavedEnvironmentRegistryStore.getState().byId[environmentId] ?? null; | ||
| 225 | } | ||
| 226 | |||
| 227 | export function getEnvironmentHttpBaseUrl(environmentId: EnvironmentId): string | null { | ||
| 228 | const primaryEnvironment = getPrimaryKnownEnvironment(); | ||
| 229 | if (primaryEnvironment?.environmentId === environmentId) { | ||
| 230 | return getKnownEnvironmentHttpBaseUrl(primaryEnvironment); | ||
| 231 | } | ||
| 232 | |||
| 233 | return getSavedEnvironmentRecord(environmentId)?.httpBaseUrl ?? null; | ||
| 234 | } | ||
| 235 | |||
| 236 | export function resolveEnvironmentHttpUrl(input: { | ||
| 237 | readonly environmentId: EnvironmentId; | ||
| 238 | readonly pathname: string; | ||
| 239 | readonly searchParams?: Record<string, string>; | ||
| 240 | }): string { | ||
| 241 | const httpBaseUrl = getEnvironmentHttpBaseUrl(input.environmentId); | ||
| 242 | if (!httpBaseUrl) { | ||
| 243 | throw new Error(`Unable to resolve HTTP base URL for environment ${input.environmentId}.`); | ||
| 244 | } | ||
| 245 | |||
| 246 | const url = new URL(httpBaseUrl); | ||
| 247 | url.pathname = input.pathname; | ||
| 248 | if (input.searchParams) { | ||
| 249 | url.search = new URLSearchParams(input.searchParams).toString(); | ||
| 250 | } | ||
| 251 | return url.toString(); | ||
| 252 | } | ||
| 253 | |||
| 254 | export function resetSavedEnvironmentRegistryStoreForTests() { | ||
| 255 | savedEnvironmentRegistryHydrated = false; | ||
| 256 | savedEnvironmentRegistryHydrationPromise = null; | ||
| 257 | useSavedEnvironmentRegistryStore.setState({ byId: {} }); | ||
| 258 | } | ||
| 259 | |||
| 260 | export async function persistSavedEnvironmentRecord(record: SavedEnvironmentRecord): Promise<void> { | ||
| 261 | const byId = { | ||
| 262 | ...useSavedEnvironmentRegistryStore.getState().byId, | ||
| 263 | [record.environmentId]: record, | ||
| 264 | }; | ||
| 265 | |||
| 266 | await ensureLocalApi().persistence.setSavedEnvironmentRegistry( | ||
| 267 | valuesOfSavedEnvironmentRegistry(byId).map((entry) => toPersistedSavedEnvironmentRecord(entry)), | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | |||
| 271 | export async function readSavedEnvironmentBearerToken( | ||
| 272 | environmentId: EnvironmentId, | ||
| 273 | ): Promise<string | null> { | ||
| 274 | return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); | ||
| 275 | } | ||
| 276 | |||
| 277 | export async function readSavedEnvironmentCredential( | ||
| 278 | environmentId: EnvironmentId, | ||
| 279 | ): Promise<SavedEnvironmentCredential | null> { | ||
| 280 | const secret = await ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); | ||
| 281 | if (!secret) { | ||
| 282 | return null; | ||
| 283 | } | ||
| 284 | const decoded = decodeSavedEnvironmentCredentialJson(secret); | ||
| 285 | if (Option.isSome(decoded)) { | ||
| 286 | return decoded.value; | ||
| 287 | } | ||
| 288 | // Legacy bearer secrets were stored directly as strings. | ||
| 289 | return { version: 1, method: "bearer", token: secret }; | ||
| 290 | } | ||
| 291 | |||
| 292 | export async function writeSavedEnvironmentCredential( | ||
| 293 | environmentId: EnvironmentId, | ||
| 294 | credential: SavedEnvironmentCredential, | ||
| 295 | ): Promise<boolean> { | ||
| 296 | return ensureLocalApi().persistence.setSavedEnvironmentSecret( | ||
| 297 | environmentId, | ||
| 298 | encodeSavedEnvironmentCredentialJson(credential), | ||
| 299 | ); | ||
| 300 | } | ||
| 301 | |||
| 302 | export async function writeSavedEnvironmentBearerToken( | ||
| 303 | environmentId: EnvironmentId, | ||
| 304 | bearerToken: string, | ||
| 305 | ): Promise<boolean> { | ||
| 306 | return ensureLocalApi().persistence.setSavedEnvironmentSecret(environmentId, bearerToken); | ||
| 307 | } | ||
| 308 | |||
| 309 | export async function removeSavedEnvironmentBearerToken( | ||
| 310 | environmentId: EnvironmentId, | ||
| 311 | ): Promise<void> { | ||
| 312 | await ensureLocalApi().persistence.removeSavedEnvironmentSecret(environmentId); | ||
| 313 | } | ||
| 314 | |||
| 315 | export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error"; | ||
| 316 | |||
| 317 | export type SavedEnvironmentAuthState = "authenticated" | "requires-auth" | "unknown"; | ||
| 318 | |||
| 319 | export interface SavedEnvironmentRuntimeState { | ||
| 320 | readonly connectionState: SavedEnvironmentConnectionState; | ||
| 321 | readonly authState: SavedEnvironmentAuthState; | ||
| 322 | readonly lastError: string | null; | ||
| 323 | readonly lastErrorAt: string | null; | ||
| 324 | readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null; | ||
| 325 | readonly descriptor: ExecutionEnvironmentDescriptor | null; | ||
| 326 | readonly serverConfig: ServerConfig | null; | ||
| 327 | readonly connectedAt: string | null; | ||
| 328 | readonly disconnectedAt: string | null; | ||
| 329 | } | ||
| 330 | |||
| 331 | interface SavedEnvironmentRuntimeStoreState { | ||
| 332 | readonly byId: Record<EnvironmentId, SavedEnvironmentRuntimeState>; | ||
| 333 | readonly ensure: (environmentId: EnvironmentId) => void; | ||
| 334 | readonly patch: ( | ||
| 335 | environmentId: EnvironmentId, | ||
| 336 | patch: Partial<SavedEnvironmentRuntimeState>, | ||
| 337 | ) => void; | ||
| 338 | readonly clear: (environmentId: EnvironmentId) => void; | ||
| 339 | readonly reset: () => void; | ||
| 340 | } | ||
| 341 | |||
| 342 | const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Object.freeze({ | ||
| 343 | connectionState: "disconnected", | ||
| 344 | authState: "unknown", | ||
| 345 | lastError: null, | ||
| 346 | lastErrorAt: null, | ||
| 347 | scopes: null, | ||
| 348 | descriptor: null, | ||
| 349 | serverConfig: null, | ||
| 350 | connectedAt: null, | ||
| 351 | disconnectedAt: null, | ||
| 352 | }); | ||
| 353 | |||
| 354 | function createDefaultSavedEnvironmentRuntimeState(): SavedEnvironmentRuntimeState { | ||
| 355 | return { | ||
| 356 | ...DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE, | ||
| 357 | }; | ||
| 358 | } | ||
| 359 | |||
| 360 | export const useSavedEnvironmentRuntimeStore = create<SavedEnvironmentRuntimeStoreState>()( | ||
| 361 | (set) => ({ | ||
| 362 | byId: {}, | ||
| 363 | ensure: (environmentId) => | ||
| 364 | set((state) => { | ||
| 365 | if (state.byId[environmentId]) { | ||
| 366 | return state; | ||
| 367 | } | ||
| 368 | return { | ||
| 369 | byId: { | ||
| 370 | ...state.byId, | ||
| 371 | [environmentId]: createDefaultSavedEnvironmentRuntimeState(), | ||
| 372 | }, | ||
| 373 | }; | ||
| 374 | }), | ||
| 375 | patch: (environmentId, patch) => | ||
| 376 | set((state) => ({ | ||
| 377 | byId: { | ||
| 378 | ...state.byId, | ||
| 379 | [environmentId]: { | ||
| 380 | ...(state.byId[environmentId] ?? createDefaultSavedEnvironmentRuntimeState()), | ||
| 381 | ...patch, | ||
| 382 | }, | ||
| 383 | }, | ||
| 384 | })), | ||
| 385 | clear: (environmentId) => | ||
| 386 | set((state) => { | ||
| 387 | const { [environmentId]: _removed, ...remaining } = state.byId; | ||
| 388 | return { | ||
| 389 | byId: remaining, | ||
| 390 | }; | ||
| 391 | }), | ||
| 392 | reset: () => | ||
| 393 | set({ | ||
| 394 | byId: {}, | ||
| 395 | }), | ||
| 396 | }), | ||
| 397 | ); | ||
| 398 | |||
| 399 | export function getSavedEnvironmentRuntimeState( | ||
| 400 | environmentId: EnvironmentId, | ||
| 401 | ): SavedEnvironmentRuntimeState { | ||
| 402 | return ( | ||
| 403 | useSavedEnvironmentRuntimeStore.getState().byId[environmentId] ?? | ||
| 404 | DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE | ||
| 405 | ); | ||
| 406 | } | ||
| 407 | |||
| 408 | export function resetSavedEnvironmentRuntimeStoreForTests() { | ||
| 409 | useSavedEnvironmentRuntimeStore.getState().reset(); | ||
| 410 | } | ||
apps/web/src/environments/runtime/connection.test.ts deleted-295| ... | @@ -1,295 +0,0 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { createEnvironmentConnection } from "./connection"; | ||
| 5 | import type { WsRpcClient } from "@t3tools/client-runtime"; | ||
| 6 | |||
| 7 | function createTestClient(config?: { readonly emitInitialSnapshot?: boolean }) { | ||
| 8 | const lifecycleListeners = new Set<(event: any) => void>(); | ||
| 9 | const configListeners = new Set<(event: any) => void>(); | ||
| 10 | const shellListeners = new Set<(event: any) => void>(); | ||
| 11 | let shellResubscribe: (() => void) | undefined; | ||
| 12 | |||
| 13 | const client = { | ||
| 14 | dispose: vi.fn(async () => undefined), | ||
| 15 | reconnect: vi.fn(async () => { | ||
| 16 | shellResubscribe?.(); | ||
| 17 | }), | ||
| 18 | server: { | ||
| 19 | getConfig: vi.fn(async () => ({ | ||
| 20 | environment: { | ||
| 21 | environmentId: EnvironmentId.make("env-1"), | ||
| 22 | }, | ||
| 23 | })), | ||
| 24 | subscribeConfig: vi.fn((listener: (event: any) => void) => { | ||
| 25 | configListeners.add(listener); | ||
| 26 | return () => configListeners.delete(listener); | ||
| 27 | }), | ||
| 28 | subscribeLifecycle: vi.fn((listener: (event: any) => void) => { | ||
| 29 | lifecycleListeners.add(listener); | ||
| 30 | return () => lifecycleListeners.delete(listener); | ||
| 31 | }), | ||
| 32 | subscribeAuthAccess: () => () => undefined, | ||
| 33 | refreshProviders: vi.fn(async () => undefined), | ||
| 34 | upsertKeybinding: vi.fn(async () => undefined), | ||
| 35 | getSettings: vi.fn(async () => undefined), | ||
| 36 | updateSettings: vi.fn(async () => undefined), | ||
| 37 | }, | ||
| 38 | orchestration: { | ||
| 39 | dispatchCommand: vi.fn(async () => undefined), | ||
| 40 | getTurnDiff: vi.fn(async () => undefined), | ||
| 41 | getFullThreadDiff: vi.fn(async () => undefined), | ||
| 42 | subscribeShell: vi.fn( | ||
| 43 | (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { | ||
| 44 | shellListeners.add(listener); | ||
| 45 | shellResubscribe = options?.onResubscribe; | ||
| 46 | if (config?.emitInitialSnapshot !== false) { | ||
| 47 | queueMicrotask(() => { | ||
| 48 | listener({ | ||
| 49 | kind: "snapshot", | ||
| 50 | snapshot: { | ||
| 51 | snapshotSequence: 1, | ||
| 52 | projects: [], | ||
| 53 | threads: [], | ||
| 54 | updatedAt: "2026-04-12T00:00:00.000Z", | ||
| 55 | }, | ||
| 56 | }); | ||
| 57 | }); | ||
| 58 | } | ||
| 59 | return () => { | ||
| 60 | shellListeners.delete(listener); | ||
| 61 | if (shellResubscribe === options?.onResubscribe) { | ||
| 62 | shellResubscribe = undefined; | ||
| 63 | } | ||
| 64 | }; | ||
| 65 | }, | ||
| 66 | ), | ||
| 67 | subscribeThread: vi.fn(() => () => undefined), | ||
| 68 | }, | ||
| 69 | terminal: { | ||
| 70 | open: vi.fn(async () => undefined), | ||
| 71 | attach: vi.fn(() => () => undefined), | ||
| 72 | write: vi.fn(async () => undefined), | ||
| 73 | resize: vi.fn(async () => undefined), | ||
| 74 | clear: vi.fn(async () => undefined), | ||
| 75 | restart: vi.fn(async () => undefined), | ||
| 76 | close: vi.fn(async () => undefined), | ||
| 77 | onEvent: vi.fn(() => () => undefined), | ||
| 78 | onMetadata: vi.fn(() => () => undefined), | ||
| 79 | }, | ||
| 80 | projects: { | ||
| 81 | searchEntries: vi.fn(async () => []), | ||
| 82 | writeFile: vi.fn(async () => undefined), | ||
| 83 | }, | ||
| 84 | shell: { | ||
| 85 | openInEditor: vi.fn(async () => undefined), | ||
| 86 | }, | ||
| 87 | git: { | ||
| 88 | runStackedAction: vi.fn(async () => ({}) as any), | ||
| 89 | resolvePullRequest: vi.fn(async () => undefined), | ||
| 90 | preparePullRequestThread: vi.fn(async () => undefined), | ||
| 91 | }, | ||
| 92 | review: { | ||
| 93 | getDiffPreview: vi.fn(async () => undefined), | ||
| 94 | }, | ||
| 95 | } as unknown as WsRpcClient; | ||
| 96 | |||
| 97 | return { | ||
| 98 | client, | ||
| 99 | emitWelcome: (environmentId: EnvironmentId) => { | ||
| 100 | for (const listener of lifecycleListeners) { | ||
| 101 | listener({ | ||
| 102 | type: "welcome", | ||
| 103 | payload: { | ||
| 104 | environment: { | ||
| 105 | environmentId, | ||
| 106 | }, | ||
| 107 | }, | ||
| 108 | }); | ||
| 109 | } | ||
| 110 | }, | ||
| 111 | emitConfigSnapshot: (environmentId: EnvironmentId) => { | ||
| 112 | for (const listener of configListeners) { | ||
| 113 | listener({ | ||
| 114 | type: "snapshot", | ||
| 115 | config: { | ||
| 116 | environment: { | ||
| 117 | environmentId, | ||
| 118 | }, | ||
| 119 | }, | ||
| 120 | }); | ||
| 121 | } | ||
| 122 | }, | ||
| 123 | emitShellSnapshot: (snapshotSequence: number) => { | ||
| 124 | for (const listener of shellListeners) { | ||
| 125 | listener({ | ||
| 126 | kind: "snapshot", | ||
| 127 | snapshot: { | ||
| 128 | snapshotSequence, | ||
| 129 | projects: [], | ||
| 130 | threads: [], | ||
| 131 | updatedAt: "2026-04-12T00:00:00.000Z", | ||
| 132 | }, | ||
| 133 | }); | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | |||
| 139 | describe("createEnvironmentConnection", () => { | ||
| 140 | it("bootstraps from the shell subscription snapshot", async () => { | ||
| 141 | const environmentId = EnvironmentId.make("env-1"); | ||
| 142 | const { client } = createTestClient(); | ||
| 143 | const syncShellSnapshot = vi.fn(); | ||
| 144 | |||
| 145 | const connection = createEnvironmentConnection({ | ||
| 146 | kind: "saved", | ||
| 147 | knownEnvironment: { | ||
| 148 | id: "env-1", | ||
| 149 | label: "Remote env", | ||
| 150 | source: "manual", | ||
| 151 | target: { | ||
| 152 | httpBaseUrl: "http://example.test", | ||
| 153 | wsBaseUrl: "ws://example.test", | ||
| 154 | }, | ||
| 155 | environmentId, | ||
| 156 | }, | ||
| 157 | client, | ||
| 158 | applyShellEvent: vi.fn(), | ||
| 159 | syncShellSnapshot, | ||
| 160 | }); | ||
| 161 | |||
| 162 | await connection.ensureBootstrapped(); | ||
| 163 | |||
| 164 | expect(syncShellSnapshot).toHaveBeenCalledWith( | ||
| 165 | expect.objectContaining({ snapshotSequence: 1 }), | ||
| 166 | environmentId, | ||
| 167 | ); | ||
| 168 | |||
| 169 | await connection.dispose(); | ||
| 170 | }); | ||
| 171 | |||
| 172 | it("rejects welcome/config identity drift", async () => { | ||
| 173 | const environmentId = EnvironmentId.make("env-1"); | ||
| 174 | const { client, emitWelcome } = createTestClient(); | ||
| 175 | |||
| 176 | const connection = createEnvironmentConnection({ | ||
| 177 | kind: "saved", | ||
| 178 | knownEnvironment: { | ||
| 179 | id: "env-1", | ||
| 180 | label: "Remote env", | ||
| 181 | source: "manual", | ||
| 182 | target: { | ||
| 183 | httpBaseUrl: "http://example.test", | ||
| 184 | wsBaseUrl: "ws://example.test", | ||
| 185 | }, | ||
| 186 | environmentId, | ||
| 187 | }, | ||
| 188 | client, | ||
| 189 | applyShellEvent: vi.fn(), | ||
| 190 | syncShellSnapshot: vi.fn(), | ||
| 191 | }); | ||
| 192 | |||
| 193 | expect(() => emitWelcome(EnvironmentId.make("env-2"))).toThrow( | ||
| 194 | "Environment connection env-1 changed identity to env-2 via server lifecycle welcome.", | ||
| 195 | ); | ||
| 196 | |||
| 197 | await connection.dispose(); | ||
| 198 | }); | ||
| 199 | |||
| 200 | it("waits for a fresh shell snapshot after reconnect", async () => { | ||
| 201 | const environmentId = EnvironmentId.make("env-1"); | ||
| 202 | const { client, emitShellSnapshot } = createTestClient(); | ||
| 203 | const syncShellSnapshot = vi.fn(); | ||
| 204 | |||
| 205 | const connection = createEnvironmentConnection({ | ||
| 206 | kind: "saved", | ||
| 207 | knownEnvironment: { | ||
| 208 | id: "env-1", | ||
| 209 | label: "Remote env", | ||
| 210 | source: "manual", | ||
| 211 | target: { | ||
| 212 | httpBaseUrl: "http://example.test", | ||
| 213 | wsBaseUrl: "ws://example.test", | ||
| 214 | }, | ||
| 215 | environmentId, | ||
| 216 | }, | ||
| 217 | client, | ||
| 218 | applyShellEvent: vi.fn(), | ||
| 219 | syncShellSnapshot, | ||
| 220 | }); | ||
| 221 | |||
| 222 | await connection.ensureBootstrapped(); | ||
| 223 | |||
| 224 | const reconnectPromise = connection.reconnect(); | ||
| 225 | await Promise.resolve(); | ||
| 226 | expect(syncShellSnapshot).toHaveBeenCalledTimes(1); | ||
| 227 | |||
| 228 | emitShellSnapshot(2); | ||
| 229 | await reconnectPromise; | ||
| 230 | |||
| 231 | expect(client.reconnect).toHaveBeenCalledTimes(1); | ||
| 232 | expect(syncShellSnapshot).toHaveBeenCalledTimes(2); | ||
| 233 | expect(syncShellSnapshot).toHaveBeenLastCalledWith( | ||
| 234 | expect.objectContaining({ snapshotSequence: 2 }), | ||
| 235 | environmentId, | ||
| 236 | ); | ||
| 237 | |||
| 238 | await connection.dispose(); | ||
| 239 | }); | ||
| 240 | |||
| 241 | it("skips primary lifecycle/config subscriptions when no handlers are registered", async () => { | ||
| 242 | const environmentId = EnvironmentId.make("env-1"); | ||
| 243 | const { client } = createTestClient(); | ||
| 244 | |||
| 245 | const connection = createEnvironmentConnection({ | ||
| 246 | kind: "primary", | ||
| 247 | knownEnvironment: { | ||
| 248 | id: "env-1", | ||
| 249 | label: "Local env", | ||
| 250 | source: "manual", | ||
| 251 | target: { | ||
| 252 | httpBaseUrl: "http://example.test", | ||
| 253 | wsBaseUrl: "ws://example.test", | ||
| 254 | }, | ||
| 255 | environmentId, | ||
| 256 | }, | ||
| 257 | client, | ||
| 258 | applyShellEvent: vi.fn(), | ||
| 259 | syncShellSnapshot: vi.fn(), | ||
| 260 | applyTerminalEvent: vi.fn(), | ||
| 261 | }); | ||
| 262 | |||
| 263 | expect(client.server.subscribeLifecycle).not.toHaveBeenCalled(); | ||
| 264 | expect(client.server.subscribeConfig).not.toHaveBeenCalled(); | ||
| 265 | expect(client.orchestration.subscribeShell).toHaveBeenCalledOnce(); | ||
| 266 | |||
| 267 | await connection.dispose(); | ||
| 268 | }); | ||
| 269 | |||
| 270 | it("rejects bootstrap waits when a pending connection is disposed", async () => { | ||
| 271 | const environmentId = EnvironmentId.make("env-1"); | ||
| 272 | const { client } = createTestClient({ emitInitialSnapshot: false }); | ||
| 273 | const connection = createEnvironmentConnection({ | ||
| 274 | kind: "saved", | ||
| 275 | knownEnvironment: { | ||
| 276 | id: "env-1", | ||
| 277 | label: "Remote env", | ||
| 278 | source: "manual", | ||
| 279 | target: { | ||
| 280 | httpBaseUrl: "http://example.test", | ||
| 281 | wsBaseUrl: "ws://example.test", | ||
| 282 | }, | ||
| 283 | environmentId, | ||
| 284 | }, | ||
| 285 | client, | ||
| 286 | applyShellEvent: vi.fn(), | ||
| 287 | syncShellSnapshot: vi.fn(), | ||
| 288 | }); | ||
| 289 | const pendingBootstrap = connection.ensureBootstrapped(); | ||
| 290 | |||
| 291 | await connection.dispose(); | ||
| 292 | |||
| 293 | await expect(pendingBootstrap).rejects.toThrow("was disposed before it finished bootstrapping"); | ||
| 294 | }); | ||
| 295 | }); | ||
apps/web/src/environments/runtime/connection.ts deleted-7| ... | @@ -1,7 +0,0 @@ | ||
| 1 | export { | ||
| 2 | createEnvironmentConnection, | ||
| 3 | createEnvironmentConnectionAttemptRegistry, | ||
| 4 | EnvironmentConnectionAttemptCancelledError, | ||
| 5 | EnvironmentConnectionDisposedError, | ||
| 6 | type EnvironmentConnection, | ||
| 7 | } from "@t3tools/client-runtime"; | ||
apps/web/src/environments/runtime/index.ts deleted-32| ... | @@ -1,32 +0,0 @@ | ||
| 1 | export { | ||
| 2 | getEnvironmentHttpBaseUrl, | ||
| 3 | getSavedEnvironmentRecord, | ||
| 4 | getSavedEnvironmentRuntimeState, | ||
| 5 | hasSavedEnvironmentRegistryHydrated, | ||
| 6 | listSavedEnvironmentRecords, | ||
| 7 | resetSavedEnvironmentRegistryStoreForTests, | ||
| 8 | resetSavedEnvironmentRuntimeStoreForTests, | ||
| 9 | resolveEnvironmentHttpUrl, | ||
| 10 | useSavedEnvironmentRegistryStore, | ||
| 11 | useSavedEnvironmentRuntimeStore, | ||
| 12 | waitForSavedEnvironmentRegistryHydration, | ||
| 13 | type SavedEnvironmentRecord, | ||
| 14 | type SavedEnvironmentRuntimeState, | ||
| 15 | } from "./catalog"; | ||
| 16 | |||
| 17 | export { | ||
| 18 | addSavedEnvironment, | ||
| 19 | addManagedRelayEnvironment, | ||
| 20 | connectDesktopSshEnvironment, | ||
| 21 | disconnectSavedEnvironment, | ||
| 22 | ensureEnvironmentConnectionBootstrapped, | ||
| 23 | getPrimaryEnvironmentConnection, | ||
| 24 | readEnvironmentConnection, | ||
| 25 | reconnectSavedEnvironment, | ||
| 26 | removeSavedEnvironment, | ||
| 27 | requireEnvironmentConnection, | ||
| 28 | resetEnvironmentServiceForTests, | ||
| 29 | startEnvironmentConnectionService, | ||
| 30 | subscribeEnvironmentConnections, | ||
| 31 | subscribeProviderInvalidations, | ||
| 32 | } from "./service"; | ||
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts deleted-1111| ... | @@ -1,1111 +0,0 @@ | ||
| 1 | import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import * as Tracer from "effect/Tracer"; | ||
| 6 | import { Headers } from "effect/unstable/http"; | ||
| 7 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | ||
| 10 | |||
| 11 | const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError); | ||
| 12 | |||
| 13 | let mockSavedRecords: Array<Record<string, unknown>> = []; | ||
| 14 | |||
| 15 | const mockResolveRemotePairingTarget = vi.fn(); | ||
| 16 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); | ||
| 17 | const mockBootstrapRemoteBearerSession = vi.fn(); | ||
| 18 | const mockFetchRemoteSessionState = vi.fn(); | ||
| 19 | const mockFetchRemoteDpopSessionState = vi.fn(); | ||
| 20 | const mockResolveRemoteDpopWebSocketConnectionUrl = vi.fn(); | ||
| 21 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); | ||
| 22 | const mockWsTransportConnectors: Array<() => Promise<string>> = []; | ||
| 23 | let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner; | ||
| 24 | let mockRelayClientTracer = Option.none<Tracer.Tracer>(); | ||
| 25 | const mockRemoteHttpRunPromise = vi.fn(<A, E>(effect: Effect.Effect<A, E>) => | ||
| 26 | Effect.runPromise( | ||
| 27 | effect.pipe( | ||
| 28 | Effect.provideService( | ||
| 29 | managedRelayDpopSigner, | ||
| 30 | managedRelayDpopSigner.of({ | ||
| 31 | thumbprint: Effect.succeed("thumbprint"), | ||
| 32 | createProof: () => Effect.succeed("dpop-proof"), | ||
| 33 | }), | ||
| 34 | ), | ||
| 35 | Effect.provideService(RelayClientTracer, mockRelayClientTracer), | ||
| 36 | ), | ||
| 37 | ), | ||
| 38 | ); | ||
| 39 | const mockBootstrapSshBearerSession = vi.fn(); | ||
| 40 | const mockFetchSshSessionState = vi.fn(); | ||
| 41 | const mockPersistSavedEnvironmentRecord = vi.fn(); | ||
| 42 | const mockWriteSavedEnvironmentBearerToken = vi.fn(); | ||
| 43 | const mockWriteSavedEnvironmentCredential = vi.fn(); | ||
| 44 | const mockSetSavedEnvironmentRegistry = vi.fn(); | ||
| 45 | const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { | ||
| 46 | return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; | ||
| 47 | }); | ||
| 48 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | ||
| 49 | const mockReadSavedEnvironmentCredential = vi.fn(); | ||
| 50 | const mockRemoveSavedEnvironmentBearerToken = vi.fn(); | ||
| 51 | const mockPatchRuntime = vi.fn(); | ||
| 52 | const mockClearRuntime = vi.fn(); | ||
| 53 | const mockRegistrySetState = vi.fn((next: { byId: Record<string, Record<string, unknown>> }) => { | ||
| 54 | mockSavedRecords = Object.values(next.byId); | ||
| 55 | }); | ||
| 56 | const mockRemove = vi.fn((environmentId: EnvironmentId) => { | ||
| 57 | mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId); | ||
| 58 | }); | ||
| 59 | const mockMarkConnected = vi.fn((environmentId: EnvironmentId, connectedAt: string) => { | ||
| 60 | mockSavedRecords = mockSavedRecords.map((record) => | ||
| 61 | record.environmentId === environmentId ? { ...record, lastConnectedAt: connectedAt } : record, | ||
| 62 | ); | ||
| 63 | }); | ||
| 64 | const mockRename = vi.fn((environmentId: EnvironmentId, label: string) => { | ||
| 65 | mockSavedRecords = mockSavedRecords.map((record) => | ||
| 66 | record.environmentId === environmentId ? { ...record, label } : record, | ||
| 67 | ); | ||
| 68 | }); | ||
| 69 | const mockUpsert = vi.fn((record: Record<string, unknown>) => { | ||
| 70 | mockSavedRecords = [ | ||
| 71 | ...mockSavedRecords.filter((entry) => entry.environmentId !== record.environmentId), | ||
| 72 | record, | ||
| 73 | ]; | ||
| 74 | }); | ||
| 75 | const mockListSavedEnvironmentRecords = vi.fn(() => mockSavedRecords); | ||
| 76 | const mockEnsureSshEnvironment = vi.fn(); | ||
| 77 | const mockDisconnectSshEnvironment = vi.fn(); | ||
| 78 | const mockFetchSshEnvironmentDescriptor = vi.fn(); | ||
| 79 | const mockToPersistedSavedEnvironmentRecord = vi.fn((record) => record); | ||
| 80 | const mockCreateEnvironmentConnection = vi.fn(); | ||
| 81 | const mockClientGetConfig = vi.fn(async () => ({ | ||
| 82 | environment: { | ||
| 83 | environmentId: EnvironmentId.make("environment-1"), | ||
| 84 | label: "Remote environment", | ||
| 85 | }, | ||
| 86 | })); | ||
| 87 | const mockConnectManagedCloudEnvironment = vi.fn(); | ||
| 88 | const mockReadManagedRelayClerkToken = vi.fn(); | ||
| 89 | |||
| 90 | vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ | ||
| 91 | ...(await importOriginal<typeof import("@t3tools/shared/remote")>()), | ||
| 92 | resolveRemotePairingTarget: mockResolveRemotePairingTarget, | ||
| 93 | })); | ||
| 94 | |||
| 95 | vi.mock("../../lib/runtime", () => ({ | ||
| 96 | webRuntime: { | ||
| 97 | runPromise: mockRemoteHttpRunPromise, | ||
| 98 | }, | ||
| 99 | })); | ||
| 100 | |||
| 101 | vi.mock("../../cloud/linkEnvironment", () => ({ | ||
| 102 | connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment, | ||
| 103 | })); | ||
| 104 | |||
| 105 | vi.mock("../../cloud/managedAuth", () => ({ | ||
| 106 | readManagedRelayClerkToken: mockReadManagedRelayClerkToken, | ||
| 107 | })); | ||
| 108 | |||
| 109 | vi.mock("~/localApi", () => ({ | ||
| 110 | ensureLocalApi: () => ({ | ||
| 111 | persistence: { | ||
| 112 | setSavedEnvironmentRegistry: mockSetSavedEnvironmentRegistry, | ||
| 113 | }, | ||
| 114 | }), | ||
| 115 | })); | ||
| 116 | |||
| 117 | vi.mock("./catalog", () => ({ | ||
| 118 | getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, | ||
| 119 | hasSavedEnvironmentRegistryHydrated: vi.fn(), | ||
| 120 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | ||
| 121 | persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, | ||
| 122 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, | ||
| 123 | readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, | ||
| 124 | removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, | ||
| 125 | toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, | ||
| 126 | useSavedEnvironmentRegistryStore: { | ||
| 127 | getState: () => ({ | ||
| 128 | upsert: mockUpsert, | ||
| 129 | remove: mockRemove, | ||
| 130 | markConnected: mockMarkConnected, | ||
| 131 | rename: mockRename, | ||
| 132 | }), | ||
| 133 | setState: mockRegistrySetState, | ||
| 134 | subscribe: vi.fn(() => () => {}), | ||
| 135 | }, | ||
| 136 | useSavedEnvironmentRuntimeStore: { | ||
| 137 | getState: () => ({ | ||
| 138 | ensure: vi.fn(), | ||
| 139 | patch: mockPatchRuntime, | ||
| 140 | clear: mockClearRuntime, | ||
| 141 | }), | ||
| 142 | }, | ||
| 143 | waitForSavedEnvironmentRegistryHydration: vi.fn(), | ||
| 144 | writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, | ||
| 145 | writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential, | ||
| 146 | })); | ||
| 147 | |||
| 148 | vi.mock("./connection", async (importOriginal) => ({ | ||
| 149 | ...(await importOriginal<typeof import("./connection")>()), | ||
| 150 | createEnvironmentConnection: mockCreateEnvironmentConnection, | ||
| 151 | })); | ||
| 152 | |||
| 153 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ||
| 154 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); | ||
| 155 | managedRelayDpopSigner = actual.ManagedRelayDpopSigner; | ||
| 156 | return { | ||
| 157 | ...actual, | ||
| 158 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, | ||
| 159 | createWsRpcClient: vi.fn(() => ({ | ||
| 160 | server: { | ||
| 161 | getConfig: mockClientGetConfig, | ||
| 162 | }, | ||
| 163 | terminal: { | ||
| 164 | onMetadata: vi.fn(() => () => undefined), | ||
| 165 | }, | ||
| 166 | orchestration: { | ||
| 167 | subscribeThread: vi.fn(() => () => {}), | ||
| 168 | }, | ||
| 169 | preview: { | ||
| 170 | subscribePorts: vi.fn(() => () => undefined), | ||
| 171 | }, | ||
| 172 | })), | ||
| 173 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, | ||
| 174 | fetchRemoteSessionState: mockFetchRemoteSessionState, | ||
| 175 | fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState, | ||
| 176 | resolveRemoteDpopWebSocketConnectionUrl: mockResolveRemoteDpopWebSocketConnectionUrl, | ||
| 177 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | ||
| 178 | }; | ||
| 179 | }); | ||
| 180 | |||
| 181 | vi.mock("../../rpc/wsTransport", () => ({ | ||
| 182 | WsTransport: vi.fn(function WsTransport(connect: () => Promise<string>) { | ||
| 183 | mockWsTransportConnectors.push(connect); | ||
| 184 | return {}; | ||
| 185 | }), | ||
| 186 | })); | ||
| 187 | |||
| 188 | describe("addSavedEnvironment", () => { | ||
| 189 | afterEach(() => { | ||
| 190 | vi.useRealTimers(); | ||
| 191 | }); | ||
| 192 | |||
| 193 | beforeEach(() => { | ||
| 194 | vi.resetModules(); | ||
| 195 | vi.clearAllMocks(); | ||
| 196 | mockSavedRecords = []; | ||
| 197 | mockRelayClientTracer = Option.none(); | ||
| 198 | mockWsTransportConnectors.length = 0; | ||
| 199 | vi.stubGlobal("window", { | ||
| 200 | desktopBridge: { | ||
| 201 | ensureSshEnvironment: mockEnsureSshEnvironment, | ||
| 202 | disconnectSshEnvironment: mockDisconnectSshEnvironment, | ||
| 203 | fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, | ||
| 204 | bootstrapSshBearerSession: mockBootstrapSshBearerSession, | ||
| 205 | fetchSshSessionState: mockFetchSshSessionState, | ||
| 206 | issueSshWebSocketTicket: vi.fn(), | ||
| 207 | }, | ||
| 208 | }); | ||
| 209 | mockResolveRemotePairingTarget.mockImplementation( | ||
| 210 | (input: { host?: string; pairingCode?: string }) => ({ | ||
| 211 | httpBaseUrl: input.host | ||
| 212 | ? input.host.endsWith("/") | ||
| 213 | ? input.host | ||
| 214 | : `${input.host}/` | ||
| 215 | : "https://remote.example.com/", | ||
| 216 | wsBaseUrl: input.host | ||
| 217 | ? input.host.replace(/^http/u, "ws").endsWith("/") | ||
| 218 | ? input.host.replace(/^http/u, "ws") | ||
| 219 | : `${input.host.replace(/^http/u, "ws")}/` | ||
| 220 | : "wss://remote.example.com/", | ||
| 221 | credential: input.pairingCode ?? "pairing-code", | ||
| 222 | }), | ||
| 223 | ); | ||
| 224 | mockReadSavedEnvironmentCredential.mockImplementation(async () => { | ||
| 225 | const token = await mockReadSavedEnvironmentBearerToken(); | ||
| 226 | return token ? { version: 1, method: "bearer", token } : null; | ||
| 227 | }); | ||
| 228 | mockFetchRemoteEnvironmentDescriptor.mockReturnValue( | ||
| 229 | Effect.succeed({ | ||
| 230 | environmentId: EnvironmentId.make("environment-1"), | ||
| 231 | label: "Remote environment", | ||
| 232 | }), | ||
| 233 | ); | ||
| 234 | mockBootstrapRemoteBearerSession.mockReturnValue( | ||
| 235 | Effect.succeed({ | ||
| 236 | access_token: "bearer-token", | ||
| 237 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 238 | }), | ||
| 239 | ); | ||
| 240 | mockFetchRemoteSessionState.mockReturnValue( | ||
| 241 | Effect.succeed({ | ||
| 242 | authenticated: true, | ||
| 243 | scopes: ["orchestration:read", "access:write"], | ||
| 244 | }), | ||
| 245 | ); | ||
| 246 | mockFetchRemoteDpopSessionState.mockReturnValue( | ||
| 247 | Effect.succeed({ | ||
| 248 | authenticated: true, | ||
| 249 | scopes: ["orchestration:read", "access:write"], | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | mockResolveRemoteWebSocketConnectionUrl.mockReturnValue( | ||
| 253 | Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"), | ||
| 254 | ); | ||
| 255 | mockResolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( | ||
| 256 | Effect.succeed("wss://remote.example.com/?wsTicket=remote-dpop-token"), | ||
| 257 | ); | ||
| 258 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ | ||
| 259 | environmentId: EnvironmentId.make("environment-1"), | ||
| 260 | label: "Remote environment", | ||
| 261 | }); | ||
| 262 | mockBootstrapSshBearerSession.mockResolvedValue({ | ||
| 263 | access_token: "ssh-bearer-token", | ||
| 264 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 265 | }); | ||
| 266 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); | ||
| 267 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); | ||
| 268 | mockWriteSavedEnvironmentCredential.mockResolvedValue(true); | ||
| 269 | mockReadManagedRelayClerkToken.mockResolvedValue(null); | ||
| 270 | mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); | ||
| 271 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | ||
| 272 | mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); | ||
| 273 | mockFetchSshSessionState.mockResolvedValue({ | ||
| 274 | authenticated: true, | ||
| 275 | scopes: ["orchestration:read", "access:write"], | ||
| 276 | }); | ||
| 277 | mockCreateEnvironmentConnection.mockImplementation( | ||
| 278 | (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ | ||
| 279 | kind: "saved", | ||
| 280 | environmentId: input.knownEnvironment.environmentId, | ||
| 281 | knownEnvironment: input.knownEnvironment, | ||
| 282 | client: input.client, | ||
| 283 | ensureBootstrapped: async () => undefined, | ||
| 284 | reconnect: async () => undefined, | ||
| 285 | dispose: async () => undefined, | ||
| 286 | }), | ||
| 287 | ); | ||
| 288 | mockClientGetConfig.mockResolvedValue({ | ||
| 289 | environment: { | ||
| 290 | environmentId: EnvironmentId.make("environment-1"), | ||
| 291 | label: "Remote environment", | ||
| 292 | }, | ||
| 293 | }); | ||
| 294 | mockEnsureSshEnvironment.mockResolvedValue({ | ||
| 295 | target: { | ||
| 296 | alias: "devbox", | ||
| 297 | hostname: "devbox.example.com", | ||
| 298 | username: "julius", | ||
| 299 | port: 22, | ||
| 300 | }, | ||
| 301 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 302 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 303 | pairingToken: "ssh-pairing-code", | ||
| 304 | }); | ||
| 305 | mockDisconnectSshEnvironment.mockResolvedValue(undefined); | ||
| 306 | }); | ||
| 307 | |||
| 308 | it("rolls back persisted metadata when bearer token persistence fails", async () => { | ||
| 309 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 310 | |||
| 311 | await expect( | ||
| 312 | addSavedEnvironment({ | ||
| 313 | label: "Remote environment", | ||
| 314 | host: "remote.example.com", | ||
| 315 | pairingCode: "123456", | ||
| 316 | }), | ||
| 317 | ).rejects.toThrow("Unable to persist saved environment credentials."); | ||
| 318 | |||
| 319 | expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledTimes(1); | ||
| 320 | expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 321 | EnvironmentId.make("environment-1"), | ||
| 322 | "bearer-token", | ||
| 323 | ); | ||
| 324 | expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([]); | ||
| 325 | expect(mockUpsert).not.toHaveBeenCalled(); | ||
| 326 | |||
| 327 | await resetEnvironmentServiceForTests(); | ||
| 328 | }); | ||
| 329 | |||
| 330 | it("restores unrelated saved environments when credential persistence rollback runs", async () => { | ||
| 331 | mockSavedRecords = [ | ||
| 332 | { | ||
| 333 | environmentId: EnvironmentId.make("environment-existing"), | ||
| 334 | label: "Existing environment", | ||
| 335 | httpBaseUrl: "https://existing.example.com/", | ||
| 336 | wsBaseUrl: "wss://existing.example.com/", | ||
| 337 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 338 | lastConnectedAt: null, | ||
| 339 | }, | ||
| 340 | ]; | ||
| 341 | |||
| 342 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 343 | |||
| 344 | await expect( | ||
| 345 | addSavedEnvironment({ | ||
| 346 | label: "Remote environment", | ||
| 347 | host: "remote.example.com", | ||
| 348 | pairingCode: "123456", | ||
| 349 | }), | ||
| 350 | ).rejects.toThrow("Unable to persist saved environment credentials."); | ||
| 351 | |||
| 352 | expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([ | ||
| 353 | expect.objectContaining({ | ||
| 354 | environmentId: EnvironmentId.make("environment-existing"), | ||
| 355 | }), | ||
| 356 | ]); | ||
| 357 | |||
| 358 | await resetEnvironmentServiceForTests(); | ||
| 359 | }); | ||
| 360 | |||
| 361 | it("persists the server label after saved environment metadata refresh", async () => { | ||
| 362 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 363 | mockClientGetConfig.mockResolvedValue({ | ||
| 364 | environment: { | ||
| 365 | environmentId: EnvironmentId.make("environment-1"), | ||
| 366 | label: "Julius's Mac mini", | ||
| 367 | }, | ||
| 368 | }); | ||
| 369 | |||
| 370 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 371 | |||
| 372 | await expect( | ||
| 373 | addSavedEnvironment({ | ||
| 374 | label: "100.65.180.100", | ||
| 375 | host: "remote.example.com", | ||
| 376 | pairingCode: "123456", | ||
| 377 | }), | ||
| 378 | ).resolves.toMatchObject({ | ||
| 379 | environmentId: EnvironmentId.make("environment-1"), | ||
| 380 | }); | ||
| 381 | |||
| 382 | expect(mockRename).toHaveBeenCalledWith( | ||
| 383 | EnvironmentId.make("environment-1"), | ||
| 384 | "Julius's Mac mini", | ||
| 385 | ); | ||
| 386 | expect(mockSavedRecords).toEqual([ | ||
| 387 | expect.objectContaining({ | ||
| 388 | environmentId: EnvironmentId.make("environment-1"), | ||
| 389 | label: "Julius's Mac mini", | ||
| 390 | }), | ||
| 391 | ]); | ||
| 392 | |||
| 393 | await resetEnvironmentServiceForTests(); | ||
| 394 | }); | ||
| 395 | |||
| 396 | it("installs relay-managed environments with versioned DPoP credentials", async () => { | ||
| 397 | const { addManagedRelayEnvironment, resetEnvironmentServiceForTests } = | ||
| 398 | await import("./service"); | ||
| 399 | |||
| 400 | await addManagedRelayEnvironment({ | ||
| 401 | environmentId: EnvironmentId.make("environment-1"), | ||
| 402 | label: "Managed remote", | ||
| 403 | httpBaseUrl: "https://managed.example.com/", | ||
| 404 | wsBaseUrl: "wss://managed.example.com/", | ||
| 405 | relayUrl: "https://relay.example.com", | ||
| 406 | accessToken: "managed-access-token", | ||
| 407 | relayTraceHeaders: Headers.empty, | ||
| 408 | }); | ||
| 409 | |||
| 410 | expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith( | ||
| 411 | EnvironmentId.make("environment-1"), | ||
| 412 | { | ||
| 413 | version: 1, | ||
| 414 | method: "dpop", | ||
| 415 | accessToken: "managed-access-token", | ||
| 416 | }, | ||
| 417 | ); | ||
| 418 | expect(mockFetchRemoteDpopSessionState).toHaveBeenCalledWith({ | ||
| 419 | httpBaseUrl: "https://managed.example.com/", | ||
| 420 | accessToken: "managed-access-token", | ||
| 421 | dpopProof: "dpop-proof", | ||
| 422 | }); | ||
| 423 | await resetEnvironmentServiceForTests(); | ||
| 424 | }); | ||
| 425 | |||
| 426 | it("renews expired managed DPoP credentials through the relay", async () => { | ||
| 427 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 428 | const productSpans: Array<Tracer.Span> = []; | ||
| 429 | mockRelayClientTracer = Option.some( | ||
| 430 | Tracer.make({ | ||
| 431 | span: (options) => { | ||
| 432 | const span = new Tracer.NativeSpan(options); | ||
| 433 | productSpans.push(span); | ||
| 434 | return span; | ||
| 435 | }, | ||
| 436 | }), | ||
| 437 | ); | ||
| 438 | const relayTraceHeaders = Headers.fromInput({ | ||
| 439 | traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", | ||
| 440 | }); | ||
| 441 | mockSavedRecords = [ | ||
| 442 | { | ||
| 443 | environmentId, | ||
| 444 | label: "Managed remote", | ||
| 445 | httpBaseUrl: "https://managed.example.com/", | ||
| 446 | wsBaseUrl: "wss://managed.example.com/", | ||
| 447 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 448 | lastConnectedAt: null, | ||
| 449 | relayManaged: { relayUrl: "https://relay.example.com" }, | ||
| 450 | }, | ||
| 451 | ]; | ||
| 452 | mockReadSavedEnvironmentCredential.mockResolvedValue({ | ||
| 453 | version: 1, | ||
| 454 | method: "dpop", | ||
| 455 | accessToken: "expired-access-token", | ||
| 456 | }); | ||
| 457 | mockFetchRemoteDpopSessionState | ||
| 458 | .mockReturnValueOnce( | ||
| 459 | Effect.fail( | ||
| 460 | decodeEnvironmentAuthInvalidError({ | ||
| 461 | _tag: "EnvironmentAuthInvalidError", | ||
| 462 | code: "auth_invalid", | ||
| 463 | reason: "invalid_credential", | ||
| 464 | traceId: "trace-auth-expired", | ||
| 465 | }), | ||
| 466 | ), | ||
| 467 | ) | ||
| 468 | .mockReturnValue(Effect.succeed({ authenticated: true, scopes: ["orchestration:read"] })); | ||
| 469 | mockReadManagedRelayClerkToken.mockResolvedValue("clerk-token"); | ||
| 470 | mockConnectManagedCloudEnvironment.mockReturnValue( | ||
| 471 | Effect.succeed({ | ||
| 472 | environmentId, | ||
| 473 | label: "Managed remote", | ||
| 474 | httpBaseUrl: "https://managed.example.com/", | ||
| 475 | wsBaseUrl: "wss://managed.example.com/", | ||
| 476 | relayUrl: "https://relay.example.com", | ||
| 477 | accessToken: "renewed-access-token", | ||
| 478 | relayTraceHeaders, | ||
| 479 | }), | ||
| 480 | ); | ||
| 481 | |||
| 482 | const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 483 | await import("./service"); | ||
| 484 | await reconnectSavedEnvironment(environmentId); | ||
| 485 | |||
| 486 | expect(mockConnectManagedCloudEnvironment).toHaveBeenCalledWith({ | ||
| 487 | clerkToken: "clerk-token", | ||
| 488 | relayUrl: "https://relay.example.com", | ||
| 489 | environment: expect.objectContaining({ environmentId }), | ||
| 490 | }); | ||
| 491 | expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(environmentId, { | ||
| 492 | version: 1, | ||
| 493 | method: "dpop", | ||
| 494 | accessToken: "renewed-access-token", | ||
| 495 | }); | ||
| 496 | const renewedTransportConnector = mockWsTransportConnectors.at(-1); | ||
| 497 | expect(renewedTransportConnector).toBeDefined(); | ||
| 498 | await renewedTransportConnector!(); | ||
| 499 | expect(mockResolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ | ||
| 500 | wsBaseUrl: "wss://managed.example.com/", | ||
| 501 | httpBaseUrl: "https://managed.example.com/", | ||
| 502 | accessToken: "renewed-access-token", | ||
| 503 | dpopProof: "dpop-proof", | ||
| 504 | }); | ||
| 505 | expect(productSpans.some((span) => span.name === "relay.environment.reconnect")).toBe(false); | ||
| 506 | await resetEnvironmentServiceForTests(); | ||
| 507 | }); | ||
| 508 | |||
| 509 | it("removes an older ssh record when the same target returns a new environment id", async () => { | ||
| 510 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 511 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ | ||
| 512 | environmentId: EnvironmentId.make("environment-2"), | ||
| 513 | label: "Remote environment", | ||
| 514 | }); | ||
| 515 | mockSavedRecords = [ | ||
| 516 | { | ||
| 517 | environmentId: EnvironmentId.make("environment-1"), | ||
| 518 | label: "Old ssh environment", | ||
| 519 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 520 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 521 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 522 | lastConnectedAt: null, | ||
| 523 | desktopSsh: { | ||
| 524 | alias: "devbox", | ||
| 525 | hostname: "devbox.example.com", | ||
| 526 | username: "julius", | ||
| 527 | port: 22, | ||
| 528 | }, | ||
| 529 | }, | ||
| 530 | ]; | ||
| 531 | |||
| 532 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 533 | |||
| 534 | await expect( | ||
| 535 | addSavedEnvironment({ | ||
| 536 | label: "Remote environment", | ||
| 537 | host: "http://127.0.0.1:3774/", | ||
| 538 | pairingCode: "ssh-pairing-code", | ||
| 539 | desktopSsh: { | ||
| 540 | alias: "devbox", | ||
| 541 | hostname: "devbox.example.com", | ||
| 542 | username: "julius", | ||
| 543 | port: 22, | ||
| 544 | }, | ||
| 545 | }), | ||
| 546 | ).resolves.toMatchObject({ | ||
| 547 | environmentId: EnvironmentId.make("environment-2"), | ||
| 548 | }); | ||
| 549 | |||
| 550 | expect(mockUpsert).toHaveBeenCalledWith( | ||
| 551 | expect.objectContaining({ | ||
| 552 | environmentId: EnvironmentId.make("environment-2"), | ||
| 553 | }), | ||
| 554 | ); | ||
| 555 | expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); | ||
| 556 | expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 557 | EnvironmentId.make("environment-1"), | ||
| 558 | ); | ||
| 559 | |||
| 560 | await resetEnvironmentServiceForTests(); | ||
| 561 | }); | ||
| 562 | |||
| 563 | it("retries desktop ssh session refresh when the forwarded endpoint returns ssh_http 401", async () => { | ||
| 564 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 565 | mockBootstrapSshBearerSession | ||
| 566 | .mockResolvedValueOnce({ | ||
| 567 | access_token: "ssh-bearer-token", | ||
| 568 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 569 | }) | ||
| 570 | .mockResolvedValueOnce({ | ||
| 571 | access_token: "ssh-bearer-token-2", | ||
| 572 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 573 | }); | ||
| 574 | mockFetchSshSessionState | ||
| 575 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) | ||
| 576 | .mockResolvedValueOnce({ | ||
| 577 | authenticated: true, | ||
| 578 | scopes: ["orchestration:read", "access:write"], | ||
| 579 | }); | ||
| 580 | |||
| 581 | const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = | ||
| 582 | await import("./service"); | ||
| 583 | |||
| 584 | await expect( | ||
| 585 | connectDesktopSshEnvironment({ | ||
| 586 | alias: "devbox", | ||
| 587 | hostname: "devbox", | ||
| 588 | username: null, | ||
| 589 | port: null, | ||
| 590 | }), | ||
| 591 | ).resolves.toMatchObject({ | ||
| 592 | environmentId: EnvironmentId.make("environment-1"), | ||
| 593 | }); | ||
| 594 | |||
| 595 | expect(mockEnsureSshEnvironment).toHaveBeenCalled(); | ||
| 596 | expect(mockBootstrapSshBearerSession).toHaveBeenCalledTimes(2); | ||
| 597 | expect(mockFetchSshSessionState).toHaveBeenCalledTimes(2); | ||
| 598 | |||
| 599 | await resetEnvironmentServiceForTests(); | ||
| 600 | }); | ||
| 601 | |||
| 602 | it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { | ||
| 603 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 604 | const authError = decodeEnvironmentAuthInvalidError({ | ||
| 605 | _tag: "EnvironmentAuthInvalidError", | ||
| 606 | code: "auth_invalid", | ||
| 607 | reason: "invalid_credential", | ||
| 608 | traceId: "trace-auth-test", | ||
| 609 | }); | ||
| 610 | mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError)); | ||
| 611 | |||
| 612 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 613 | |||
| 614 | await expect( | ||
| 615 | addSavedEnvironment({ | ||
| 616 | label: "Remote environment", | ||
| 617 | host: "remote.example.com", | ||
| 618 | pairingCode: "123456", | ||
| 619 | }), | ||
| 620 | ).rejects.toThrow("Saved environment credential expired. Pair it again."); | ||
| 621 | |||
| 622 | expect(mockEnsureSshEnvironment).not.toHaveBeenCalled(); | ||
| 623 | expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); | ||
| 624 | expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 625 | EnvironmentId.make("environment-1"), | ||
| 626 | ); | ||
| 627 | |||
| 628 | await resetEnvironmentServiceForTests(); | ||
| 629 | }); | ||
| 630 | |||
| 631 | it("only registers the retried ssh connection after bearer re-issuance succeeds", async () => { | ||
| 632 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 633 | mockBootstrapSshBearerSession | ||
| 634 | .mockResolvedValueOnce({ | ||
| 635 | access_token: "ssh-bearer-token", | ||
| 636 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 637 | }) | ||
| 638 | .mockResolvedValueOnce({ | ||
| 639 | access_token: "ssh-bearer-token-2", | ||
| 640 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 641 | }); | ||
| 642 | mockFetchSshSessionState | ||
| 643 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) | ||
| 644 | .mockResolvedValueOnce({ | ||
| 645 | authenticated: true, | ||
| 646 | scopes: ["orchestration:read", "access:write"], | ||
| 647 | }); | ||
| 648 | |||
| 649 | const createdConnections: Array<{ | ||
| 650 | readonly environmentId: EnvironmentId; | ||
| 651 | readonly dispose: ReturnType<typeof vi.fn>; | ||
| 652 | }> = []; | ||
| 653 | mockCreateEnvironmentConnection.mockImplementation( | ||
| 654 | (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => { | ||
| 655 | const connection = { | ||
| 656 | kind: "saved" as const, | ||
| 657 | environmentId: input.knownEnvironment.environmentId, | ||
| 658 | knownEnvironment: input.knownEnvironment, | ||
| 659 | client: input.client, | ||
| 660 | ensureBootstrapped: async () => undefined, | ||
| 661 | reconnect: async () => undefined, | ||
| 662 | dispose: vi.fn(async () => undefined), | ||
| 663 | }; | ||
| 664 | createdConnections.push(connection); | ||
| 665 | return connection; | ||
| 666 | }, | ||
| 667 | ); | ||
| 668 | |||
| 669 | const { | ||
| 670 | connectDesktopSshEnvironment, | ||
| 671 | listEnvironmentConnections, | ||
| 672 | resetEnvironmentServiceForTests, | ||
| 673 | } = await import("./service"); | ||
| 674 | |||
| 675 | await connectDesktopSshEnvironment({ | ||
| 676 | alias: "devbox", | ||
| 677 | hostname: "devbox", | ||
| 678 | username: null, | ||
| 679 | port: null, | ||
| 680 | }); | ||
| 681 | |||
| 682 | expect(createdConnections).toHaveLength(2); | ||
| 683 | expect(createdConnections[0]?.dispose).toHaveBeenCalledTimes(1); | ||
| 684 | expect(listEnvironmentConnections()).toHaveLength(1); | ||
| 685 | expect(listEnvironmentConnections()[0]).toBe(createdConnections[1]); | ||
| 686 | |||
| 687 | await resetEnvironmentServiceForTests(); | ||
| 688 | }); | ||
| 689 | |||
| 690 | it("marks desktop ssh reconnect failures as runtime errors when bearer recovery fails", async () => { | ||
| 691 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 692 | |||
| 693 | const connection = { | ||
| 694 | kind: "saved" as const, | ||
| 695 | environmentId: EnvironmentId.make("environment-1"), | ||
| 696 | knownEnvironment: { | ||
| 697 | environmentId: EnvironmentId.make("environment-1"), | ||
| 698 | }, | ||
| 699 | client: { | ||
| 700 | terminal: { | ||
| 701 | onMetadata: vi.fn(() => () => undefined), | ||
| 702 | }, | ||
| 703 | preview: { | ||
| 704 | subscribePorts: vi.fn(() => () => undefined), | ||
| 705 | }, | ||
| 706 | }, | ||
| 707 | ensureBootstrapped: async () => undefined, | ||
| 708 | reconnect: vi.fn(async () => { | ||
| 709 | throw new Error("socket closed"); | ||
| 710 | }), | ||
| 711 | dispose: async () => undefined, | ||
| 712 | }; | ||
| 713 | mockCreateEnvironmentConnection.mockReturnValue(connection); | ||
| 714 | |||
| 715 | const { addSavedEnvironment, reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 716 | await import("./service"); | ||
| 717 | |||
| 718 | await addSavedEnvironment({ | ||
| 719 | label: "Remote environment", | ||
| 720 | host: "http://127.0.0.1:3774/", | ||
| 721 | pairingCode: "ssh-pairing-code", | ||
| 722 | desktopSsh: { | ||
| 723 | alias: "devbox", | ||
| 724 | hostname: "devbox.example.com", | ||
| 725 | username: "julius", | ||
| 726 | port: 22, | ||
| 727 | }, | ||
| 728 | }); | ||
| 729 | |||
| 730 | mockSavedRecords = [ | ||
| 731 | { | ||
| 732 | environmentId: EnvironmentId.make("environment-1"), | ||
| 733 | label: "Remote environment", | ||
| 734 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 735 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 736 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 737 | lastConnectedAt: null, | ||
| 738 | desktopSsh: { | ||
| 739 | alias: "devbox", | ||
| 740 | hostname: "devbox.example.com", | ||
| 741 | username: "julius", | ||
| 742 | port: 22, | ||
| 743 | }, | ||
| 744 | }, | ||
| 745 | ]; | ||
| 746 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); | ||
| 747 | |||
| 748 | await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( | ||
| 749 | "Unable to persist saved environment credentials.", | ||
| 750 | ); | ||
| 751 | |||
| 752 | expect(mockPatchRuntime).toHaveBeenCalledWith( | ||
| 753 | EnvironmentId.make("environment-1"), | ||
| 754 | expect.objectContaining({ | ||
| 755 | connectionState: "error", | ||
| 756 | lastError: "Unable to persist saved environment credentials.", | ||
| 757 | }), | ||
| 758 | ); | ||
| 759 | |||
| 760 | await resetEnvironmentServiceForTests(); | ||
| 761 | }); | ||
| 762 | |||
| 763 | it("bootstraps a desktop ssh environment through the desktop bridge", async () => { | ||
| 764 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 765 | |||
| 766 | const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = | ||
| 767 | await import("./service"); | ||
| 768 | |||
| 769 | await expect( | ||
| 770 | connectDesktopSshEnvironment({ | ||
| 771 | alias: "devbox", | ||
| 772 | hostname: "devbox", | ||
| 773 | username: null, | ||
| 774 | port: null, | ||
| 775 | }), | ||
| 776 | ).resolves.toMatchObject({ | ||
| 777 | environmentId: EnvironmentId.make("environment-1"), | ||
| 778 | }); | ||
| 779 | |||
| 780 | expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( | ||
| 781 | { | ||
| 782 | alias: "devbox", | ||
| 783 | hostname: "devbox", | ||
| 784 | username: null, | ||
| 785 | port: null, | ||
| 786 | }, | ||
| 787 | { issuePairingToken: true }, | ||
| 788 | ); | ||
| 789 | expect(mockResolveRemotePairingTarget).toHaveBeenCalledWith({ | ||
| 790 | host: "http://127.0.0.1:3774/", | ||
| 791 | pairingCode: "ssh-pairing-code", | ||
| 792 | }); | ||
| 793 | expect(mockFetchSshEnvironmentDescriptor).toHaveBeenCalledWith("http://127.0.0.1:3774/"); | ||
| 794 | expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( | ||
| 795 | "http://127.0.0.1:3774/", | ||
| 796 | "ssh-pairing-code", | ||
| 797 | ); | ||
| 798 | expect(mockFetchRemoteEnvironmentDescriptor).not.toHaveBeenCalled(); | ||
| 799 | expect(mockBootstrapRemoteBearerSession).not.toHaveBeenCalled(); | ||
| 800 | expect(mockUpsert.mock.invocationCallOrder[0]).toBeLessThan( | ||
| 801 | mockCreateEnvironmentConnection.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, | ||
| 802 | ); | ||
| 803 | |||
| 804 | await resetEnvironmentServiceForTests(); | ||
| 805 | }); | ||
| 806 | |||
| 807 | it("disconnects the desktop ssh process before removing a saved ssh environment", async () => { | ||
| 808 | mockSavedRecords = [ | ||
| 809 | { | ||
| 810 | environmentId: EnvironmentId.make("environment-1"), | ||
| 811 | label: "Remote environment", | ||
| 812 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 813 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 814 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 815 | lastConnectedAt: null, | ||
| 816 | desktopSsh: { | ||
| 817 | alias: "devbox", | ||
| 818 | hostname: "devbox.example.com", | ||
| 819 | username: "julius", | ||
| 820 | port: 22, | ||
| 821 | }, | ||
| 822 | }, | ||
| 823 | ]; | ||
| 824 | |||
| 825 | const { removeSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 826 | |||
| 827 | await removeSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 828 | |||
| 829 | expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ | ||
| 830 | alias: "devbox", | ||
| 831 | hostname: "devbox.example.com", | ||
| 832 | username: "julius", | ||
| 833 | port: 22, | ||
| 834 | }); | ||
| 835 | expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); | ||
| 836 | expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 837 | EnvironmentId.make("environment-1"), | ||
| 838 | ); | ||
| 839 | expect(mockDisconnectSshEnvironment.mock.invocationCallOrder[0]).toBeLessThan( | ||
| 840 | mockRemove.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, | ||
| 841 | ); | ||
| 842 | |||
| 843 | await resetEnvironmentServiceForTests(); | ||
| 844 | }); | ||
| 845 | |||
| 846 | it("disconnects a saved ssh environment without removing its saved record", async () => { | ||
| 847 | mockSavedRecords = [ | ||
| 848 | { | ||
| 849 | environmentId: EnvironmentId.make("environment-1"), | ||
| 850 | label: "Remote environment", | ||
| 851 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 852 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 853 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 854 | lastConnectedAt: null, | ||
| 855 | desktopSsh: { | ||
| 856 | alias: "devbox", | ||
| 857 | hostname: "devbox.example.com", | ||
| 858 | username: "julius", | ||
| 859 | port: 22, | ||
| 860 | }, | ||
| 861 | }, | ||
| 862 | ]; | ||
| 863 | |||
| 864 | const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 865 | await import("./service"); | ||
| 866 | |||
| 867 | await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 868 | |||
| 869 | expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ | ||
| 870 | alias: "devbox", | ||
| 871 | hostname: "devbox.example.com", | ||
| 872 | username: "julius", | ||
| 873 | port: 22, | ||
| 874 | }); | ||
| 875 | expect(mockRemove).not.toHaveBeenCalled(); | ||
| 876 | expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 877 | EnvironmentId.make("environment-1"), | ||
| 878 | ); | ||
| 879 | |||
| 880 | await resetEnvironmentServiceForTests(); | ||
| 881 | }); | ||
| 882 | |||
| 883 | it("keeps remote environment credentials when disconnecting a non-ssh saved environment", async () => { | ||
| 884 | mockSavedRecords = [ | ||
| 885 | { | ||
| 886 | environmentId: EnvironmentId.make("environment-1"), | ||
| 887 | label: "Remote environment", | ||
| 888 | httpBaseUrl: "https://remote.example.com/", | ||
| 889 | wsBaseUrl: "wss://remote.example.com/", | ||
| 890 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 891 | lastConnectedAt: null, | ||
| 892 | }, | ||
| 893 | ]; | ||
| 894 | |||
| 895 | const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 896 | await import("./service"); | ||
| 897 | |||
| 898 | await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 899 | |||
| 900 | expect(mockDisconnectSshEnvironment).not.toHaveBeenCalled(); | ||
| 901 | expect(mockRemove).not.toHaveBeenCalled(); | ||
| 902 | expect(mockRemoveSavedEnvironmentBearerToken).not.toHaveBeenCalled(); | ||
| 903 | |||
| 904 | await resetEnvironmentServiceForTests(); | ||
| 905 | }); | ||
| 906 | |||
| 907 | it("cancels a pending saved environment connection when disconnected", async () => { | ||
| 908 | mockSavedRecords = [ | ||
| 909 | { | ||
| 910 | environmentId: EnvironmentId.make("environment-1"), | ||
| 911 | label: "Remote environment", | ||
| 912 | httpBaseUrl: "https://remote.example.com/", | ||
| 913 | wsBaseUrl: "wss://remote.example.com/", | ||
| 914 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 915 | lastConnectedAt: null, | ||
| 916 | }, | ||
| 917 | ]; | ||
| 918 | mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); | ||
| 919 | const dispose = vi.fn(async () => undefined); | ||
| 920 | mockCreateEnvironmentConnection.mockImplementation( | ||
| 921 | (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ | ||
| 922 | kind: "saved" as const, | ||
| 923 | environmentId: input.knownEnvironment.environmentId, | ||
| 924 | knownEnvironment: input.knownEnvironment, | ||
| 925 | client: input.client, | ||
| 926 | ensureBootstrapped: async () => undefined, | ||
| 927 | reconnect: async () => undefined, | ||
| 928 | dispose, | ||
| 929 | }), | ||
| 930 | ); | ||
| 931 | let resolveSessionState!: (value: { | ||
| 932 | readonly authenticated: true; | ||
| 933 | readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; | ||
| 934 | }) => void; | ||
| 935 | mockFetchRemoteSessionState.mockReturnValue( | ||
| 936 | Effect.promise( | ||
| 937 | () => | ||
| 938 | new Promise((resolve) => { | ||
| 939 | resolveSessionState = resolve; | ||
| 940 | }), | ||
| 941 | ), | ||
| 942 | ); | ||
| 943 | |||
| 944 | const { | ||
| 945 | disconnectSavedEnvironment, | ||
| 946 | listEnvironmentConnections, | ||
| 947 | reconnectSavedEnvironment, | ||
| 948 | resetEnvironmentServiceForTests, | ||
| 949 | } = await import("./service"); | ||
| 950 | |||
| 951 | const reconnectPromise = reconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 952 | await vi.waitFor(() => { | ||
| 953 | expect(mockFetchRemoteSessionState).toHaveBeenCalledOnce(); | ||
| 954 | }); | ||
| 955 | |||
| 956 | await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 957 | resolveSessionState({ | ||
| 958 | authenticated: true, | ||
| 959 | scopes: ["orchestration:read", "access:write"], | ||
| 960 | }); | ||
| 961 | await expect(reconnectPromise).resolves.toBeUndefined(); | ||
| 962 | |||
| 963 | expect(listEnvironmentConnections()).toHaveLength(0); | ||
| 964 | expect(dispose).toHaveBeenCalledOnce(); | ||
| 965 | expect(mockPatchRuntime).not.toHaveBeenCalledWith( | ||
| 966 | EnvironmentId.make("environment-1"), | ||
| 967 | expect.objectContaining({ | ||
| 968 | connectionState: "error", | ||
| 969 | }), | ||
| 970 | ); | ||
| 971 | |||
| 972 | await resetEnvironmentServiceForTests(); | ||
| 973 | }); | ||
| 974 | |||
| 975 | it("reissues ssh pairing credentials when connecting after a manual ssh disconnect", async () => { | ||
| 976 | mockSavedRecords = [ | ||
| 977 | { | ||
| 978 | environmentId: EnvironmentId.make("environment-1"), | ||
| 979 | label: "Remote environment", | ||
| 980 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 981 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 982 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 983 | lastConnectedAt: null, | ||
| 984 | desktopSsh: { | ||
| 985 | alias: "devbox", | ||
| 986 | hostname: "devbox.example.com", | ||
| 987 | username: "julius", | ||
| 988 | port: 22, | ||
| 989 | }, | ||
| 990 | }, | ||
| 991 | ]; | ||
| 992 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | ||
| 993 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 994 | |||
| 995 | const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 996 | await import("./service"); | ||
| 997 | |||
| 998 | await reconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 999 | |||
| 1000 | expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( | ||
| 1001 | { | ||
| 1002 | alias: "devbox", | ||
| 1003 | hostname: "devbox.example.com", | ||
| 1004 | username: "julius", | ||
| 1005 | port: 22, | ||
| 1006 | }, | ||
| 1007 | { issuePairingToken: true }, | ||
| 1008 | ); | ||
| 1009 | expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( | ||
| 1010 | "http://127.0.0.1:3774/", | ||
| 1011 | "ssh-pairing-code", | ||
| 1012 | ); | ||
| 1013 | expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 1014 | EnvironmentId.make("environment-1"), | ||
| 1015 | "ssh-bearer-token", | ||
| 1016 | ); | ||
| 1017 | |||
| 1018 | await resetEnvironmentServiceForTests(); | ||
| 1019 | }); | ||
| 1020 | |||
| 1021 | it("rolls back ssh registry metadata when pairing token issuance fails", async () => { | ||
| 1022 | const originalRecord = { | ||
| 1023 | environmentId: EnvironmentId.make("environment-1"), | ||
| 1024 | label: "Remote environment", | ||
| 1025 | httpBaseUrl: "http://127.0.0.1:3773/", | ||
| 1026 | wsBaseUrl: "ws://127.0.0.1:3773/", | ||
| 1027 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 1028 | lastConnectedAt: null, | ||
| 1029 | desktopSsh: { | ||
| 1030 | alias: "devbox", | ||
| 1031 | hostname: "devbox.example.com", | ||
| 1032 | username: "julius", | ||
| 1033 | port: 22, | ||
| 1034 | }, | ||
| 1035 | }; | ||
| 1036 | mockSavedRecords = [originalRecord]; | ||
| 1037 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | ||
| 1038 | mockEnsureSshEnvironment.mockResolvedValue({ | ||
| 1039 | target: { | ||
| 1040 | alias: "devbox", | ||
| 1041 | hostname: "devbox.example.com", | ||
| 1042 | username: "julius", | ||
| 1043 | port: 22, | ||
| 1044 | }, | ||
| 1045 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 1046 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 1047 | pairingToken: null, | ||
| 1048 | }); | ||
| 1049 | |||
| 1050 | const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 1051 | await import("./service"); | ||
| 1052 | |||
| 1053 | await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( | ||
| 1054 | "Desktop SSH launch did not return a pairing token.", | ||
| 1055 | ); | ||
| 1056 | |||
| 1057 | expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledWith( | ||
| 1058 | expect.objectContaining({ | ||
| 1059 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 1060 | }), | ||
| 1061 | ); | ||
| 1062 | expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([originalRecord]); | ||
| 1063 | expect(mockSavedRecords).toEqual([originalRecord]); | ||
| 1064 | expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); | ||
| 1065 | |||
| 1066 | await resetEnvironmentServiceForTests(); | ||
| 1067 | }); | ||
| 1068 | |||
| 1069 | it("surfaces desktop ssh bootstrap failures during saved ssh reconnect", async () => { | ||
| 1070 | mockSavedRecords = [ | ||
| 1071 | { | ||
| 1072 | environmentId: EnvironmentId.make("environment-1"), | ||
| 1073 | label: "Remote environment", | ||
| 1074 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 1075 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 1076 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 1077 | lastConnectedAt: null, | ||
| 1078 | desktopSsh: { | ||
| 1079 | alias: "devbox", | ||
| 1080 | hostname: "devbox.example.com", | ||
| 1081 | username: "julius", | ||
| 1082 | port: 22, | ||
| 1083 | }, | ||
| 1084 | }, | ||
| 1085 | ]; | ||
| 1086 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | ||
| 1087 | mockEnsureSshEnvironment.mockRejectedValue(new Error("SSH command timed out after 60000ms.")); | ||
| 1088 | |||
| 1089 | const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 1090 | await import("./service"); | ||
| 1091 | |||
| 1092 | await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( | ||
| 1093 | "SSH command timed out after 60000ms.", | ||
| 1094 | ); | ||
| 1095 | expect(mockPatchRuntime).toHaveBeenCalledWith( | ||
| 1096 | EnvironmentId.make("environment-1"), | ||
| 1097 | expect.objectContaining({ | ||
| 1098 | connectionState: "connecting", | ||
| 1099 | }), | ||
| 1100 | ); | ||
| 1101 | expect(mockPatchRuntime).toHaveBeenCalledWith( | ||
| 1102 | EnvironmentId.make("environment-1"), | ||
| 1103 | expect.objectContaining({ | ||
| 1104 | connectionState: "error", | ||
| 1105 | lastError: "SSH command timed out after 60000ms.", | ||
| 1106 | }), | ||
| 1107 | ); | ||
| 1108 | |||
| 1109 | await resetEnvironmentServiceForTests(); | ||
| 1110 | }); | ||
| 1111 | }); | ||
apps/web/src/environments/runtime/service.savedEnvironments.test.ts deleted-332| ... | @@ -1,332 +0,0 @@ | ||
| 1 | import { QueryClient } from "@tanstack/react-query"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | const mockCreateEnvironmentConnection = vi.fn(); | ||
| 6 | const mockCreateWsRpcClient = vi.fn(); | ||
| 7 | const mockFetchRemoteSessionState = vi.fn(); | ||
| 8 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(() => "ws://remote.example.test"); | ||
| 9 | const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect); | ||
| 10 | const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); | ||
| 11 | const mockListSavedEnvironmentRecords = vi.fn(); | ||
| 12 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | ||
| 13 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | ||
| 14 | const mockReadSavedEnvironmentCredential = vi.fn(); | ||
| 15 | const mockGetSavedEnvironmentRecord = vi.fn(); | ||
| 16 | |||
| 17 | function MockWsTransport() { | ||
| 18 | return undefined; | ||
| 19 | } | ||
| 20 | |||
| 21 | vi.mock("../primary", () => ({ | ||
| 22 | getPrimaryKnownEnvironment: vi.fn(() => ({ | ||
| 23 | id: "env-1", | ||
| 24 | label: "Primary environment", | ||
| 25 | source: "window-origin", | ||
| 26 | target: { | ||
| 27 | httpBaseUrl: "http://127.0.0.1:3000/", | ||
| 28 | wsBaseUrl: "ws://127.0.0.1:3000/", | ||
| 29 | }, | ||
| 30 | environmentId: EnvironmentId.make("env-1"), | ||
| 31 | })), | ||
| 32 | })); | ||
| 33 | |||
| 34 | vi.mock("../../lib/runtime", () => ({ | ||
| 35 | webRuntime: { | ||
| 36 | runPromise: mockRemoteHttpRunPromise, | ||
| 37 | }, | ||
| 38 | })); | ||
| 39 | |||
| 40 | vi.mock("./catalog", () => ({ | ||
| 41 | getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, | ||
| 42 | hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), | ||
| 43 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | ||
| 44 | persistSavedEnvironmentRecord: vi.fn(), | ||
| 45 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, | ||
| 46 | readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, | ||
| 47 | removeSavedEnvironmentBearerToken: vi.fn(), | ||
| 48 | useSavedEnvironmentRegistryStore: { | ||
| 49 | subscribe: mockSavedEnvironmentRegistrySubscribe, | ||
| 50 | getState: () => ({ | ||
| 51 | upsert: vi.fn(), | ||
| 52 | remove: vi.fn(), | ||
| 53 | markConnected: vi.fn(), | ||
| 54 | rename: vi.fn(), | ||
| 55 | }), | ||
| 56 | }, | ||
| 57 | useSavedEnvironmentRuntimeStore: { | ||
| 58 | getState: () => ({ | ||
| 59 | ensure: vi.fn(), | ||
| 60 | patch: vi.fn(), | ||
| 61 | clear: vi.fn(), | ||
| 62 | }), | ||
| 63 | }, | ||
| 64 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, | ||
| 65 | writeSavedEnvironmentBearerToken: vi.fn(), | ||
| 66 | writeSavedEnvironmentCredential: vi.fn(), | ||
| 67 | })); | ||
| 68 | |||
| 69 | vi.mock("./connection", async (importOriginal) => ({ | ||
| 70 | ...(await importOriginal<typeof import("./connection")>()), | ||
| 71 | createEnvironmentConnection: mockCreateEnvironmentConnection, | ||
| 72 | })); | ||
| 73 | |||
| 74 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ||
| 75 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); | ||
| 76 | return { | ||
| 77 | ...actual, | ||
| 78 | createWsRpcClient: mockCreateWsRpcClient, | ||
| 79 | fetchRemoteSessionState: mockFetchRemoteSessionState, | ||
| 80 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | ||
| 81 | }; | ||
| 82 | }); | ||
| 83 | |||
| 84 | vi.mock("../../rpc/wsTransport", () => ({ | ||
| 85 | WsTransport: MockWsTransport, | ||
| 86 | })); | ||
| 87 | |||
| 88 | vi.mock("~/composerDraftStore", () => ({ | ||
| 89 | markPromotedDraftThreadByRef: vi.fn(), | ||
| 90 | markPromotedDraftThreadsByRef: vi.fn(), | ||
| 91 | useComposerDraftStore: { | ||
| 92 | getState: () => ({ | ||
| 93 | getDraftThreadByRef: vi.fn(() => null), | ||
| 94 | clearDraftThread: vi.fn(), | ||
| 95 | }), | ||
| 96 | }, | ||
| 97 | })); | ||
| 98 | |||
| 99 | vi.mock("~/localApi", () => ({ | ||
| 100 | ensureLocalApi: vi.fn(() => ({ | ||
| 101 | persistence: { | ||
| 102 | setSavedEnvironmentRegistry: vi.fn(async () => undefined), | ||
| 103 | }, | ||
| 104 | })), | ||
| 105 | })); | ||
| 106 | |||
| 107 | vi.mock("~/lib/terminalStateCleanup", () => ({ | ||
| 108 | collectActiveTerminalThreadIds: vi.fn(() => []), | ||
| 109 | })); | ||
| 110 | |||
| 111 | vi.mock("~/orchestrationEventEffects", () => ({ | ||
| 112 | deriveOrchestrationBatchEffects: vi.fn(() => ({ | ||
| 113 | promotedThreadRefs: [], | ||
| 114 | invalidatedProviderState: false, | ||
| 115 | })), | ||
| 116 | })); | ||
| 117 | |||
| 118 | vi.mock("~/store", () => ({ | ||
| 119 | useStore: { | ||
| 120 | getState: () => ({ | ||
| 121 | syncServerShellSnapshot: vi.fn(), | ||
| 122 | syncServerThreadDetail: vi.fn(), | ||
| 123 | removeServerThreadDetail: vi.fn(), | ||
| 124 | applyServerShellEvent: vi.fn(), | ||
| 125 | }), | ||
| 126 | }, | ||
| 127 | selectProjectsAcrossEnvironments: vi.fn(() => []), | ||
| 128 | selectSidebarThreadSummaryByRef: vi.fn(() => null), | ||
| 129 | selectThreadByRef: vi.fn(() => null), | ||
| 130 | selectThreadsAcrossEnvironments: vi.fn(() => []), | ||
| 131 | })); | ||
| 132 | |||
| 133 | vi.mock("~/terminalStateStore", () => ({ | ||
| 134 | useTerminalStateStore: { | ||
| 135 | getState: () => ({ | ||
| 136 | applyTerminalEvent: vi.fn(), | ||
| 137 | removeTerminalState: vi.fn(), | ||
| 138 | clearTerminalSelection: vi.fn(), | ||
| 139 | }), | ||
| 140 | }, | ||
| 141 | })); | ||
| 142 | |||
| 143 | vi.mock("~/uiStateStore", () => ({ | ||
| 144 | useUiStateStore: { | ||
| 145 | getState: () => ({ | ||
| 146 | clearThreadUi: vi.fn(), | ||
| 147 | syncPromotedDraftThreadRefs: vi.fn(), | ||
| 148 | }), | ||
| 149 | }, | ||
| 150 | })); | ||
| 151 | |||
| 152 | const savedRecord = { | ||
| 153 | environmentId: EnvironmentId.make("env-saved"), | ||
| 154 | label: "Remote environment", | ||
| 155 | httpBaseUrl: "https://remote.example.test/", | ||
| 156 | wsBaseUrl: "wss://remote.example.test/", | ||
| 157 | }; | ||
| 158 | |||
| 159 | const configSnapshot = { | ||
| 160 | environment: { | ||
| 161 | environmentId: savedRecord.environmentId, | ||
| 162 | label: "Remote environment", | ||
| 163 | }, | ||
| 164 | }; | ||
| 165 | |||
| 166 | function createClient() { | ||
| 167 | return { | ||
| 168 | dispose: vi.fn(async () => undefined), | ||
| 169 | reconnect: vi.fn(async () => undefined), | ||
| 170 | server: { | ||
| 171 | getConfig: vi.fn(async () => configSnapshot), | ||
| 172 | subscribeConfig: vi.fn(() => () => undefined), | ||
| 173 | subscribeLifecycle: vi.fn(() => () => undefined), | ||
| 174 | subscribeAuthAccess: vi.fn(() => () => undefined), | ||
| 175 | refreshProviders: vi.fn(async () => undefined), | ||
| 176 | upsertKeybinding: vi.fn(async () => undefined), | ||
| 177 | getSettings: vi.fn(async () => undefined), | ||
| 178 | updateSettings: vi.fn(async () => undefined), | ||
| 179 | }, | ||
| 180 | orchestration: { | ||
| 181 | subscribeShell: vi.fn(() => () => undefined), | ||
| 182 | subscribeThread: vi.fn(() => () => undefined), | ||
| 183 | dispatchCommand: vi.fn(async () => undefined), | ||
| 184 | getTurnDiff: vi.fn(async () => undefined), | ||
| 185 | getFullThreadDiff: vi.fn(async () => undefined), | ||
| 186 | }, | ||
| 187 | terminal: { | ||
| 188 | open: vi.fn(async () => undefined), | ||
| 189 | write: vi.fn(async () => undefined), | ||
| 190 | resize: vi.fn(async () => undefined), | ||
| 191 | clear: vi.fn(async () => undefined), | ||
| 192 | restart: vi.fn(async () => undefined), | ||
| 193 | close: vi.fn(async () => undefined), | ||
| 194 | onMetadata: vi.fn(() => () => undefined), | ||
| 195 | }, | ||
| 196 | preview: { | ||
| 197 | subscribePorts: vi.fn(() => () => undefined), | ||
| 198 | }, | ||
| 199 | projects: { | ||
| 200 | searchEntries: vi.fn(async () => []), | ||
| 201 | writeFile: vi.fn(async () => undefined), | ||
| 202 | }, | ||
| 203 | shell: { | ||
| 204 | openInEditor: vi.fn(async () => undefined), | ||
| 205 | }, | ||
| 206 | git: { | ||
| 207 | pull: vi.fn(async () => undefined), | ||
| 208 | refreshStatus: vi.fn(async () => undefined), | ||
| 209 | onStatus: vi.fn(() => () => undefined), | ||
| 210 | runStackedAction: vi.fn(async () => ({})), | ||
| 211 | listBranches: vi.fn(async () => []), | ||
| 212 | createWorktree: vi.fn(async () => undefined), | ||
| 213 | removeWorktree: vi.fn(async () => undefined), | ||
| 214 | createBranch: vi.fn(async () => undefined), | ||
| 215 | checkout: vi.fn(async () => undefined), | ||
| 216 | init: vi.fn(async () => undefined), | ||
| 217 | resolvePullRequest: vi.fn(async () => undefined), | ||
| 218 | preparePullRequestThread: vi.fn(async () => undefined), | ||
| 219 | }, | ||
| 220 | }; | ||
| 221 | } | ||
| 222 | |||
| 223 | describe("saved environment startup", () => { | ||
| 224 | beforeEach(() => { | ||
| 225 | vi.useFakeTimers(); | ||
| 226 | vi.resetModules(); | ||
| 227 | vi.clearAllMocks(); | ||
| 228 | |||
| 229 | mockFetchRemoteSessionState.mockResolvedValue({ | ||
| 230 | authenticated: true, | ||
| 231 | scopes: ["orchestration:read", "access:write"], | ||
| 232 | }); | ||
| 233 | mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) => | ||
| 234 | environmentId === savedRecord.environmentId ? savedRecord : null, | ||
| 235 | ); | ||
| 236 | mockListSavedEnvironmentRecords.mockReturnValue([savedRecord]); | ||
| 237 | mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); | ||
| 238 | mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); | ||
| 239 | mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token"); | ||
| 240 | mockReadSavedEnvironmentCredential.mockImplementation(async () => { | ||
| 241 | const token = await mockReadSavedEnvironmentBearerToken(); | ||
| 242 | return token ? { version: 1, method: "bearer", token } : null; | ||
| 243 | }); | ||
| 244 | mockCreateWsRpcClient.mockImplementation(() => createClient()); | ||
| 245 | mockCreateEnvironmentConnection.mockImplementation((input) => { | ||
| 246 | if (input.kind === "saved") { | ||
| 247 | queueMicrotask(() => { | ||
| 248 | input.onConfigSnapshot?.(configSnapshot); | ||
| 249 | }); | ||
| 250 | } | ||
| 251 | |||
| 252 | return { | ||
| 253 | kind: input.kind, | ||
| 254 | environmentId: input.knownEnvironment.environmentId, | ||
| 255 | knownEnvironment: input.knownEnvironment, | ||
| 256 | client: input.client, | ||
| 257 | ensureBootstrapped: vi.fn(async () => undefined), | ||
| 258 | reconnect: vi.fn(async () => undefined), | ||
| 259 | dispose: vi.fn(async () => undefined), | ||
| 260 | }; | ||
| 261 | }); | ||
| 262 | }); | ||
| 263 | |||
| 264 | afterEach(async () => { | ||
| 265 | const { resetEnvironmentServiceForTests } = await import("./service"); | ||
| 266 | await resetEnvironmentServiceForTests(); | ||
| 267 | vi.useRealTimers(); | ||
| 268 | }); | ||
| 269 | |||
| 270 | it("uses the initial config snapshot instead of issuing an extra getConfig call", async () => { | ||
| 271 | const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } = | ||
| 272 | await import("./service"); | ||
| 273 | |||
| 274 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 275 | await vi.runAllTimersAsync(); | ||
| 276 | |||
| 277 | const savedConnectionCall = mockCreateEnvironmentConnection.mock.calls.find( | ||
| 278 | ([input]) => input.kind === "saved", | ||
| 279 | ); | ||
| 280 | expect(savedConnectionCall).toBeDefined(); | ||
| 281 | |||
| 282 | const savedClient = savedConnectionCall?.[0]?.client; | ||
| 283 | expect(savedClient.server.getConfig).not.toHaveBeenCalled(); | ||
| 284 | expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1); | ||
| 285 | |||
| 286 | stop(); | ||
| 287 | await resetEnvironmentServiceForTests(); | ||
| 288 | }); | ||
| 289 | |||
| 290 | it("coalesces hydration and registry sync so the initial saved connection only starts once", async () => { | ||
| 291 | let finishHydration!: () => void; | ||
| 292 | let finishTokenRead!: (token: string) => void; | ||
| 293 | |||
| 294 | mockWaitForSavedEnvironmentRegistryHydration.mockImplementation( | ||
| 295 | () => | ||
| 296 | new Promise<void>((resolve) => { | ||
| 297 | finishHydration = () => resolve(); | ||
| 298 | }), | ||
| 299 | ); | ||
| 300 | mockReadSavedEnvironmentBearerToken.mockImplementation( | ||
| 301 | () => | ||
| 302 | new Promise<string>((resolve) => { | ||
| 303 | finishTokenRead = resolve; | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | |||
| 307 | const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } = | ||
| 308 | await import("./service"); | ||
| 309 | |||
| 310 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 311 | const registryListener = mockSavedEnvironmentRegistrySubscribe.mock.calls[0]?.[0]; | ||
| 312 | expect(registryListener).toBeTypeOf("function"); | ||
| 313 | |||
| 314 | registryListener?.(); | ||
| 315 | finishHydration(); | ||
| 316 | await vi.waitFor(() => { | ||
| 317 | expect(mockReadSavedEnvironmentBearerToken).toHaveBeenCalledTimes(1); | ||
| 318 | }); | ||
| 319 | |||
| 320 | finishTokenRead("saved-bearer-token"); | ||
| 321 | await vi.runAllTimersAsync(); | ||
| 322 | |||
| 323 | const savedConnectionCalls = mockCreateEnvironmentConnection.mock.calls.filter( | ||
| 324 | ([input]) => input.kind === "saved", | ||
| 325 | ); | ||
| 326 | expect(savedConnectionCalls).toHaveLength(1); | ||
| 327 | expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1); | ||
| 328 | |||
| 329 | stop(); | ||
| 330 | await resetEnvironmentServiceForTests(); | ||
| 331 | }); | ||
| 332 | }); | ||
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts deleted-667| ... | @@ -1,667 +0,0 @@ | ||
| 1 | import { QueryClient } from "@tanstack/react-query"; | ||
| 2 | import type { WsRpcClient } from "@t3tools/client-runtime"; | ||
| 3 | import { | ||
| 4 | EnvironmentId, | ||
| 5 | ProjectId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | TurnId, | ||
| 9 | type OrchestrationShellSnapshot, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 12 | |||
| 13 | const mockSubscribeThread = vi.fn(); | ||
| 14 | const mockThreadUnsubscribe = vi.fn(); | ||
| 15 | const mockCreateEnvironmentConnection = vi.fn(); | ||
| 16 | const mockCreateWsRpcClient = vi.fn(); | ||
| 17 | const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); | ||
| 18 | const mockListSavedEnvironmentRecords = vi.fn(); | ||
| 19 | const mockGetSavedEnvironmentRecord = vi.fn(); | ||
| 20 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | ||
| 21 | const mockReadSavedEnvironmentCredential = vi.fn(); | ||
| 22 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | ||
| 23 | const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); | ||
| 24 | const mockFetchRemoteSessionState = vi.fn(); | ||
| 25 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/ws"); | ||
| 26 | const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect); | ||
| 27 | const mockConnectionReconnects: Array<ReturnType<typeof vi.fn>> = []; | ||
| 28 | let savedEnvironmentRegistryListener: (() => void) | null = null; | ||
| 29 | |||
| 30 | function MockWsTransport() { | ||
| 31 | return undefined; | ||
| 32 | } | ||
| 33 | |||
| 34 | vi.mock("../primary", () => ({ | ||
| 35 | getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment, | ||
| 36 | })); | ||
| 37 | |||
| 38 | vi.mock("../../lib/runtime", () => ({ | ||
| 39 | webRuntime: { | ||
| 40 | runPromise: mockRemoteHttpRunPromise, | ||
| 41 | }, | ||
| 42 | })); | ||
| 43 | |||
| 44 | vi.mock("./catalog", () => ({ | ||
| 45 | getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, | ||
| 46 | hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), | ||
| 47 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | ||
| 48 | persistSavedEnvironmentRecord: vi.fn(), | ||
| 49 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, | ||
| 50 | readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, | ||
| 51 | removeSavedEnvironmentBearerToken: vi.fn(), | ||
| 52 | useSavedEnvironmentRegistryStore: { | ||
| 53 | subscribe: mockSavedEnvironmentRegistrySubscribe, | ||
| 54 | getState: () => ({ | ||
| 55 | upsert: vi.fn(), | ||
| 56 | remove: vi.fn(), | ||
| 57 | markConnected: vi.fn(), | ||
| 58 | rename: vi.fn(), | ||
| 59 | }), | ||
| 60 | }, | ||
| 61 | useSavedEnvironmentRuntimeStore: { | ||
| 62 | getState: () => ({ | ||
| 63 | ensure: vi.fn(), | ||
| 64 | patch: vi.fn(), | ||
| 65 | clear: vi.fn(), | ||
| 66 | }), | ||
| 67 | }, | ||
| 68 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, | ||
| 69 | writeSavedEnvironmentBearerToken: vi.fn(), | ||
| 70 | writeSavedEnvironmentCredential: vi.fn(), | ||
| 71 | })); | ||
| 72 | |||
| 73 | vi.mock("./connection", async (importOriginal) => ({ | ||
| 74 | ...(await importOriginal<typeof import("./connection")>()), | ||
| 75 | createEnvironmentConnection: mockCreateEnvironmentConnection, | ||
| 76 | })); | ||
| 77 | |||
| 78 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ||
| 79 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); | ||
| 80 | const stubWsClient: WsRpcClient = { | ||
| 81 | dispose: async () => undefined, | ||
| 82 | reconnect: async () => undefined, | ||
| 83 | isHeartbeatFresh: () => false, | ||
| 84 | cloud: { | ||
| 85 | getRelayClientStatus: vi.fn(), | ||
| 86 | installRelayClient: vi.fn(), | ||
| 87 | }, | ||
| 88 | orchestration: { | ||
| 89 | dispatchCommand: vi.fn(), | ||
| 90 | getTurnDiff: vi.fn(), | ||
| 91 | getFullThreadDiff: vi.fn(), | ||
| 92 | getArchivedShellSnapshot: vi.fn(), | ||
| 93 | subscribeShell: vi.fn(() => () => undefined), | ||
| 94 | subscribeThread: mockSubscribeThread, | ||
| 95 | }, | ||
| 96 | terminal: { | ||
| 97 | open: vi.fn(), | ||
| 98 | attach: vi.fn(() => () => undefined), | ||
| 99 | write: vi.fn(), | ||
| 100 | resize: vi.fn(), | ||
| 101 | clear: vi.fn(), | ||
| 102 | restart: vi.fn(), | ||
| 103 | close: vi.fn(), | ||
| 104 | onEvent: vi.fn(() => () => undefined), | ||
| 105 | onMetadata: vi.fn(() => () => undefined), | ||
| 106 | }, | ||
| 107 | preview: { | ||
| 108 | open: vi.fn(), | ||
| 109 | navigate: vi.fn(), | ||
| 110 | refresh: vi.fn(), | ||
| 111 | close: vi.fn(), | ||
| 112 | list: vi.fn(), | ||
| 113 | reportStatus: vi.fn(), | ||
| 114 | automation: { | ||
| 115 | connect: vi.fn(() => () => undefined), | ||
| 116 | respond: vi.fn(), | ||
| 117 | reportOwner: vi.fn(), | ||
| 118 | clearOwner: vi.fn(), | ||
| 119 | }, | ||
| 120 | onEvent: vi.fn(() => () => undefined), | ||
| 121 | subscribePorts: vi.fn(() => () => undefined), | ||
| 122 | }, | ||
| 123 | projects: { | ||
| 124 | listEntries: vi.fn(), | ||
| 125 | readFile: vi.fn(), | ||
| 126 | searchEntries: vi.fn(), | ||
| 127 | writeFile: vi.fn(), | ||
| 128 | }, | ||
| 129 | filesystem: { | ||
| 130 | browse: vi.fn(), | ||
| 131 | }, | ||
| 132 | assets: { createUrl: vi.fn() }, | ||
| 133 | sourceControl: { | ||
| 134 | lookupRepository: vi.fn(), | ||
| 135 | cloneRepository: vi.fn(), | ||
| 136 | publishRepository: vi.fn(), | ||
| 137 | }, | ||
| 138 | shell: { | ||
| 139 | openInEditor: vi.fn(), | ||
| 140 | }, | ||
| 141 | vcs: { | ||
| 142 | pull: vi.fn(), | ||
| 143 | refreshStatus: vi.fn(), | ||
| 144 | onStatus: vi.fn(() => () => undefined), | ||
| 145 | listRefs: vi.fn(), | ||
| 146 | createWorktree: vi.fn(), | ||
| 147 | removeWorktree: vi.fn(), | ||
| 148 | createRef: vi.fn(), | ||
| 149 | switchRef: vi.fn(), | ||
| 150 | init: vi.fn(), | ||
| 151 | }, | ||
| 152 | git: { | ||
| 153 | runStackedAction: vi.fn(), | ||
| 154 | resolvePullRequest: vi.fn(), | ||
| 155 | preparePullRequestThread: vi.fn(), | ||
| 156 | }, | ||
| 157 | review: { | ||
| 158 | getDiffPreview: vi.fn(), | ||
| 159 | }, | ||
| 160 | server: { | ||
| 161 | getConfig: vi.fn(), | ||
| 162 | refreshProviders: vi.fn(), | ||
| 163 | discoverSourceControl: vi.fn(), | ||
| 164 | updateProvider: vi.fn(), | ||
| 165 | upsertKeybinding: vi.fn(), | ||
| 166 | removeKeybinding: vi.fn(), | ||
| 167 | getSettings: vi.fn(), | ||
| 168 | updateSettings: vi.fn(), | ||
| 169 | subscribeConfig: vi.fn(() => () => undefined), | ||
| 170 | subscribeLifecycle: vi.fn(() => () => undefined), | ||
| 171 | subscribeAuthAccess: vi.fn(() => () => undefined), | ||
| 172 | getTraceDiagnostics: vi.fn(), | ||
| 173 | getProcessDiagnostics: vi.fn(), | ||
| 174 | getProcessResourceHistory: vi.fn(), | ||
| 175 | signalProcess: vi.fn(), | ||
| 176 | }, | ||
| 177 | }; | ||
| 178 | return { | ||
| 179 | ...actual, | ||
| 180 | createWsRpcClient: vi.fn(() => stubWsClient), | ||
| 181 | fetchRemoteSessionState: mockFetchRemoteSessionState, | ||
| 182 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | ||
| 183 | }; | ||
| 184 | }); | ||
| 185 | |||
| 186 | vi.mock("../../rpc/wsTransport", () => ({ | ||
| 187 | WsTransport: MockWsTransport, | ||
| 188 | })); | ||
| 189 | |||
| 190 | function makeThreadShellSnapshot(params: { | ||
| 191 | readonly threadId: ThreadId; | ||
| 192 | readonly sessionStatus?: | ||
| 193 | | "idle" | ||
| 194 | | "starting" | ||
| 195 | | "running" | ||
| 196 | | "ready" | ||
| 197 | | "interrupted" | ||
| 198 | | "stopped" | ||
| 199 | | "error"; | ||
| 200 | readonly hasPendingApprovals?: boolean; | ||
| 201 | readonly hasPendingUserInput?: boolean; | ||
| 202 | readonly hasActionableProposedPlan?: boolean; | ||
| 203 | }): OrchestrationShellSnapshot { | ||
| 204 | const projectId = ProjectId.make("project-1"); | ||
| 205 | const turnId = TurnId.make("turn-1"); | ||
| 206 | |||
| 207 | return { | ||
| 208 | snapshotSequence: 1, | ||
| 209 | projects: [], | ||
| 210 | updatedAt: "2026-04-13T00:00:00.000Z", | ||
| 211 | threads: [ | ||
| 212 | { | ||
| 213 | id: params.threadId, | ||
| 214 | projectId, | ||
| 215 | title: "Thread", | ||
| 216 | modelSelection: { | ||
| 217 | instanceId: ProviderInstanceId.make("codex"), | ||
| 218 | model: "gpt-5-codex", | ||
| 219 | }, | ||
| 220 | runtimeMode: "full-access", | ||
| 221 | interactionMode: "default", | ||
| 222 | branch: null, | ||
| 223 | worktreePath: null, | ||
| 224 | latestTurn: | ||
| 225 | params.sessionStatus === "running" | ||
| 226 | ? { | ||
| 227 | turnId, | ||
| 228 | state: "running", | ||
| 229 | requestedAt: "2026-04-13T00:00:00.000Z", | ||
| 230 | startedAt: "2026-04-13T00:00:01.000Z", | ||
| 231 | completedAt: null, | ||
| 232 | assistantMessageId: null, | ||
| 233 | } | ||
| 234 | : null, | ||
| 235 | createdAt: "2026-04-13T00:00:00.000Z", | ||
| 236 | updatedAt: "2026-04-13T00:00:00.000Z", | ||
| 237 | archivedAt: null, | ||
| 238 | session: params.sessionStatus | ||
| 239 | ? { | ||
| 240 | threadId: params.threadId, | ||
| 241 | status: params.sessionStatus, | ||
| 242 | providerName: "codex", | ||
| 243 | runtimeMode: "full-access", | ||
| 244 | activeTurnId: params.sessionStatus === "running" ? turnId : null, | ||
| 245 | lastError: null, | ||
| 246 | updatedAt: "2026-04-13T00:00:00.000Z", | ||
| 247 | } | ||
| 248 | : null, | ||
| 249 | latestUserMessageAt: null, | ||
| 250 | hasPendingApprovals: params.hasPendingApprovals ?? false, | ||
| 251 | hasPendingUserInput: params.hasPendingUserInput ?? false, | ||
| 252 | hasActionableProposedPlan: params.hasActionableProposedPlan ?? false, | ||
| 253 | }, | ||
| 254 | ], | ||
| 255 | }; | ||
| 256 | } | ||
| 257 | |||
| 258 | describe("retainThreadDetailSubscription", () => { | ||
| 259 | beforeEach(() => { | ||
| 260 | vi.useFakeTimers(); | ||
| 261 | vi.resetModules(); | ||
| 262 | vi.clearAllMocks(); | ||
| 263 | mockGetPrimaryKnownEnvironment.mockReturnValue({ | ||
| 264 | id: "env-1", | ||
| 265 | label: "Primary environment", | ||
| 266 | source: "window-origin", | ||
| 267 | target: { | ||
| 268 | httpBaseUrl: "http://127.0.0.1:3000/", | ||
| 269 | wsBaseUrl: "ws://127.0.0.1:3000/", | ||
| 270 | }, | ||
| 271 | environmentId: EnvironmentId.make("env-1"), | ||
| 272 | }); | ||
| 273 | |||
| 274 | mockThreadUnsubscribe.mockImplementation(() => undefined); | ||
| 275 | mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe); | ||
| 276 | mockCreateWsRpcClient.mockReturnValue({ | ||
| 277 | server: { | ||
| 278 | getConfig: vi.fn(async () => ({ | ||
| 279 | environment: { | ||
| 280 | environmentId: EnvironmentId.make("env-remote"), | ||
| 281 | label: "Remote env", | ||
| 282 | platform: { os: "darwin", arch: "arm64" }, | ||
| 283 | serverVersion: "0.0.0-test", | ||
| 284 | capabilities: { repositoryIdentity: true }, | ||
| 285 | }, | ||
| 286 | })), | ||
| 287 | }, | ||
| 288 | isHeartbeatFresh: vi.fn(() => true), | ||
| 289 | orchestration: { | ||
| 290 | subscribeThread: mockSubscribeThread, | ||
| 291 | }, | ||
| 292 | }); | ||
| 293 | mockCreateEnvironmentConnection.mockImplementation((input) => { | ||
| 294 | const reconnect = vi.fn(async () => undefined); | ||
| 295 | mockConnectionReconnects.push(reconnect); | ||
| 296 | queueMicrotask(() => { | ||
| 297 | input.onConfigSnapshot?.({ | ||
| 298 | environment: { | ||
| 299 | environmentId: input.knownEnvironment.environmentId, | ||
| 300 | label: input.knownEnvironment.label, | ||
| 301 | platform: { os: "darwin", arch: "arm64" }, | ||
| 302 | serverVersion: "0.0.0-test", | ||
| 303 | capabilities: { repositoryIdentity: true }, | ||
| 304 | }, | ||
| 305 | }); | ||
| 306 | }); | ||
| 307 | return { | ||
| 308 | kind: input.kind, | ||
| 309 | environmentId: input.knownEnvironment.environmentId, | ||
| 310 | knownEnvironment: input.knownEnvironment, | ||
| 311 | client: input.client, | ||
| 312 | ensureBootstrapped: vi.fn(async () => undefined), | ||
| 313 | reconnect, | ||
| 314 | dispose: vi.fn(async () => undefined), | ||
| 315 | }; | ||
| 316 | }); | ||
| 317 | savedEnvironmentRegistryListener = null; | ||
| 318 | mockSavedEnvironmentRegistrySubscribe.mockImplementation((listener: () => void) => { | ||
| 319 | savedEnvironmentRegistryListener = listener; | ||
| 320 | return () => { | ||
| 321 | if (savedEnvironmentRegistryListener === listener) { | ||
| 322 | savedEnvironmentRegistryListener = null; | ||
| 323 | } | ||
| 324 | }; | ||
| 325 | }); | ||
| 326 | mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); | ||
| 327 | mockListSavedEnvironmentRecords.mockReturnValue([]); | ||
| 328 | mockGetSavedEnvironmentRecord.mockReturnValue(null); | ||
| 329 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | ||
| 330 | mockReadSavedEnvironmentCredential.mockImplementation(async () => { | ||
| 331 | const token = await mockReadSavedEnvironmentBearerToken(); | ||
| 332 | return token ? { version: 1, method: "bearer", token } : null; | ||
| 333 | }); | ||
| 334 | mockFetchRemoteSessionState.mockResolvedValue({ | ||
| 335 | authenticated: true, | ||
| 336 | scopes: ["orchestration:read"], | ||
| 337 | }); | ||
| 338 | mockConnectionReconnects.length = 0; | ||
| 339 | }); | ||
| 340 | |||
| 341 | afterEach(async () => { | ||
| 342 | const { resetEnvironmentServiceForTests } = await import("./service"); | ||
| 343 | await resetEnvironmentServiceForTests(); | ||
| 344 | vi.unstubAllGlobals(); | ||
| 345 | vi.useRealTimers(); | ||
| 346 | }); | ||
| 347 | |||
| 348 | it("keeps thread detail subscriptions warm across releases until idle eviction", async () => { | ||
| 349 | const { | ||
| 350 | retainThreadDetailSubscription, | ||
| 351 | startEnvironmentConnectionService, | ||
| 352 | resetEnvironmentServiceForTests, | ||
| 353 | } = await import("./service"); | ||
| 354 | |||
| 355 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 356 | const environmentId = EnvironmentId.make("env-1"); | ||
| 357 | const threadId = ThreadId.make("thread-1"); | ||
| 358 | |||
| 359 | const releaseFirst = retainThreadDetailSubscription(environmentId, threadId); | ||
| 360 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); | ||
| 361 | |||
| 362 | releaseFirst(); | ||
| 363 | expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); | ||
| 364 | |||
| 365 | const releaseSecond = retainThreadDetailSubscription(environmentId, threadId); | ||
| 366 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); | ||
| 367 | |||
| 368 | releaseSecond(); | ||
| 369 | await vi.advanceTimersByTimeAsync(2 * 60 * 1000); | ||
| 370 | expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); | ||
| 371 | |||
| 372 | await vi.advanceTimersByTimeAsync(28 * 60 * 1000); | ||
| 373 | expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); | ||
| 374 | |||
| 375 | stop(); | ||
| 376 | await resetEnvironmentServiceForTests(); | ||
| 377 | }); | ||
| 378 | |||
| 379 | it("does not start the primary connection until the known environment has an id", async () => { | ||
| 380 | mockGetPrimaryKnownEnvironment.mockReturnValue({ | ||
| 381 | id: "env-1", | ||
| 382 | label: "Primary environment", | ||
| 383 | source: "window-origin", | ||
| 384 | target: { | ||
| 385 | httpBaseUrl: "http://127.0.0.1:3000/", | ||
| 386 | wsBaseUrl: "ws://127.0.0.1:3000/", | ||
| 387 | }, | ||
| 388 | }); | ||
| 389 | const { | ||
| 390 | listEnvironmentConnections, | ||
| 391 | resetEnvironmentServiceForTests, | ||
| 392 | startEnvironmentConnectionService, | ||
| 393 | } = await import("./service"); | ||
| 394 | |||
| 395 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 396 | |||
| 397 | expect(mockCreateEnvironmentConnection).not.toHaveBeenCalled(); | ||
| 398 | expect(listEnvironmentConnections()).toEqual([]); | ||
| 399 | |||
| 400 | stop(); | ||
| 401 | await resetEnvironmentServiceForTests(); | ||
| 402 | }); | ||
| 403 | |||
| 404 | it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => { | ||
| 405 | const { | ||
| 406 | retainThreadDetailSubscription, | ||
| 407 | startEnvironmentConnectionService, | ||
| 408 | resetEnvironmentServiceForTests, | ||
| 409 | } = await import("./service"); | ||
| 410 | |||
| 411 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 412 | const environmentId = EnvironmentId.make("env-1"); | ||
| 413 | const threadId = ThreadId.make("thread-active"); | ||
| 414 | |||
| 415 | const connectionInput = mockCreateEnvironmentConnection.mock.calls[0]?.[0]; | ||
| 416 | expect(connectionInput).toBeDefined(); | ||
| 417 | |||
| 418 | connectionInput.syncShellSnapshot( | ||
| 419 | makeThreadShellSnapshot({ | ||
| 420 | threadId, | ||
| 421 | sessionStatus: "ready", | ||
| 422 | hasPendingApprovals: true, | ||
| 423 | }), | ||
| 424 | environmentId, | ||
| 425 | ); | ||
| 426 | |||
| 427 | const release = retainThreadDetailSubscription(environmentId, threadId); | ||
| 428 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); | ||
| 429 | |||
| 430 | release(); | ||
| 431 | await vi.advanceTimersByTimeAsync(30 * 60 * 1000); | ||
| 432 | expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); | ||
| 433 | |||
| 434 | connectionInput.applyShellEvent( | ||
| 435 | { | ||
| 436 | kind: "thread-upserted", | ||
| 437 | sequence: 2, | ||
| 438 | thread: makeThreadShellSnapshot({ | ||
| 439 | threadId, | ||
| 440 | sessionStatus: "idle", | ||
| 441 | }).threads[0]!, | ||
| 442 | }, | ||
| 443 | environmentId, | ||
| 444 | ); | ||
| 445 | |||
| 446 | await vi.advanceTimersByTimeAsync(30 * 60 * 1000); | ||
| 447 | expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); | ||
| 448 | |||
| 449 | stop(); | ||
| 450 | await resetEnvironmentServiceForTests(); | ||
| 451 | }); | ||
| 452 | |||
| 453 | it("reattaches retained thread detail subscriptions after a saved environment reconnect replaces the client", async () => { | ||
| 454 | const environmentId = EnvironmentId.make("env-remote"); | ||
| 455 | const threadId = ThreadId.make("thread-reconnect"); | ||
| 456 | const record = { | ||
| 457 | environmentId, | ||
| 458 | label: "Remote env", | ||
| 459 | httpBaseUrl: "http://remote.example.test", | ||
| 460 | wsBaseUrl: "ws://remote.example.test", | ||
| 461 | createdAt: "2026-05-01T00:00:00.000Z", | ||
| 462 | lastConnectedAt: "2026-05-01T00:00:00.000Z", | ||
| 463 | }; | ||
| 464 | mockListSavedEnvironmentRecords.mockReturnValue([record]); | ||
| 465 | mockGetSavedEnvironmentRecord.mockReturnValue(record); | ||
| 466 | mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); | ||
| 467 | |||
| 468 | const { | ||
| 469 | disconnectSavedEnvironment, | ||
| 470 | listEnvironmentConnections, | ||
| 471 | reconnectSavedEnvironment, | ||
| 472 | retainThreadDetailSubscription, | ||
| 473 | startEnvironmentConnectionService, | ||
| 474 | resetEnvironmentServiceForTests, | ||
| 475 | } = await import("./service"); | ||
| 476 | |||
| 477 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 478 | savedEnvironmentRegistryListener?.(); | ||
| 479 | await vi.waitFor(() => { | ||
| 480 | expect( | ||
| 481 | listEnvironmentConnections().some( | ||
| 482 | (connection) => connection.environmentId === environmentId, | ||
| 483 | ), | ||
| 484 | ).toBe(true); | ||
| 485 | }); | ||
| 486 | const createConnectionCallsBeforeReconnect = mockCreateEnvironmentConnection.mock.calls.length; | ||
| 487 | |||
| 488 | const release = retainThreadDetailSubscription(environmentId, threadId); | ||
| 489 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); | ||
| 490 | |||
| 491 | await disconnectSavedEnvironment(environmentId); | ||
| 492 | expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); | ||
| 493 | expect( | ||
| 494 | listEnvironmentConnections().some((connection) => connection.environmentId === environmentId), | ||
| 495 | ).toBe(false); | ||
| 496 | |||
| 497 | const reconnectPromise = reconnectSavedEnvironment(environmentId); | ||
| 498 | await vi.advanceTimersByTimeAsync(200); | ||
| 499 | await reconnectPromise; | ||
| 500 | await vi.waitFor(() => { | ||
| 501 | expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes( | ||
| 502 | createConnectionCallsBeforeReconnect + 1, | ||
| 503 | ); | ||
| 504 | expect(mockSubscribeThread).toHaveBeenCalledTimes(2); | ||
| 505 | }); | ||
| 506 | |||
| 507 | release(); | ||
| 508 | stop(); | ||
| 509 | await resetEnvironmentServiceForTests(); | ||
| 510 | }); | ||
| 511 | |||
| 512 | it("keeps healthy environment streams connected when the browser resumes from the background", async () => { | ||
| 513 | let visibilityState: DocumentVisibilityState = "visible"; | ||
| 514 | const documentTarget = new EventTarget(); | ||
| 515 | const windowTarget = new EventTarget(); | ||
| 516 | vi.stubGlobal("document", { | ||
| 517 | addEventListener: documentTarget.addEventListener.bind(documentTarget), | ||
| 518 | removeEventListener: documentTarget.removeEventListener.bind(documentTarget), | ||
| 519 | get visibilityState() { | ||
| 520 | return visibilityState; | ||
| 521 | }, | ||
| 522 | }); | ||
| 523 | vi.stubGlobal("window", { | ||
| 524 | addEventListener: windowTarget.addEventListener.bind(windowTarget), | ||
| 525 | removeEventListener: windowTarget.removeEventListener.bind(windowTarget), | ||
| 526 | }); | ||
| 527 | |||
| 528 | const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = | ||
| 529 | await import("./service"); | ||
| 530 | mockCreateEnvironmentConnection.mockImplementation((input) => { | ||
| 531 | const reconnect = vi.fn(async () => undefined); | ||
| 532 | mockConnectionReconnects.push(reconnect); | ||
| 533 | queueMicrotask(() => { | ||
| 534 | input.onConfigSnapshot?.({ | ||
| 535 | environment: { | ||
| 536 | environmentId: input.knownEnvironment.environmentId, | ||
| 537 | label: input.knownEnvironment.label, | ||
| 538 | platform: { os: "darwin", arch: "arm64" }, | ||
| 539 | serverVersion: "0.0.0-test", | ||
| 540 | capabilities: { repositoryIdentity: true }, | ||
| 541 | }, | ||
| 542 | }); | ||
| 543 | }); | ||
| 544 | return { | ||
| 545 | kind: input.kind, | ||
| 546 | environmentId: input.knownEnvironment.environmentId, | ||
| 547 | knownEnvironment: input.knownEnvironment, | ||
| 548 | client: { | ||
| 549 | ...input.client, | ||
| 550 | isHeartbeatFresh: vi.fn(() => true), | ||
| 551 | }, | ||
| 552 | ensureBootstrapped: vi.fn(async () => undefined), | ||
| 553 | reconnect, | ||
| 554 | dispose: vi.fn(async () => undefined), | ||
| 555 | }; | ||
| 556 | }); | ||
| 557 | |||
| 558 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 559 | expect(mockConnectionReconnects).toHaveLength(1); | ||
| 560 | |||
| 561 | visibilityState = "hidden"; | ||
| 562 | documentTarget.dispatchEvent(new Event("visibilitychange")); | ||
| 563 | expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); | ||
| 564 | |||
| 565 | visibilityState = "visible"; | ||
| 566 | documentTarget.dispatchEvent(new Event("visibilitychange")); | ||
| 567 | expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); | ||
| 568 | |||
| 569 | stop(); | ||
| 570 | await resetEnvironmentServiceForTests(); | ||
| 571 | }); | ||
| 572 | |||
| 573 | it("reconnects stale environment streams when the browser resumes from the background", async () => { | ||
| 574 | let visibilityState: DocumentVisibilityState = "visible"; | ||
| 575 | const documentTarget = new EventTarget(); | ||
| 576 | const windowTarget = new EventTarget(); | ||
| 577 | vi.stubGlobal("document", { | ||
| 578 | addEventListener: documentTarget.addEventListener.bind(documentTarget), | ||
| 579 | removeEventListener: documentTarget.removeEventListener.bind(documentTarget), | ||
| 580 | get visibilityState() { | ||
| 581 | return visibilityState; | ||
| 582 | }, | ||
| 583 | }); | ||
| 584 | vi.stubGlobal("window", { | ||
| 585 | addEventListener: windowTarget.addEventListener.bind(windowTarget), | ||
| 586 | removeEventListener: windowTarget.removeEventListener.bind(windowTarget), | ||
| 587 | }); | ||
| 588 | mockCreateWsRpcClient.mockReturnValue({ | ||
| 589 | server: { | ||
| 590 | getConfig: vi.fn(async () => ({ | ||
| 591 | environment: { | ||
| 592 | environmentId: EnvironmentId.make("env-remote"), | ||
| 593 | label: "Remote env", | ||
| 594 | platform: { os: "darwin", arch: "arm64" }, | ||
| 595 | serverVersion: "0.0.0-test", | ||
| 596 | capabilities: { repositoryIdentity: true }, | ||
| 597 | }, | ||
| 598 | })), | ||
| 599 | }, | ||
| 600 | isHeartbeatFresh: vi.fn(() => false), | ||
| 601 | orchestration: { | ||
| 602 | subscribeThread: mockSubscribeThread, | ||
| 603 | }, | ||
| 604 | }); | ||
| 605 | |||
| 606 | const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = | ||
| 607 | await import("./service"); | ||
| 608 | |||
| 609 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 610 | expect(mockConnectionReconnects).toHaveLength(1); | ||
| 611 | |||
| 612 | visibilityState = "hidden"; | ||
| 613 | documentTarget.dispatchEvent(new Event("visibilitychange")); | ||
| 614 | expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); | ||
| 615 | |||
| 616 | visibilityState = "visible"; | ||
| 617 | documentTarget.dispatchEvent(new Event("visibilitychange")); | ||
| 618 | expect(mockConnectionReconnects[0]).toHaveBeenCalledTimes(1); | ||
| 619 | |||
| 620 | stop(); | ||
| 621 | await resetEnvironmentServiceForTests(); | ||
| 622 | }); | ||
| 623 | |||
| 624 | it("allows a larger idle cache before capacity eviction starts", async () => { | ||
| 625 | const { | ||
| 626 | retainThreadDetailSubscription, | ||
| 627 | startEnvironmentConnectionService, | ||
| 628 | resetEnvironmentServiceForTests, | ||
| 629 | } = await import("./service"); | ||
| 630 | |||
| 631 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 632 | const environmentId = EnvironmentId.make("env-1"); | ||
| 633 | |||
| 634 | for (let index = 0; index < 12; index += 1) { | ||
| 635 | const release = retainThreadDetailSubscription( | ||
| 636 | environmentId, | ||
| 637 | ThreadId.make(`thread-${index + 1}`), | ||
| 638 | ); | ||
| 639 | release(); | ||
| 640 | } | ||
| 641 | |||
| 642 | expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); | ||
| 643 | |||
| 644 | stop(); | ||
| 645 | await resetEnvironmentServiceForTests(); | ||
| 646 | }); | ||
| 647 | |||
| 648 | it("disposes cached thread detail subscriptions when the environment service resets", async () => { | ||
| 649 | const { | ||
| 650 | retainThreadDetailSubscription, | ||
| 651 | startEnvironmentConnectionService, | ||
| 652 | resetEnvironmentServiceForTests, | ||
| 653 | } = await import("./service"); | ||
| 654 | |||
| 655 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 656 | const environmentId = EnvironmentId.make("env-1"); | ||
| 657 | const threadId = ThreadId.make("thread-2"); | ||
| 658 | |||
| 659 | const release = retainThreadDetailSubscription(environmentId, threadId); | ||
| 660 | release(); | ||
| 661 | |||
| 662 | await resetEnvironmentServiceForTests(); | ||
| 663 | expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); | ||
| 664 | |||
| 665 | stop(); | ||
| 666 | }); | ||
| 667 | }); | ||
apps/web/src/environments/runtime/service.ts deleted-2103| ... | @@ -1,2103 +0,0 @@ | ||
| 1 | import { | ||
| 2 | AuthEnvironmentScope, | ||
| 3 | type DesktopSshEnvironmentBootstrap, | ||
| 4 | type DesktopSshEnvironmentTarget, | ||
| 5 | type EnvironmentId, | ||
| 6 | type OrchestrationEvent, | ||
| 7 | type OrchestrationShellSnapshot, | ||
| 8 | type OrchestrationShellStreamEvent, | ||
| 9 | type ServerConfig, | ||
| 10 | EnvironmentAuthInvalidError, | ||
| 11 | ThreadId, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { | ||
| 14 | createWsRpcClient as createBaseWsRpcClient, | ||
| 15 | type WsRpcClient, | ||
| 16 | bootstrapRemoteBearerSession, | ||
| 17 | fetchRemoteEnvironmentDescriptor, | ||
| 18 | fetchRemoteDpopSessionState, | ||
| 19 | fetchRemoteSessionState, | ||
| 20 | type ManagedRelayDpopProofInput, | ||
| 21 | ManagedRelayDpopSigner, | ||
| 22 | resolveRemoteDpopWebSocketConnectionUrl, | ||
| 23 | resolveRemoteWebSocketConnectionUrl, | ||
| 24 | } from "@t3tools/client-runtime"; | ||
| 25 | |||
| 26 | import { type QueryClient } from "@tanstack/react-query"; | ||
| 27 | import { Throttler } from "@tanstack/react-pacer"; | ||
| 28 | import * as Effect from "effect/Effect"; | ||
| 29 | import * as Option from "effect/Option"; | ||
| 30 | import * as Schema from "effect/Schema"; | ||
| 31 | import { Headers, HttpTraceContext } from "effect/unstable/http"; | ||
| 32 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 33 | import { | ||
| 34 | createKnownEnvironment, | ||
| 35 | getKnownEnvironmentWsBaseUrl, | ||
| 36 | scopedThreadKey, | ||
| 37 | scopeProjectRef, | ||
| 38 | scopeThreadRef, | ||
| 39 | } from "@t3tools/client-runtime"; | ||
| 40 | |||
| 41 | import { | ||
| 42 | markPromotedDraftThreadByRef, | ||
| 43 | markPromotedDraftThreadsByRef, | ||
| 44 | useComposerDraftStore, | ||
| 45 | } from "~/composerDraftStore"; | ||
| 46 | import { ensureLocalApi } from "~/localApi"; | ||
| 47 | import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; | ||
| 48 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; | ||
| 49 | import { getPrimaryKnownEnvironment } from "../primary"; | ||
| 50 | import { webRuntime } from "../../lib/runtime"; | ||
| 51 | import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment"; | ||
| 52 | import { readManagedRelayClerkToken } from "../../cloud/managedAuth"; | ||
| 53 | |||
| 54 | import { | ||
| 55 | getSavedEnvironmentRecord, | ||
| 56 | hasSavedEnvironmentRegistryHydrated, | ||
| 57 | listSavedEnvironmentRecords, | ||
| 58 | persistSavedEnvironmentRecord, | ||
| 59 | readSavedEnvironmentCredential, | ||
| 60 | removeSavedEnvironmentBearerToken, | ||
| 61 | type SavedEnvironmentRecord, | ||
| 62 | type SavedEnvironmentCredential, | ||
| 63 | toPersistedSavedEnvironmentRecord, | ||
| 64 | useSavedEnvironmentRegistryStore, | ||
| 65 | useSavedEnvironmentRuntimeStore, | ||
| 66 | waitForSavedEnvironmentRegistryHydration, | ||
| 67 | writeSavedEnvironmentBearerToken, | ||
| 68 | writeSavedEnvironmentCredential, | ||
| 69 | } from "./catalog"; | ||
| 70 | import { | ||
| 71 | createEnvironmentConnection, | ||
| 72 | createEnvironmentConnectionAttemptRegistry, | ||
| 73 | EnvironmentConnectionAttemptCancelledError, | ||
| 74 | type EnvironmentConnection, | ||
| 75 | } from "./connection"; | ||
| 76 | import { | ||
| 77 | useStore, | ||
| 78 | selectProjectsAcrossEnvironments, | ||
| 79 | selectSidebarThreadSummaryByRef, | ||
| 80 | selectThreadByRef, | ||
| 81 | selectThreadsAcrossEnvironments, | ||
| 82 | } from "~/store"; | ||
| 83 | import { useTerminalUiStateStore } from "~/terminalUiStateStore"; | ||
| 84 | import { useUiStateStore } from "~/uiStateStore"; | ||
| 85 | import { getServerConfig } from "../../rpc/serverState"; | ||
| 86 | import { WsTransport } from "~/rpc/wsTransport"; | ||
| 87 | import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew"; | ||
| 88 | import { | ||
| 89 | deriveLogicalProjectKeyFromSettings, | ||
| 90 | derivePhysicalProjectKey, | ||
| 91 | } from "../../logicalProject"; | ||
| 92 | |||
| 93 | const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); | ||
| 94 | import { getClientSettings } from "~/hooks/useSettings"; | ||
| 95 | import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; | ||
| 96 | import { subscribePortDiscovery, usePortDiscoveryStore } from "../../portDiscoveryState"; | ||
| 97 | import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; | ||
| 98 | import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; | ||
| 99 | |||
| 100 | type EnvironmentServiceState = { | ||
| 101 | readonly queryClient: QueryClient; | ||
| 102 | readonly queryInvalidationThrottler: Throttler<() => void>; | ||
| 103 | refCount: number; | ||
| 104 | stop: () => void; | ||
| 105 | }; | ||
| 106 | |||
| 107 | type ThreadDetailSubscriptionEntry = { | ||
| 108 | readonly environmentId: EnvironmentId; | ||
| 109 | readonly threadId: ThreadId; | ||
| 110 | unsubscribe: () => void; | ||
| 111 | unsubscribeConnectionListener: (() => void) | null; | ||
| 112 | refCount: number; | ||
| 113 | lastAccessedAt: number; | ||
| 114 | evictionTimeoutId: ReturnType<typeof setTimeout> | null; | ||
| 115 | }; | ||
| 116 | |||
| 117 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); | ||
| 118 | const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); | ||
| 119 | |||
| 120 | function isSavedEnvironmentConnectionCancelledError( | ||
| 121 | error: unknown, | ||
| 122 | ): error is EnvironmentConnectionAttemptCancelledError { | ||
| 123 | return error instanceof EnvironmentConnectionAttemptCancelledError; | ||
| 124 | } | ||
| 125 | |||
| 126 | interface PendingSavedEnvironmentConnection { | ||
| 127 | readonly isCurrent: () => boolean; | ||
| 128 | readonly promise: Promise<EnvironmentConnection>; | ||
| 129 | } | ||
| 130 | |||
| 131 | const savedEnvironmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); | ||
| 132 | const pendingSavedEnvironmentConnections = new Map< | ||
| 133 | EnvironmentId, | ||
| 134 | PendingSavedEnvironmentConnection | ||
| 135 | >(); | ||
| 136 | const environmentConnectionListeners = new Set<() => void>(); | ||
| 137 | const providerInvalidationListeners = new Set<() => void>(); | ||
| 138 | const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>(); | ||
| 139 | const lastAppliedProjectionVersionByEnvironment = new Map< | ||
| 140 | EnvironmentId, | ||
| 141 | { | ||
| 142 | readonly sequence: number; | ||
| 143 | readonly updatedAt: string | null; | ||
| 144 | } | ||
| 145 | >(); | ||
| 146 | const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>(); | ||
| 147 | const portDiscoverySubscriptions = new Map<EnvironmentId, () => void>(); | ||
| 148 | |||
| 149 | let activeService: EnvironmentServiceState | null = null; | ||
| 150 | let needsProviderInvalidation = false; | ||
| 151 | let lastBrowserHiddenAt: number | null = null; | ||
| 152 | let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; | ||
| 153 | |||
| 154 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 155 | // This file still owns web's legacy thread-detail subscription cache. Mobile | ||
| 156 | // uses createThreadDetailManager from @t3tools/client-runtime for the same | ||
| 157 | // retain/reconnect/evict lifecycle. When touching this logic, prefer migrating | ||
| 158 | // web to the shared manager or extracting the missing adapter layer instead of | ||
| 159 | // adding more behavior here. | ||
| 160 | // | ||
| 161 | // Thread detail subscription cache policy: | ||
| 162 | // - Active consumers keep a subscription retained via refCount. | ||
| 163 | // - Released subscriptions stay warm for a longer idle TTL to avoid churn | ||
| 164 | // while moving around the UI. | ||
| 165 | // - Threads with active work or pending user action are sticky and are never | ||
| 166 | // evicted while they remain non-idle. | ||
| 167 | // - Capacity eviction only targets idle cached subscriptions. | ||
| 168 | const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; | ||
| 169 | const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; | ||
| 170 | const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000; | ||
| 171 | const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; | ||
| 172 | const NOOP = () => undefined; | ||
| 173 | const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; | ||
| 174 | |||
| 175 | const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const signer = yield* ManagedRelayDpopSigner; | ||
| 178 | return yield* signer.createProof(input); | ||
| 179 | }); | ||
| 180 | |||
| 181 | function createDeferredPromise<T>() { | ||
| 182 | let resolve: ((value: T) => void) | null = null; | ||
| 183 | const promise = new Promise<T>((nextResolve) => { | ||
| 184 | resolve = nextResolve; | ||
| 185 | }); | ||
| 186 | |||
| 187 | return { | ||
| 188 | promise, | ||
| 189 | resolve: (value: T) => { | ||
| 190 | resolve?.(value); | ||
| 191 | resolve = null; | ||
| 192 | }, | ||
| 193 | }; | ||
| 194 | } | ||
| 195 | |||
| 196 | async function waitForConfigSnapshot( | ||
| 197 | promise: Promise<ServerConfig>, | ||
| 198 | timeoutMs: number, | ||
| 199 | ): Promise<ServerConfig | null> { | ||
| 200 | return await new Promise<ServerConfig | null>((resolve) => { | ||
| 201 | const timeoutId = globalThis.setTimeout(() => resolve(null), timeoutMs); | ||
| 202 | promise.then( | ||
| 203 | (config) => { | ||
| 204 | clearTimeout(timeoutId); | ||
| 205 | resolve(config); | ||
| 206 | }, | ||
| 207 | () => { | ||
| 208 | clearTimeout(timeoutId); | ||
| 209 | resolve(null); | ||
| 210 | }, | ||
| 211 | ); | ||
| 212 | }); | ||
| 213 | } | ||
| 214 | |||
| 215 | function createSavedEnvironmentSyncScheduler() { | ||
| 216 | let activeSync: Promise<void> | null = null; | ||
| 217 | let queued = false; | ||
| 218 | |||
| 219 | const run = async (): Promise<void> => { | ||
| 220 | do { | ||
| 221 | queued = false; | ||
| 222 | await syncSavedEnvironmentConnections(listSavedEnvironmentRecords()); | ||
| 223 | } while (queued); | ||
| 224 | }; | ||
| 225 | |||
| 226 | return () => { | ||
| 227 | if (activeSync) { | ||
| 228 | queued = true; | ||
| 229 | return activeSync; | ||
| 230 | } | ||
| 231 | |||
| 232 | activeSync = run() | ||
| 233 | .catch(() => undefined) | ||
| 234 | .finally(() => { | ||
| 235 | activeSync = null; | ||
| 236 | }); | ||
| 237 | |||
| 238 | return activeSync; | ||
| 239 | }; | ||
| 240 | } | ||
| 241 | function compareAppliedProjectionVersion( | ||
| 242 | left: { readonly sequence: number; readonly updatedAt: string | null }, | ||
| 243 | right: { readonly sequence: number; readonly updatedAt: string | null }, | ||
| 244 | ): number { | ||
| 245 | if (left.sequence !== right.sequence) { | ||
| 246 | return left.sequence - right.sequence; | ||
| 247 | } | ||
| 248 | |||
| 249 | const leftUpdatedAt = left.updatedAt ?? ""; | ||
| 250 | const rightUpdatedAt = right.updatedAt ?? ""; | ||
| 251 | if (leftUpdatedAt === rightUpdatedAt) { | ||
| 252 | return 0; | ||
| 253 | } | ||
| 254 | |||
| 255 | return leftUpdatedAt < rightUpdatedAt ? -1 : 1; | ||
| 256 | } | ||
| 257 | |||
| 258 | function toAppliedProjectionVersion( | ||
| 259 | snapshot: Pick<OrchestrationShellSnapshot, "snapshotSequence" | "updatedAt">, | ||
| 260 | ): { | ||
| 261 | readonly sequence: number; | ||
| 262 | readonly updatedAt: string; | ||
| 263 | } { | ||
| 264 | return { | ||
| 265 | sequence: snapshot.snapshotSequence, | ||
| 266 | updatedAt: snapshot.updatedAt, | ||
| 267 | }; | ||
| 268 | } | ||
| 269 | |||
| 270 | export function shouldApplyProjectionSnapshot(input: { | ||
| 271 | readonly current: { | ||
| 272 | readonly sequence: number; | ||
| 273 | readonly updatedAt: string | null; | ||
| 274 | } | null; | ||
| 275 | readonly next: Pick<OrchestrationShellSnapshot, "snapshotSequence" | "updatedAt">; | ||
| 276 | }): boolean { | ||
| 277 | if (input.current === null) { | ||
| 278 | return true; | ||
| 279 | } | ||
| 280 | |||
| 281 | return compareAppliedProjectionVersion(input.current, toAppliedProjectionVersion(input.next)) < 0; | ||
| 282 | } | ||
| 283 | |||
| 284 | export function shouldApplyProjectionEvent(input: { | ||
| 285 | readonly current: { | ||
| 286 | readonly sequence: number; | ||
| 287 | readonly updatedAt: string | null; | ||
| 288 | } | null; | ||
| 289 | readonly sequence: number; | ||
| 290 | }): boolean { | ||
| 291 | if (input.current === null) { | ||
| 292 | return true; | ||
| 293 | } | ||
| 294 | |||
| 295 | return input.sequence > input.current.sequence; | ||
| 296 | } | ||
| 297 | |||
| 298 | function readLastAppliedProjectionVersion(environmentId: EnvironmentId): { | ||
| 299 | readonly sequence: number; | ||
| 300 | readonly updatedAt: string | null; | ||
| 301 | } | null { | ||
| 302 | return lastAppliedProjectionVersionByEnvironment.get(environmentId) ?? null; | ||
| 303 | } | ||
| 304 | |||
| 305 | function markAppliedProjectionSnapshot( | ||
| 306 | environmentId: EnvironmentId, | ||
| 307 | snapshot: Pick<OrchestrationShellSnapshot, "snapshotSequence" | "updatedAt">, | ||
| 308 | ): void { | ||
| 309 | const nextVersion = toAppliedProjectionVersion(snapshot); | ||
| 310 | const currentVersion = readLastAppliedProjectionVersion(environmentId); | ||
| 311 | if ( | ||
| 312 | currentVersion !== null && | ||
| 313 | compareAppliedProjectionVersion(currentVersion, nextVersion) >= 0 | ||
| 314 | ) { | ||
| 315 | return; | ||
| 316 | } | ||
| 317 | |||
| 318 | lastAppliedProjectionVersionByEnvironment.set(environmentId, nextVersion); | ||
| 319 | } | ||
| 320 | |||
| 321 | function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: number): void { | ||
| 322 | const currentVersion = readLastAppliedProjectionVersion(environmentId); | ||
| 323 | if (currentVersion !== null && sequence <= currentVersion.sequence) { | ||
| 324 | return; | ||
| 325 | } | ||
| 326 | |||
| 327 | lastAppliedProjectionVersionByEnvironment.set(environmentId, { | ||
| 328 | sequence, | ||
| 329 | updatedAt: currentVersion?.updatedAt ?? null, | ||
| 330 | }); | ||
| 331 | } | ||
| 332 | function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { | ||
| 333 | return scopedThreadKey(scopeThreadRef(environmentId, threadId)); | ||
| 334 | } | ||
| 335 | |||
| 336 | function clearThreadDetailSubscriptionEviction( | ||
| 337 | entry: ThreadDetailSubscriptionEntry, | ||
| 338 | ): ThreadDetailSubscriptionEntry { | ||
| 339 | if (entry.evictionTimeoutId !== null) { | ||
| 340 | clearTimeout(entry.evictionTimeoutId); | ||
| 341 | entry.evictionTimeoutId = null; | ||
| 342 | } | ||
| 343 | return entry; | ||
| 344 | } | ||
| 345 | |||
| 346 | function isNonIdleThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { | ||
| 347 | const threadRef = scopeThreadRef(entry.environmentId, entry.threadId); | ||
| 348 | const state = useStore.getState(); | ||
| 349 | const sidebarThread = selectSidebarThreadSummaryByRef(state, threadRef); | ||
| 350 | |||
| 351 | // Prefer shell/sidebar state first because it carries the coarse thread | ||
| 352 | // readiness flags used throughout the UI (pending approvals/input/plan). | ||
| 353 | if (sidebarThread) { | ||
| 354 | if ( | ||
| 355 | sidebarThread.hasPendingApprovals || | ||
| 356 | sidebarThread.hasPendingUserInput || | ||
| 357 | sidebarThread.hasActionableProposedPlan | ||
| 358 | ) { | ||
| 359 | return true; | ||
| 360 | } | ||
| 361 | |||
| 362 | const orchestrationStatus = sidebarThread.session?.orchestrationStatus; | ||
| 363 | if ( | ||
| 364 | orchestrationStatus && | ||
| 365 | orchestrationStatus !== "idle" && | ||
| 366 | orchestrationStatus !== "stopped" | ||
| 367 | ) { | ||
| 368 | return true; | ||
| 369 | } | ||
| 370 | |||
| 371 | if (sidebarThread.latestTurn?.state === "running") { | ||
| 372 | return true; | ||
| 373 | } | ||
| 374 | } | ||
| 375 | |||
| 376 | const thread = selectThreadByRef(state, threadRef); | ||
| 377 | if (!thread) { | ||
| 378 | return false; | ||
| 379 | } | ||
| 380 | |||
| 381 | const orchestrationStatus = thread.session?.orchestrationStatus; | ||
| 382 | return ( | ||
| 383 | Boolean( | ||
| 384 | orchestrationStatus && orchestrationStatus !== "idle" && orchestrationStatus !== "stopped", | ||
| 385 | ) || | ||
| 386 | thread.latestTurn?.state === "running" || | ||
| 387 | thread.pendingSourceProposedPlan !== undefined | ||
| 388 | ); | ||
| 389 | } | ||
| 390 | |||
| 391 | function shouldEvictThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { | ||
| 392 | return entry.refCount === 0 && !isNonIdleThreadDetailSubscription(entry); | ||
| 393 | } | ||
| 394 | |||
| 395 | function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { | ||
| 396 | if (entry.unsubscribeConnectionListener !== null) { | ||
| 397 | entry.unsubscribeConnectionListener(); | ||
| 398 | entry.unsubscribeConnectionListener = null; | ||
| 399 | } | ||
| 400 | if (entry.unsubscribe !== NOOP) { | ||
| 401 | return true; | ||
| 402 | } | ||
| 403 | |||
| 404 | const connection = readEnvironmentConnection(entry.environmentId); | ||
| 405 | if (!connection) { | ||
| 406 | return false; | ||
| 407 | } | ||
| 408 | |||
| 409 | entry.unsubscribe = connection.client.orchestration.subscribeThread( | ||
| 410 | { threadId: entry.threadId }, | ||
| 411 | (item) => { | ||
| 412 | if (item.kind === "snapshot") { | ||
| 413 | useStore.getState().syncServerThreadDetail(item.snapshot.thread, entry.environmentId); | ||
| 414 | return; | ||
| 415 | } | ||
| 416 | applyEnvironmentThreadDetailEvent(item.event, entry.environmentId); | ||
| 417 | }, | ||
| 418 | ); | ||
| 419 | return true; | ||
| 420 | } | ||
| 421 | |||
| 422 | function watchThreadDetailSubscriptionConnection(entry: ThreadDetailSubscriptionEntry): void { | ||
| 423 | if (entry.unsubscribeConnectionListener !== null) { | ||
| 424 | return; | ||
| 425 | } | ||
| 426 | |||
| 427 | entry.unsubscribeConnectionListener = subscribeEnvironmentConnections(() => { | ||
| 428 | if (attachThreadDetailSubscription(entry)) { | ||
| 429 | entry.lastAccessedAt = Date.now(); | ||
| 430 | } | ||
| 431 | }); | ||
| 432 | attachThreadDetailSubscription(entry); | ||
| 433 | } | ||
| 434 | |||
| 435 | function disposeThreadDetailSubscriptionByKey(key: string): boolean { | ||
| 436 | const entry = threadDetailSubscriptions.get(key); | ||
| 437 | if (!entry) { | ||
| 438 | return false; | ||
| 439 | } | ||
| 440 | |||
| 441 | clearThreadDetailSubscriptionEviction(entry); | ||
| 442 | entry.unsubscribeConnectionListener?.(); | ||
| 443 | entry.unsubscribeConnectionListener = null; | ||
| 444 | threadDetailSubscriptions.delete(key); | ||
| 445 | entry.unsubscribe(); | ||
| 446 | entry.unsubscribe = NOOP; | ||
| 447 | return true; | ||
| 448 | } | ||
| 449 | |||
| 450 | function disposeThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { | ||
| 451 | for (const [key, entry] of threadDetailSubscriptions) { | ||
| 452 | if (entry.environmentId === environmentId) { | ||
| 453 | disposeThreadDetailSubscriptionByKey(key); | ||
| 454 | } | ||
| 455 | } | ||
| 456 | } | ||
| 457 | |||
| 458 | function detachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { | ||
| 459 | for (const entry of threadDetailSubscriptions.values()) { | ||
| 460 | if (entry.environmentId !== environmentId) { | ||
| 461 | continue; | ||
| 462 | } | ||
| 463 | entry.unsubscribe(); | ||
| 464 | entry.unsubscribe = NOOP; | ||
| 465 | watchThreadDetailSubscriptionConnection(entry); | ||
| 466 | } | ||
| 467 | } | ||
| 468 | |||
| 469 | function attachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { | ||
| 470 | for (const entry of threadDetailSubscriptions.values()) { | ||
| 471 | if (entry.environmentId === environmentId) { | ||
| 472 | attachThreadDetailSubscription(entry); | ||
| 473 | } | ||
| 474 | } | ||
| 475 | } | ||
| 476 | |||
| 477 | function reconcileThreadDetailSubscriptionsForEnvironment( | ||
| 478 | environmentId: EnvironmentId, | ||
| 479 | threadIds: ReadonlyArray<ThreadId>, | ||
| 480 | ): void { | ||
| 481 | const activeThreadIds = new Set(threadIds); | ||
| 482 | for (const [key, entry] of threadDetailSubscriptions) { | ||
| 483 | if (entry.environmentId === environmentId && !activeThreadIds.has(entry.threadId)) { | ||
| 484 | disposeThreadDetailSubscriptionByKey(key); | ||
| 485 | } | ||
| 486 | } | ||
| 487 | } | ||
| 488 | |||
| 489 | function scheduleThreadDetailSubscriptionEviction(entry: ThreadDetailSubscriptionEntry): void { | ||
| 490 | clearThreadDetailSubscriptionEviction(entry); | ||
| 491 | if (!shouldEvictThreadDetailSubscription(entry)) { | ||
| 492 | return; | ||
| 493 | } | ||
| 494 | |||
| 495 | entry.evictionTimeoutId = setTimeout(() => { | ||
| 496 | const currentEntry = threadDetailSubscriptions.get( | ||
| 497 | getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), | ||
| 498 | ); | ||
| 499 | if (!currentEntry) { | ||
| 500 | return; | ||
| 501 | } | ||
| 502 | |||
| 503 | currentEntry.evictionTimeoutId = null; | ||
| 504 | if (!shouldEvictThreadDetailSubscription(currentEntry)) { | ||
| 505 | return; | ||
| 506 | } | ||
| 507 | disposeThreadDetailSubscriptionByKey( | ||
| 508 | getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), | ||
| 509 | ); | ||
| 510 | }, THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS); | ||
| 511 | } | ||
| 512 | |||
| 513 | function evictIdleThreadDetailSubscriptionsToCapacity(): void { | ||
| 514 | if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { | ||
| 515 | return; | ||
| 516 | } | ||
| 517 | |||
| 518 | const idleEntries = [...threadDetailSubscriptions.entries()] | ||
| 519 | .filter(([, entry]) => shouldEvictThreadDetailSubscription(entry)) | ||
| 520 | .toSorted(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt); | ||
| 521 | |||
| 522 | for (const [key] of idleEntries) { | ||
| 523 | if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { | ||
| 524 | return; | ||
| 525 | } | ||
| 526 | disposeThreadDetailSubscriptionByKey(key); | ||
| 527 | } | ||
| 528 | } | ||
| 529 | |||
| 530 | function reconcileThreadDetailSubscriptionEvictionState( | ||
| 531 | entry: ThreadDetailSubscriptionEntry, | ||
| 532 | ): void { | ||
| 533 | clearThreadDetailSubscriptionEviction(entry); | ||
| 534 | if (!shouldEvictThreadDetailSubscription(entry)) { | ||
| 535 | return; | ||
| 536 | } | ||
| 537 | |||
| 538 | scheduleThreadDetailSubscriptionEviction(entry); | ||
| 539 | } | ||
| 540 | |||
| 541 | function reconcileThreadDetailSubscriptionEvictionForThread( | ||
| 542 | environmentId: EnvironmentId, | ||
| 543 | threadId: ThreadId, | ||
| 544 | ): void { | ||
| 545 | const entry = threadDetailSubscriptions.get( | ||
| 546 | getThreadDetailSubscriptionKey(environmentId, threadId), | ||
| 547 | ); | ||
| 548 | if (!entry) { | ||
| 549 | return; | ||
| 550 | } | ||
| 551 | |||
| 552 | reconcileThreadDetailSubscriptionEvictionState(entry); | ||
| 553 | } | ||
| 554 | |||
| 555 | function reconcileThreadDetailSubscriptionEvictionForEnvironment( | ||
| 556 | environmentId: EnvironmentId, | ||
| 557 | ): void { | ||
| 558 | for (const entry of threadDetailSubscriptions.values()) { | ||
| 559 | if (entry.environmentId === environmentId) { | ||
| 560 | reconcileThreadDetailSubscriptionEvictionState(entry); | ||
| 561 | } | ||
| 562 | } | ||
| 563 | evictIdleThreadDetailSubscriptionsToCapacity(); | ||
| 564 | } | ||
| 565 | |||
| 566 | export function retainThreadDetailSubscription( | ||
| 567 | environmentId: EnvironmentId, | ||
| 568 | threadId: ThreadId, | ||
| 569 | ): () => void { | ||
| 570 | const key = getThreadDetailSubscriptionKey(environmentId, threadId); | ||
| 571 | const existing = threadDetailSubscriptions.get(key); | ||
| 572 | if (existing) { | ||
| 573 | clearThreadDetailSubscriptionEviction(existing); | ||
| 574 | existing.refCount += 1; | ||
| 575 | existing.lastAccessedAt = Date.now(); | ||
| 576 | if (!attachThreadDetailSubscription(existing)) { | ||
| 577 | watchThreadDetailSubscriptionConnection(existing); | ||
| 578 | } | ||
| 579 | let released = false; | ||
| 580 | return () => { | ||
| 581 | if (released) { | ||
| 582 | return; | ||
| 583 | } | ||
| 584 | released = true; | ||
| 585 | existing.refCount = Math.max(0, existing.refCount - 1); | ||
| 586 | existing.lastAccessedAt = Date.now(); | ||
| 587 | if (existing.refCount === 0) { | ||
| 588 | reconcileThreadDetailSubscriptionEvictionState(existing); | ||
| 589 | evictIdleThreadDetailSubscriptionsToCapacity(); | ||
| 590 | } | ||
| 591 | }; | ||
| 592 | } | ||
| 593 | |||
| 594 | const entry: ThreadDetailSubscriptionEntry = { | ||
| 595 | environmentId, | ||
| 596 | threadId, | ||
| 597 | unsubscribe: NOOP, | ||
| 598 | unsubscribeConnectionListener: null, | ||
| 599 | refCount: 1, | ||
| 600 | lastAccessedAt: Date.now(), | ||
| 601 | evictionTimeoutId: null, | ||
| 602 | }; | ||
| 603 | threadDetailSubscriptions.set(key, entry); | ||
| 604 | if (!attachThreadDetailSubscription(entry)) { | ||
| 605 | watchThreadDetailSubscriptionConnection(entry); | ||
| 606 | } | ||
| 607 | evictIdleThreadDetailSubscriptionsToCapacity(); | ||
| 608 | |||
| 609 | let released = false; | ||
| 610 | return () => { | ||
| 611 | if (released) { | ||
| 612 | return; | ||
| 613 | } | ||
| 614 | released = true; | ||
| 615 | entry.refCount = Math.max(0, entry.refCount - 1); | ||
| 616 | entry.lastAccessedAt = Date.now(); | ||
| 617 | if (entry.refCount === 0) { | ||
| 618 | reconcileThreadDetailSubscriptionEvictionState(entry); | ||
| 619 | evictIdleThreadDetailSubscriptionsToCapacity(); | ||
| 620 | } | ||
| 621 | }; | ||
| 622 | } | ||
| 623 | |||
| 624 | function emitEnvironmentConnectionRegistryChange() { | ||
| 625 | for (const listener of environmentConnectionListeners) { | ||
| 626 | listener(); | ||
| 627 | } | ||
| 628 | } | ||
| 629 | |||
| 630 | function emitProviderInvalidation() { | ||
| 631 | for (const listener of providerInvalidationListeners) { | ||
| 632 | listener(); | ||
| 633 | } | ||
| 634 | } | ||
| 635 | |||
| 636 | function getRuntimeErrorFields(error: unknown) { | ||
| 637 | return { | ||
| 638 | lastError: error instanceof Error ? error.message : String(error), | ||
| 639 | lastErrorAt: new Date().toISOString(), | ||
| 640 | } as const; | ||
| 641 | } | ||
| 642 | |||
| 643 | function isoNow(): string { | ||
| 644 | return new Date().toISOString(); | ||
| 645 | } | ||
| 646 | |||
| 647 | function readSshHttpErrorStatus(error: unknown): number | null { | ||
| 648 | if (!(error instanceof Error)) { | ||
| 649 | return null; | ||
| 650 | } | ||
| 651 | |||
| 652 | const match = SSH_HTTP_STATUS_RE.exec(error.message); | ||
| 653 | if (!match) { | ||
| 654 | return null; | ||
| 655 | } | ||
| 656 | |||
| 657 | const parsed = Number.parseInt(match[1] ?? "", 10); | ||
| 658 | return Number.isInteger(parsed) ? parsed : null; | ||
| 659 | } | ||
| 660 | |||
| 661 | function isSshHttpAuthError(error: unknown, status: number): boolean { | ||
| 662 | return readSshHttpErrorStatus(error) === status; | ||
| 663 | } | ||
| 664 | |||
| 665 | function isDesktopSshTargetEqual( | ||
| 666 | left: DesktopSshEnvironmentTarget | undefined, | ||
| 667 | right: DesktopSshEnvironmentTarget | undefined, | ||
| 668 | ): boolean { | ||
| 669 | if (!left || !right) { | ||
| 670 | return false; | ||
| 671 | } | ||
| 672 | |||
| 673 | return ( | ||
| 674 | left.alias === right.alias && | ||
| 675 | left.hostname === right.hostname && | ||
| 676 | left.username === right.username && | ||
| 677 | left.port === right.port | ||
| 678 | ); | ||
| 679 | } | ||
| 680 | |||
| 681 | function findSavedEnvironmentRecordByDesktopSshTarget( | ||
| 682 | target: DesktopSshEnvironmentTarget | undefined, | ||
| 683 | ): SavedEnvironmentRecord | null { | ||
| 684 | if (!target) { | ||
| 685 | return null; | ||
| 686 | } | ||
| 687 | |||
| 688 | return ( | ||
| 689 | listSavedEnvironmentRecords().find((record) => | ||
| 690 | isDesktopSshTargetEqual(record.desktopSsh, target), | ||
| 691 | ) ?? null | ||
| 692 | ); | ||
| 693 | } | ||
| 694 | |||
| 695 | function buildSavedEnvironmentRegistryById( | ||
| 696 | records: ReadonlyArray<SavedEnvironmentRecord>, | ||
| 697 | ): Record<EnvironmentId, SavedEnvironmentRecord> { | ||
| 698 | return Object.fromEntries(records.map((record) => [record.environmentId, record])) as Record< | ||
| 699 | EnvironmentId, | ||
| 700 | SavedEnvironmentRecord | ||
| 701 | >; | ||
| 702 | } | ||
| 703 | |||
| 704 | type SavedEnvironmentRegistrySnapshot = ReadonlyMap<EnvironmentId, SavedEnvironmentRecord | null>; | ||
| 705 | |||
| 706 | function snapshotSavedEnvironmentRegistry( | ||
| 707 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 708 | ): SavedEnvironmentRegistrySnapshot { | ||
| 709 | return new Map( | ||
| 710 | environmentIds.map((environmentId) => [ | ||
| 711 | environmentId, | ||
| 712 | getSavedEnvironmentRecord(environmentId) ?? null, | ||
| 713 | ]), | ||
| 714 | ); | ||
| 715 | } | ||
| 716 | |||
| 717 | async function persistSavedEnvironmentRegistryRollback( | ||
| 718 | snapshot: SavedEnvironmentRegistrySnapshot, | ||
| 719 | ): Promise<void> { | ||
| 720 | const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords()); | ||
| 721 | for (const [environmentId, record] of snapshot) { | ||
| 722 | if (record) { | ||
| 723 | byId[environmentId] = record; | ||
| 724 | continue; | ||
| 725 | } | ||
| 726 | delete byId[environmentId]; | ||
| 727 | } | ||
| 728 | const records = Object.values(byId); | ||
| 729 | await ensureLocalApi().persistence.setSavedEnvironmentRegistry( | ||
| 730 | records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), | ||
| 731 | ); | ||
| 732 | useSavedEnvironmentRegistryStore.setState({ | ||
| 733 | byId, | ||
| 734 | }); | ||
| 735 | } | ||
| 736 | |||
| 737 | async function resolveDesktopSshEnvironmentBootstrap( | ||
| 738 | target: DesktopSshEnvironmentTarget, | ||
| 739 | options?: { readonly issuePairingToken?: boolean }, | ||
| 740 | ): Promise<DesktopSshEnvironmentBootstrap> { | ||
| 741 | const desktopBridge = window.desktopBridge; | ||
| 742 | if (!desktopBridge) { | ||
| 743 | throw new Error("SSH launch is only available in the desktop app."); | ||
| 744 | } | ||
| 745 | |||
| 746 | return await desktopBridge.ensureSshEnvironment(target, options); | ||
| 747 | } | ||
| 748 | |||
| 749 | function getDesktopSshBridge() { | ||
| 750 | const desktopBridge = window.desktopBridge; | ||
| 751 | if (!desktopBridge) { | ||
| 752 | throw new Error("SSH launch is only available in the desktop app."); | ||
| 753 | } | ||
| 754 | return desktopBridge; | ||
| 755 | } | ||
| 756 | |||
| 757 | async function fetchDesktopSshEnvironmentDescriptor(httpBaseUrl: string) { | ||
| 758 | return await getDesktopSshBridge().fetchSshEnvironmentDescriptor(httpBaseUrl); | ||
| 759 | } | ||
| 760 | |||
| 761 | async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: string) { | ||
| 762 | return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential); | ||
| 763 | } | ||
| 764 | |||
| 765 | function readIssuedBearerScopes(scope: string): ReadonlyArray<AuthEnvironmentScope> { | ||
| 766 | return decodeIssuedBearerScopes(scope.split(" ")); | ||
| 767 | } | ||
| 768 | |||
| 769 | async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { | ||
| 770 | return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); | ||
| 771 | } | ||
| 772 | |||
| 773 | async function resolveDesktopSshWebSocketConnectionUrl( | ||
| 774 | wsBaseUrl: string, | ||
| 775 | httpBaseUrl: string, | ||
| 776 | bearerToken: string, | ||
| 777 | ) { | ||
| 778 | const issued = await getDesktopSshBridge().issueSshWebSocketTicket(httpBaseUrl, bearerToken); | ||
| 779 | const url = new URL(wsBaseUrl, window.location.origin); | ||
| 780 | url.searchParams.set("wsTicket", issued.ticket); | ||
| 781 | return url.toString(); | ||
| 782 | } | ||
| 783 | |||
| 784 | async function prepareSavedEnvironmentRecordForConnection( | ||
| 785 | record: SavedEnvironmentRecord, | ||
| 786 | options?: { readonly issuePairingToken?: boolean }, | ||
| 787 | ): Promise<{ | ||
| 788 | readonly record: SavedEnvironmentRecord; | ||
| 789 | readonly pairingToken: string | null; | ||
| 790 | readonly remotePort: number | null; | ||
| 791 | readonly remoteServerKind: "external" | "managed" | null; | ||
| 792 | }> { | ||
| 793 | if (!record.desktopSsh) { | ||
| 794 | return { | ||
| 795 | record, | ||
| 796 | pairingToken: null, | ||
| 797 | remotePort: null, | ||
| 798 | remoteServerKind: null, | ||
| 799 | }; | ||
| 800 | } | ||
| 801 | |||
| 802 | const bootstrap = await resolveDesktopSshEnvironmentBootstrap(record.desktopSsh, options); | ||
| 803 | const nextRecord: SavedEnvironmentRecord = { | ||
| 804 | ...record, | ||
| 805 | httpBaseUrl: bootstrap.httpBaseUrl, | ||
| 806 | wsBaseUrl: bootstrap.wsBaseUrl, | ||
| 807 | desktopSsh: bootstrap.target, | ||
| 808 | }; | ||
| 809 | |||
| 810 | if ( | ||
| 811 | nextRecord.httpBaseUrl !== record.httpBaseUrl || | ||
| 812 | nextRecord.wsBaseUrl !== record.wsBaseUrl || | ||
| 813 | !isDesktopSshTargetEqual(nextRecord.desktopSsh, record.desktopSsh) | ||
| 814 | ) { | ||
| 815 | await persistSavedEnvironmentRecord(nextRecord); | ||
| 816 | useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); | ||
| 817 | } | ||
| 818 | |||
| 819 | return { | ||
| 820 | record: nextRecord, | ||
| 821 | pairingToken: bootstrap.pairingToken, | ||
| 822 | remotePort: bootstrap.remotePort ?? null, | ||
| 823 | remoteServerKind: bootstrap.remoteServerKind ?? null, | ||
| 824 | }; | ||
| 825 | } | ||
| 826 | |||
| 827 | async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ | ||
| 828 | readonly record: SavedEnvironmentRecord; | ||
| 829 | readonly bearerToken: string; | ||
| 830 | readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null; | ||
| 831 | }> { | ||
| 832 | const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); | ||
| 833 | const prepared = await prepareSavedEnvironmentRecordForConnection(record, { | ||
| 834 | issuePairingToken: true, | ||
| 835 | }); | ||
| 836 | if (!prepared.pairingToken) { | ||
| 837 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); | ||
| 838 | throw new Error("Desktop SSH launch did not return a pairing token."); | ||
| 839 | } | ||
| 840 | |||
| 841 | const bearerSession = await bootstrapDesktopSshBearerSession( | ||
| 842 | prepared.record.httpBaseUrl, | ||
| 843 | prepared.pairingToken, | ||
| 844 | ).catch(async (error) => { | ||
| 845 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); | ||
| 846 | const detail = [ | ||
| 847 | `local ${prepared.record.httpBaseUrl}`, | ||
| 848 | `remote port ${prepared.remotePort ?? "unknown"}`, | ||
| 849 | prepared.remoteServerKind ? `remote server ${prepared.remoteServerKind}` : null, | ||
| 850 | ] | ||
| 851 | .filter(Boolean) | ||
| 852 | .join(", "); | ||
| 853 | const message = error instanceof Error ? error.message : String(error); | ||
| 854 | throw new Error(`${message} (${detail})`); | ||
| 855 | }); | ||
| 856 | const didPersistBearerToken = await writeSavedEnvironmentBearerToken( | ||
| 857 | prepared.record.environmentId, | ||
| 858 | bearerSession.access_token, | ||
| 859 | ); | ||
| 860 | if (!didPersistBearerToken) { | ||
| 861 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); | ||
| 862 | throw new Error("Unable to persist saved environment credentials."); | ||
| 863 | } | ||
| 864 | |||
| 865 | return { | ||
| 866 | record: prepared.record, | ||
| 867 | bearerToken: bearerSession.access_token, | ||
| 868 | scopes: readIssuedBearerScopes(bearerSession.scope), | ||
| 869 | }; | ||
| 870 | } | ||
| 871 | |||
| 872 | function setRuntimeConnecting(environmentId: EnvironmentId) { | ||
| 873 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | ||
| 874 | connectionState: "connecting", | ||
| 875 | lastError: null, | ||
| 876 | lastErrorAt: null, | ||
| 877 | }); | ||
| 878 | } | ||
| 879 | |||
| 880 | function setRuntimeConnected(environmentId: EnvironmentId) { | ||
| 881 | const connectedAt = isoNow(); | ||
| 882 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | ||
| 883 | connectionState: "connected", | ||
| 884 | authState: "authenticated", | ||
| 885 | connectedAt, | ||
| 886 | disconnectedAt: null, | ||
| 887 | lastError: null, | ||
| 888 | lastErrorAt: null, | ||
| 889 | }); | ||
| 890 | useSavedEnvironmentRegistryStore.getState().markConnected(environmentId, connectedAt); | ||
| 891 | } | ||
| 892 | |||
| 893 | function setRuntimeDisconnected(environmentId: EnvironmentId, reason?: string | null) { | ||
| 894 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | ||
| 895 | connectionState: "disconnected", | ||
| 896 | disconnectedAt: isoNow(), | ||
| 897 | ...(reason && reason.trim().length > 0 | ||
| 898 | ? { | ||
| 899 | lastError: reason, | ||
| 900 | lastErrorAt: isoNow(), | ||
| 901 | } | ||
| 902 | : {}), | ||
| 903 | }); | ||
| 904 | } | ||
| 905 | |||
| 906 | function setRuntimeError(environmentId: EnvironmentId, error: unknown) { | ||
| 907 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | ||
| 908 | connectionState: "error", | ||
| 909 | ...getRuntimeErrorFields(error), | ||
| 910 | }); | ||
| 911 | } | ||
| 912 | |||
| 913 | function coalesceOrchestrationUiEvents( | ||
| 914 | events: ReadonlyArray<OrchestrationEvent>, | ||
| 915 | ): OrchestrationEvent[] { | ||
| 916 | if (events.length < 2) { | ||
| 917 | return [...events]; | ||
| 918 | } | ||
| 919 | |||
| 920 | const coalesced: OrchestrationEvent[] = []; | ||
| 921 | for (const event of events) { | ||
| 922 | const previous = coalesced.at(-1); | ||
| 923 | if ( | ||
| 924 | previous?.type === "thread.message-sent" && | ||
| 925 | event.type === "thread.message-sent" && | ||
| 926 | previous.payload.threadId === event.payload.threadId && | ||
| 927 | previous.payload.messageId === event.payload.messageId | ||
| 928 | ) { | ||
| 929 | coalesced[coalesced.length - 1] = { | ||
| 930 | ...event, | ||
| 931 | payload: { | ||
| 932 | ...event.payload, | ||
| 933 | attachments: event.payload.attachments ?? previous.payload.attachments, | ||
| 934 | createdAt: previous.payload.createdAt, | ||
| 935 | text: | ||
| 936 | !event.payload.streaming && event.payload.text.length > 0 | ||
| 937 | ? event.payload.text | ||
| 938 | : previous.payload.text + event.payload.text, | ||
| 939 | }, | ||
| 940 | }; | ||
| 941 | continue; | ||
| 942 | } | ||
| 943 | |||
| 944 | coalesced.push(event); | ||
| 945 | } | ||
| 946 | |||
| 947 | return coalesced; | ||
| 948 | } | ||
| 949 | |||
| 950 | function syncProjectUiFromStore() { | ||
| 951 | const projects = selectProjectsAcrossEnvironments(useStore.getState()); | ||
| 952 | const clientSettings = getClientSettings(); | ||
| 953 | useUiStateStore.getState().syncProjects( | ||
| 954 | projects.map((project) => ({ | ||
| 955 | key: derivePhysicalProjectKey(project), | ||
| 956 | logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), | ||
| 957 | cwd: project.cwd, | ||
| 958 | })), | ||
| 959 | ); | ||
| 960 | } | ||
| 961 | |||
| 962 | function syncThreadUiFromStore() { | ||
| 963 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | ||
| 964 | useUiStateStore.getState().syncThreads( | ||
| 965 | threads.map((thread) => ({ | ||
| 966 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 967 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, | ||
| 968 | })), | ||
| 969 | ); | ||
| 970 | markPromotedDraftThreadsByRef( | ||
| 971 | threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), | ||
| 972 | ); | ||
| 973 | } | ||
| 974 | |||
| 975 | function reconcileSnapshotDerivedState() { | ||
| 976 | syncProjectUiFromStore(); | ||
| 977 | syncThreadUiFromStore(); | ||
| 978 | |||
| 979 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | ||
| 980 | const activeThreadKeys = collectActiveTerminalUiThreadKeys({ | ||
| 981 | snapshotThreads: threads.map((thread) => ({ | ||
| 982 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 983 | deletedAt: null, | ||
| 984 | archivedAt: thread.archivedAt, | ||
| 985 | })), | ||
| 986 | draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), | ||
| 987 | }); | ||
| 988 | useTerminalUiStateStore.getState().removeOrphanedTerminalUiStates(activeThreadKeys); | ||
| 989 | } | ||
| 990 | |||
| 991 | function applyRecoveredEventBatch( | ||
| 992 | events: ReadonlyArray<OrchestrationEvent>, | ||
| 993 | environmentId: EnvironmentId, | ||
| 994 | ) { | ||
| 995 | if (events.length === 0) { | ||
| 996 | return; | ||
| 997 | } | ||
| 998 | |||
| 999 | const batchEffects = deriveOrchestrationBatchEffects(events); | ||
| 1000 | const uiEvents = coalesceOrchestrationUiEvents(events); | ||
| 1001 | const needsProjectUiSync = events.some( | ||
| 1002 | (event) => | ||
| 1003 | event.type === "project.created" || | ||
| 1004 | event.type === "project.meta-updated" || | ||
| 1005 | event.type === "project.deleted", | ||
| 1006 | ); | ||
| 1007 | |||
| 1008 | if (batchEffects.needsProviderInvalidation) { | ||
| 1009 | needsProviderInvalidation = true; | ||
| 1010 | void activeService?.queryInvalidationThrottler.maybeExecute(); | ||
| 1011 | } | ||
| 1012 | |||
| 1013 | useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); | ||
| 1014 | if (needsProjectUiSync) { | ||
| 1015 | const projects = selectProjectsAcrossEnvironments(useStore.getState()); | ||
| 1016 | const clientSettings = getClientSettings(); | ||
| 1017 | useUiStateStore.getState().syncProjects( | ||
| 1018 | projects.map((project) => ({ | ||
| 1019 | key: derivePhysicalProjectKey(project), | ||
| 1020 | logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), | ||
| 1021 | cwd: project.cwd, | ||
| 1022 | })), | ||
| 1023 | ); | ||
| 1024 | } | ||
| 1025 | |||
| 1026 | const needsThreadUiSync = events.some( | ||
| 1027 | (event) => event.type === "thread.created" || event.type === "thread.deleted", | ||
| 1028 | ); | ||
| 1029 | if (needsThreadUiSync) { | ||
| 1030 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | ||
| 1031 | useUiStateStore.getState().syncThreads( | ||
| 1032 | threads.map((thread) => ({ | ||
| 1033 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 1034 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, | ||
| 1035 | })), | ||
| 1036 | ); | ||
| 1037 | } | ||
| 1038 | |||
| 1039 | const draftStore = useComposerDraftStore.getState(); | ||
| 1040 | for (const threadId of batchEffects.promoteDraftThreadIds) { | ||
| 1041 | markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); | ||
| 1042 | } | ||
| 1043 | for (const threadId of batchEffects.clearDeletedThreadIds) { | ||
| 1044 | draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); | ||
| 1045 | useUiStateStore | ||
| 1046 | .getState() | ||
| 1047 | .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); | ||
| 1048 | } | ||
| 1049 | for (const event of events) { | ||
| 1050 | if (event.type === "project.deleted") { | ||
| 1051 | draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId)); | ||
| 1052 | } | ||
| 1053 | } | ||
| 1054 | for (const threadId of batchEffects.removeTerminalUiStateThreadIds) { | ||
| 1055 | useTerminalUiStateStore | ||
| 1056 | .getState() | ||
| 1057 | .removeTerminalUiState(scopeThreadRef(environmentId, threadId)); | ||
| 1058 | } | ||
| 1059 | |||
| 1060 | reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); | ||
| 1061 | } | ||
| 1062 | |||
| 1063 | export function applyEnvironmentThreadDetailEvent( | ||
| 1064 | event: OrchestrationEvent, | ||
| 1065 | environmentId: EnvironmentId, | ||
| 1066 | ) { | ||
| 1067 | applyRecoveredEventBatch([event], environmentId); | ||
| 1068 | } | ||
| 1069 | |||
| 1070 | function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) { | ||
| 1071 | if ( | ||
| 1072 | !shouldApplyProjectionEvent({ | ||
| 1073 | current: readLastAppliedProjectionVersion(environmentId), | ||
| 1074 | sequence: event.sequence, | ||
| 1075 | }) | ||
| 1076 | ) { | ||
| 1077 | return; | ||
| 1078 | } | ||
| 1079 | |||
| 1080 | const threadId = | ||
| 1081 | event.kind === "thread-upserted" | ||
| 1082 | ? event.thread.id | ||
| 1083 | : event.kind === "thread-removed" | ||
| 1084 | ? event.threadId | ||
| 1085 | : null; | ||
| 1086 | const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null; | ||
| 1087 | const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; | ||
| 1088 | |||
| 1089 | useStore.getState().applyShellEvent(event, environmentId); | ||
| 1090 | markAppliedProjectionEvent(environmentId, event.sequence); | ||
| 1091 | |||
| 1092 | switch (event.kind) { | ||
| 1093 | case "project-upserted": | ||
| 1094 | case "project-removed": | ||
| 1095 | syncProjectUiFromStore(); | ||
| 1096 | return; | ||
| 1097 | case "thread-upserted": | ||
| 1098 | syncThreadUiFromStore(); | ||
| 1099 | if (!previousThread && threadRef) { | ||
| 1100 | markPromotedDraftThreadByRef(threadRef); | ||
| 1101 | } | ||
| 1102 | if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { | ||
| 1103 | useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); | ||
| 1104 | } | ||
| 1105 | reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id); | ||
| 1106 | evictIdleThreadDetailSubscriptionsToCapacity(); | ||
| 1107 | return; | ||
| 1108 | case "thread-removed": | ||
| 1109 | if (threadRef) { | ||
| 1110 | disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef)); | ||
| 1111 | useComposerDraftStore.getState().clearDraftThread(threadRef); | ||
| 1112 | useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef)); | ||
| 1113 | useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); | ||
| 1114 | } | ||
| 1115 | syncThreadUiFromStore(); | ||
| 1116 | return; | ||
| 1117 | } | ||
| 1118 | } | ||
| 1119 | |||
| 1120 | function createEnvironmentConnectionHandlers() { | ||
| 1121 | return { | ||
| 1122 | applyShellEvent, | ||
| 1123 | syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { | ||
| 1124 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 1125 | // Shell snapshots already have createShellSnapshotManager in | ||
| 1126 | // @t3tools/client-runtime. Web currently projects snapshots straight into | ||
| 1127 | // its denormalized Zustand store; future shell changes should migrate or | ||
| 1128 | // bridge to the shared manager instead of growing this handler. | ||
| 1129 | if ( | ||
| 1130 | !shouldApplyProjectionSnapshot({ | ||
| 1131 | current: readLastAppliedProjectionVersion(environmentId), | ||
| 1132 | next: snapshot, | ||
| 1133 | }) | ||
| 1134 | ) { | ||
| 1135 | return; | ||
| 1136 | } | ||
| 1137 | |||
| 1138 | useStore.getState().syncServerShellSnapshot(snapshot, environmentId); | ||
| 1139 | markAppliedProjectionSnapshot(environmentId, snapshot); | ||
| 1140 | reconcileThreadDetailSubscriptionsForEnvironment( | ||
| 1141 | environmentId, | ||
| 1142 | snapshot.threads.map((thread) => thread.id), | ||
| 1143 | ); | ||
| 1144 | reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); | ||
| 1145 | reconcileSnapshotDerivedState(); | ||
| 1146 | }, | ||
| 1147 | }; | ||
| 1148 | } | ||
| 1149 | |||
| 1150 | function createWsRpcClient(transport: WsTransport): WsRpcClient { | ||
| 1151 | return createBaseWsRpcClient(transport, { | ||
| 1152 | beforeReconnect: () => resetWsReconnectBackoff(), | ||
| 1153 | }); | ||
| 1154 | } | ||
| 1155 | |||
| 1156 | function createPrimaryEnvironmentClient( | ||
| 1157 | knownEnvironment: ReturnType<typeof getPrimaryKnownEnvironment>, | ||
| 1158 | ) { | ||
| 1159 | const wsBaseUrl = getKnownEnvironmentWsBaseUrl(knownEnvironment); | ||
| 1160 | if (!wsBaseUrl) { | ||
| 1161 | throw new Error( | ||
| 1162 | `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`, | ||
| 1163 | ); | ||
| 1164 | } | ||
| 1165 | const connectionLabel = knownEnvironment?.label ?? null; | ||
| 1166 | |||
| 1167 | return createWsRpcClient( | ||
| 1168 | new WsTransport(wsBaseUrl, { | ||
| 1169 | getConnectionLabel: () => connectionLabel, | ||
| 1170 | getVersionMismatchHint: () => | ||
| 1171 | resolveServerConfigVersionMismatch(getServerConfig())?.hint ?? null, | ||
| 1172 | }), | ||
| 1173 | ); | ||
| 1174 | } | ||
| 1175 | |||
| 1176 | function createSavedEnvironmentClient( | ||
| 1177 | environmentId: EnvironmentId, | ||
| 1178 | credentialRef: { current: SavedEnvironmentCredential }, | ||
| 1179 | relayTraceHeadersRef: { current: Headers.Headers | null }, | ||
| 1180 | ): WsRpcClient { | ||
| 1181 | useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); | ||
| 1182 | |||
| 1183 | return createWsRpcClient( | ||
| 1184 | new WsTransport( | ||
| 1185 | async () => { | ||
| 1186 | const record = getSavedEnvironmentRecord(environmentId); | ||
| 1187 | if (!record) { | ||
| 1188 | throw new Error(`Saved environment ${environmentId} not found.`); | ||
| 1189 | } | ||
| 1190 | const credential = credentialRef.current; | ||
| 1191 | if (record.desktopSsh) { | ||
| 1192 | if (credential.method !== "bearer") { | ||
| 1193 | throw new Error("SSH environments require bearer credentials."); | ||
| 1194 | } | ||
| 1195 | return await resolveDesktopSshWebSocketConnectionUrl( | ||
| 1196 | record.wsBaseUrl, | ||
| 1197 | record.httpBaseUrl, | ||
| 1198 | credential.token, | ||
| 1199 | ); | ||
| 1200 | } | ||
| 1201 | if (credential.method === "dpop") { | ||
| 1202 | try { | ||
| 1203 | const relayTraceHeaders = relayTraceHeadersRef.current; | ||
| 1204 | relayTraceHeadersRef.current = null; | ||
| 1205 | return await webRuntime.runPromise( | ||
| 1206 | resolveManagedRelayWebSocketUrl(record, credential, relayTraceHeaders), | ||
| 1207 | ); | ||
| 1208 | } catch (error) { | ||
| 1209 | if (!isEnvironmentAuthInvalidError(error)) { | ||
| 1210 | throw error; | ||
| 1211 | } | ||
| 1212 | const renewed = await renewManagedRelayCredential(record); | ||
| 1213 | if (!renewed || renewed.credential.method !== "dpop") { | ||
| 1214 | throw error; | ||
| 1215 | } | ||
| 1216 | const renewedCredential = renewed.credential; | ||
| 1217 | credentialRef.current = renewedCredential; | ||
| 1218 | return await webRuntime.runPromise( | ||
| 1219 | resolveManagedRelayWebSocketUrl( | ||
| 1220 | renewed.record, | ||
| 1221 | renewedCredential, | ||
| 1222 | renewed.relayTraceHeaders, | ||
| 1223 | ), | ||
| 1224 | ); | ||
| 1225 | } | ||
| 1226 | } | ||
| 1227 | return await webRuntime.runPromise( | ||
| 1228 | resolveRemoteWebSocketConnectionUrl({ | ||
| 1229 | wsBaseUrl: record.wsBaseUrl, | ||
| 1230 | httpBaseUrl: record.httpBaseUrl, | ||
| 1231 | bearerToken: credential.token, | ||
| 1232 | }), | ||
| 1233 | ); | ||
| 1234 | }, | ||
| 1235 | { | ||
| 1236 | getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, | ||
| 1237 | getVersionMismatchHint: () => | ||
| 1238 | resolveServerConfigVersionMismatch( | ||
| 1239 | useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, | ||
| 1240 | )?.hint ?? null, | ||
| 1241 | onAttempt: () => { | ||
| 1242 | setRuntimeConnecting(environmentId); | ||
| 1243 | }, | ||
| 1244 | onOpen: () => { | ||
| 1245 | setRuntimeConnected(environmentId); | ||
| 1246 | }, | ||
| 1247 | onError: (message: string) => { | ||
| 1248 | const mismatch = resolveServerConfigVersionMismatch( | ||
| 1249 | useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, | ||
| 1250 | ); | ||
| 1251 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | ||
| 1252 | connectionState: "error", | ||
| 1253 | lastError: appendVersionMismatchHint(message, mismatch), | ||
| 1254 | lastErrorAt: isoNow(), | ||
| 1255 | }); | ||
| 1256 | }, | ||
| 1257 | onClose: (details: { readonly code: number; readonly reason: string }) => { | ||
| 1258 | setRuntimeDisconnected( | ||
| 1259 | environmentId, | ||
| 1260 | appendVersionMismatchHint( | ||
| 1261 | details.reason, | ||
| 1262 | resolveServerConfigVersionMismatch( | ||
| 1263 | useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, | ||
| 1264 | ), | ||
| 1265 | ), | ||
| 1266 | ); | ||
| 1267 | }, | ||
| 1268 | }, | ||
| 1269 | ), | ||
| 1270 | ); | ||
| 1271 | } | ||
| 1272 | |||
| 1273 | async function refreshSavedEnvironmentMetadata( | ||
| 1274 | environmentId: EnvironmentId, | ||
| 1275 | credential: SavedEnvironmentCredential, | ||
| 1276 | client: WsRpcClient, | ||
| 1277 | scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null, | ||
| 1278 | configHint?: ServerConfig | null, | ||
| 1279 | ): Promise<void> { | ||
| 1280 | const record = getSavedEnvironmentRecord(environmentId); | ||
| 1281 | if (!record) { | ||
| 1282 | throw new Error(`Saved environment ${environmentId} not found.`); | ||
| 1283 | } | ||
| 1284 | |||
| 1285 | const [serverConfig, sessionState] = await Promise.all([ | ||
| 1286 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), | ||
| 1287 | record.desktopSsh | ||
| 1288 | ? credential.method === "bearer" | ||
| 1289 | ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token) | ||
| 1290 | : Promise.reject(new Error("SSH environments require bearer credentials.")) | ||
| 1291 | : credential.method === "dpop" | ||
| 1292 | ? webRuntime.runPromise( | ||
| 1293 | createManagedRelayDpopProof({ | ||
| 1294 | method: "GET", | ||
| 1295 | url: new URL("/api/auth/session", record.httpBaseUrl).toString(), | ||
| 1296 | accessToken: credential.accessToken, | ||
| 1297 | }).pipe( | ||
| 1298 | Effect.flatMap((proof) => | ||
| 1299 | fetchRemoteDpopSessionState({ | ||
| 1300 | httpBaseUrl: record.httpBaseUrl, | ||
| 1301 | accessToken: credential.accessToken, | ||
| 1302 | dpopProof: proof, | ||
| 1303 | }), | ||
| 1304 | ), | ||
| 1305 | ), | ||
| 1306 | ) | ||
| 1307 | : webRuntime.runPromise( | ||
| 1308 | fetchRemoteSessionState({ | ||
| 1309 | httpBaseUrl: record.httpBaseUrl, | ||
| 1310 | bearerToken: credential.token, | ||
| 1311 | }), | ||
| 1312 | ), | ||
| 1313 | ]); | ||
| 1314 | |||
| 1315 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | ||
| 1316 | authState: sessionState.authenticated ? "authenticated" : "requires-auth", | ||
| 1317 | descriptor: serverConfig.environment, | ||
| 1318 | serverConfig, | ||
| 1319 | scopes: sessionState.authenticated ? (sessionState.scopes ?? scopeHint ?? null) : null, | ||
| 1320 | }); | ||
| 1321 | useSavedEnvironmentRegistryStore | ||
| 1322 | .getState() | ||
| 1323 | .rename(record.environmentId, serverConfig.environment.label); | ||
| 1324 | } | ||
| 1325 | |||
| 1326 | const resolveManagedRelayWebSocketUrl = Effect.fn( | ||
| 1327 | "web.environment.resolveManagedRelayWebSocketUrl", | ||
| 1328 | )(function* ( | ||
| 1329 | record: SavedEnvironmentRecord, | ||
| 1330 | credential: Extract<SavedEnvironmentCredential, { readonly method: "dpop" }>, | ||
| 1331 | traceHeaders: Headers.Headers | null, | ||
| 1332 | ) { | ||
| 1333 | const request = createManagedRelayDpopProof({ | ||
| 1334 | method: "POST", | ||
| 1335 | url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(), | ||
| 1336 | accessToken: credential.accessToken, | ||
| 1337 | }).pipe( | ||
| 1338 | Effect.flatMap((proof) => | ||
| 1339 | resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 1340 | wsBaseUrl: record.wsBaseUrl, | ||
| 1341 | httpBaseUrl: record.httpBaseUrl, | ||
| 1342 | accessToken: credential.accessToken, | ||
| 1343 | dpopProof: proof, | ||
| 1344 | }), | ||
| 1345 | ), | ||
| 1346 | ); | ||
| 1347 | const parent = traceHeaders ? HttpTraceContext.fromHeaders(traceHeaders) : Option.none(); | ||
| 1348 | return yield* ( | ||
| 1349 | Option.isSome(parent) | ||
| 1350 | ? request.pipe(Effect.withParentSpan(parent.value)) | ||
| 1351 | : request.pipe( | ||
| 1352 | Effect.withSpan("relay.environment.reconnect", { | ||
| 1353 | root: true, | ||
| 1354 | attributes: { "relay.environment_id": record.environmentId }, | ||
| 1355 | }), | ||
| 1356 | ) | ||
| 1357 | ).pipe(withRelayClientTracing); | ||
| 1358 | }); | ||
| 1359 | |||
| 1360 | async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{ | ||
| 1361 | readonly record: SavedEnvironmentRecord; | ||
| 1362 | readonly credential: SavedEnvironmentCredential; | ||
| 1363 | readonly relayTraceHeaders: Headers.Headers; | ||
| 1364 | } | null> { | ||
| 1365 | if (!record.relayManaged) { | ||
| 1366 | return null; | ||
| 1367 | } | ||
| 1368 | const clerkToken = await readManagedRelayClerkToken(); | ||
| 1369 | if (!clerkToken) { | ||
| 1370 | return null; | ||
| 1371 | } | ||
| 1372 | const connected = await webRuntime.runPromise( | ||
| 1373 | connectManagedCloudEnvironment({ | ||
| 1374 | clerkToken, | ||
| 1375 | relayUrl: record.relayManaged.relayUrl, | ||
| 1376 | environment: { | ||
| 1377 | environmentId: record.environmentId, | ||
| 1378 | label: record.label, | ||
| 1379 | linkedAt: record.createdAt, | ||
| 1380 | endpoint: { | ||
| 1381 | httpBaseUrl: record.httpBaseUrl, | ||
| 1382 | wsBaseUrl: record.wsBaseUrl, | ||
| 1383 | providerKind: "cloudflare_tunnel", | ||
| 1384 | }, | ||
| 1385 | }, | ||
| 1386 | }), | ||
| 1387 | ); | ||
| 1388 | const nextRecord: SavedEnvironmentRecord = { | ||
| 1389 | ...record, | ||
| 1390 | label: connected.label, | ||
| 1391 | httpBaseUrl: connected.httpBaseUrl, | ||
| 1392 | wsBaseUrl: connected.wsBaseUrl, | ||
| 1393 | }; | ||
| 1394 | const credential: SavedEnvironmentCredential = { | ||
| 1395 | version: 1, | ||
| 1396 | method: "dpop", | ||
| 1397 | accessToken: connected.accessToken, | ||
| 1398 | }; | ||
| 1399 | await persistSavedEnvironmentRecord(nextRecord); | ||
| 1400 | if (!(await writeSavedEnvironmentCredential(nextRecord.environmentId, credential))) { | ||
| 1401 | throw new Error("Unable to persist refreshed managed environment credentials."); | ||
| 1402 | } | ||
| 1403 | useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); | ||
| 1404 | return { record: nextRecord, credential, relayTraceHeaders: connected.relayTraceHeaders }; | ||
| 1405 | } | ||
| 1406 | |||
| 1407 | function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { | ||
| 1408 | const existing = environmentConnections.get(connection.environmentId); | ||
| 1409 | if (existing && existing !== connection) { | ||
| 1410 | throw new Error(`Environment ${connection.environmentId} already has an active connection.`); | ||
| 1411 | } | ||
| 1412 | environmentConnections.set(connection.environmentId, connection); | ||
| 1413 | terminalMetadataSubscriptions.get(connection.environmentId)?.(); | ||
| 1414 | terminalMetadataSubscriptions.set( | ||
| 1415 | connection.environmentId, | ||
| 1416 | subscribeTerminalMetadata({ | ||
| 1417 | environmentId: connection.environmentId, | ||
| 1418 | client: connection.client, | ||
| 1419 | }), | ||
| 1420 | ); | ||
| 1421 | portDiscoverySubscriptions.get(connection.environmentId)?.(); | ||
| 1422 | portDiscoverySubscriptions.set( | ||
| 1423 | connection.environmentId, | ||
| 1424 | subscribePortDiscovery({ | ||
| 1425 | environmentId: connection.environmentId, | ||
| 1426 | previewApi: connection.client.preview, | ||
| 1427 | }), | ||
| 1428 | ); | ||
| 1429 | attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); | ||
| 1430 | emitEnvironmentConnectionRegistryChange(); | ||
| 1431 | return connection; | ||
| 1432 | } | ||
| 1433 | |||
| 1434 | async function removeConnection(environmentId: EnvironmentId): Promise<boolean> { | ||
| 1435 | const connection = environmentConnections.get(environmentId); | ||
| 1436 | if (!connection) { | ||
| 1437 | return false; | ||
| 1438 | } | ||
| 1439 | |||
| 1440 | lastAppliedProjectionVersionByEnvironment.delete(environmentId); | ||
| 1441 | environmentConnections.delete(environmentId); | ||
| 1442 | terminalMetadataSubscriptions.get(environmentId)?.(); | ||
| 1443 | terminalMetadataSubscriptions.delete(environmentId); | ||
| 1444 | portDiscoverySubscriptions.get(environmentId)?.(); | ||
| 1445 | portDiscoverySubscriptions.delete(environmentId); | ||
| 1446 | usePortDiscoveryStore.getState().clearEnvironment(environmentId); | ||
| 1447 | terminalSessionManager.invalidateEnvironment(environmentId); | ||
| 1448 | emitEnvironmentConnectionRegistryChange(); | ||
| 1449 | detachThreadDetailSubscriptionsForEnvironment(environmentId); | ||
| 1450 | await connection.dispose(); | ||
| 1451 | return true; | ||
| 1452 | } | ||
| 1453 | |||
| 1454 | function createPrimaryEnvironmentConnection(): EnvironmentConnection { | ||
| 1455 | const knownEnvironment = getPrimaryKnownEnvironment(); | ||
| 1456 | if (!knownEnvironment?.environmentId) { | ||
| 1457 | throw new Error("Unable to resolve the primary environment."); | ||
| 1458 | } | ||
| 1459 | |||
| 1460 | const existing = environmentConnections.get(knownEnvironment.environmentId); | ||
| 1461 | if (existing) { | ||
| 1462 | return existing; | ||
| 1463 | } | ||
| 1464 | |||
| 1465 | return registerConnection( | ||
| 1466 | createEnvironmentConnection({ | ||
| 1467 | kind: "primary", | ||
| 1468 | knownEnvironment, | ||
| 1469 | client: createPrimaryEnvironmentClient(knownEnvironment), | ||
| 1470 | ...createEnvironmentConnectionHandlers(), | ||
| 1471 | }), | ||
| 1472 | ); | ||
| 1473 | } | ||
| 1474 | |||
| 1475 | function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null { | ||
| 1476 | return getPrimaryKnownEnvironment()?.environmentId ? createPrimaryEnvironmentConnection() : null; | ||
| 1477 | } | ||
| 1478 | |||
| 1479 | async function ensureSavedEnvironmentConnection( | ||
| 1480 | record: SavedEnvironmentRecord, | ||
| 1481 | options?: { | ||
| 1482 | readonly client?: WsRpcClient; | ||
| 1483 | readonly bearerToken?: string; | ||
| 1484 | readonly credential?: SavedEnvironmentCredential; | ||
| 1485 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null; | ||
| 1486 | readonly serverConfig?: ServerConfig | null; | ||
| 1487 | readonly allowManagedRenewal?: boolean; | ||
| 1488 | readonly relayTraceHeaders?: Headers.Headers; | ||
| 1489 | }, | ||
| 1490 | ): Promise<EnvironmentConnection> { | ||
| 1491 | const existing = environmentConnections.get(record.environmentId); | ||
| 1492 | if (existing) { | ||
| 1493 | return existing; | ||
| 1494 | } | ||
| 1495 | |||
| 1496 | const pending = pendingSavedEnvironmentConnections.get(record.environmentId); | ||
| 1497 | if (pending) { | ||
| 1498 | return pending.promise; | ||
| 1499 | } | ||
| 1500 | |||
| 1501 | const attempt = savedEnvironmentConnectionAttempts.begin(record.environmentId); | ||
| 1502 | const pendingEntry: PendingSavedEnvironmentConnection = { | ||
| 1503 | isCurrent: attempt.isCurrent, | ||
| 1504 | promise: Promise.resolve().then(async () => { | ||
| 1505 | let activeRecord = record; | ||
| 1506 | let scopeHint = options?.scopes ?? null; | ||
| 1507 | let credential = | ||
| 1508 | options?.credential ?? | ||
| 1509 | (options?.bearerToken | ||
| 1510 | ? ({ version: 1, method: "bearer", token: options.bearerToken } as const) | ||
| 1511 | : await readSavedEnvironmentCredential(record.environmentId)); | ||
| 1512 | if (!credential) { | ||
| 1513 | if (record.desktopSsh) { | ||
| 1514 | const issued = await issueDesktopSshBearerSession(record); | ||
| 1515 | activeRecord = issued.record; | ||
| 1516 | credential = { version: 1, method: "bearer", token: issued.bearerToken }; | ||
| 1517 | scopeHint = issued.scopes; | ||
| 1518 | } else { | ||
| 1519 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | ||
| 1520 | authState: "requires-auth", | ||
| 1521 | scopes: null, | ||
| 1522 | connectionState: "disconnected", | ||
| 1523 | lastError: "Saved environment is missing its saved credential. Pair it again.", | ||
| 1524 | lastErrorAt: isoNow(), | ||
| 1525 | }); | ||
| 1526 | throw new Error("Saved environment is missing its saved credential."); | ||
| 1527 | } | ||
| 1528 | } else { | ||
| 1529 | const prepared = await prepareSavedEnvironmentRecordForConnection(record); | ||
| 1530 | activeRecord = prepared.record; | ||
| 1531 | } | ||
| 1532 | |||
| 1533 | const activeCredential = { current: credential }; | ||
| 1534 | const relayTraceHeaders = { current: options?.relayTraceHeaders ?? null }; | ||
| 1535 | const client = | ||
| 1536 | options?.client ?? | ||
| 1537 | createSavedEnvironmentClient( | ||
| 1538 | activeRecord.environmentId, | ||
| 1539 | activeCredential, | ||
| 1540 | relayTraceHeaders, | ||
| 1541 | ); | ||
| 1542 | const initialConfigSnapshot = createDeferredPromise<ServerConfig>(); | ||
| 1543 | const knownEnvironment = createKnownEnvironment({ | ||
| 1544 | id: activeRecord.environmentId, | ||
| 1545 | label: activeRecord.label, | ||
| 1546 | source: "manual", | ||
| 1547 | target: { | ||
| 1548 | httpBaseUrl: activeRecord.httpBaseUrl, | ||
| 1549 | wsBaseUrl: activeRecord.wsBaseUrl, | ||
| 1550 | }, | ||
| 1551 | }); | ||
| 1552 | const connection = createEnvironmentConnection({ | ||
| 1553 | kind: "saved", | ||
| 1554 | knownEnvironment: { | ||
| 1555 | ...knownEnvironment, | ||
| 1556 | environmentId: activeRecord.environmentId, | ||
| 1557 | }, | ||
| 1558 | client, | ||
| 1559 | refreshMetadata: async () => { | ||
| 1560 | await refreshSavedEnvironmentMetadata( | ||
| 1561 | activeRecord.environmentId, | ||
| 1562 | activeCredential.current, | ||
| 1563 | client, | ||
| 1564 | ); | ||
| 1565 | }, | ||
| 1566 | onConfigSnapshot: (config) => { | ||
| 1567 | initialConfigSnapshot.resolve(config); | ||
| 1568 | useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { | ||
| 1569 | descriptor: config.environment, | ||
| 1570 | serverConfig: config, | ||
| 1571 | }); | ||
| 1572 | }, | ||
| 1573 | onWelcome: (payload) => { | ||
| 1574 | useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { | ||
| 1575 | descriptor: payload.environment, | ||
| 1576 | }); | ||
| 1577 | }, | ||
| 1578 | ...createEnvironmentConnectionHandlers(), | ||
| 1579 | }); | ||
| 1580 | |||
| 1581 | try { | ||
| 1582 | try { | ||
| 1583 | const initialServerConfig = | ||
| 1584 | options?.serverConfig ?? | ||
| 1585 | (await waitForConfigSnapshot( | ||
| 1586 | initialConfigSnapshot.promise, | ||
| 1587 | INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS, | ||
| 1588 | )); | ||
| 1589 | await refreshSavedEnvironmentMetadata( | ||
| 1590 | activeRecord.environmentId, | ||
| 1591 | activeCredential.current, | ||
| 1592 | client, | ||
| 1593 | scopeHint, | ||
| 1594 | initialServerConfig, | ||
| 1595 | ); | ||
| 1596 | } catch (error) { | ||
| 1597 | const isAuthError = activeRecord.desktopSsh | ||
| 1598 | ? isSshHttpAuthError(error, 401) | ||
| 1599 | : isEnvironmentAuthInvalidError(error); | ||
| 1600 | if (!isAuthError) { | ||
| 1601 | throw error; | ||
| 1602 | } | ||
| 1603 | if (!activeRecord.desktopSsh) { | ||
| 1604 | if ( | ||
| 1605 | activeCredential.current.method === "dpop" && | ||
| 1606 | options?.allowManagedRenewal !== false | ||
| 1607 | ) { | ||
| 1608 | const renewed = await renewManagedRelayCredential(activeRecord); | ||
| 1609 | if (renewed) { | ||
| 1610 | await connection.dispose().catch(() => undefined); | ||
| 1611 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); | ||
| 1612 | return await ensureSavedEnvironmentConnection(renewed.record, { | ||
| 1613 | credential: renewed.credential, | ||
| 1614 | scopes: scopeHint, | ||
| 1615 | serverConfig: options?.serverConfig ?? null, | ||
| 1616 | allowManagedRenewal: false, | ||
| 1617 | relayTraceHeaders: renewed.relayTraceHeaders, | ||
| 1618 | }); | ||
| 1619 | } | ||
| 1620 | } | ||
| 1621 | await removeSavedEnvironmentBearerToken(activeRecord.environmentId); | ||
| 1622 | throw new Error( | ||
| 1623 | activeCredential.current.method === "dpop" | ||
| 1624 | ? "Managed tunnel credential expired. Connect it again from T3 Connect." | ||
| 1625 | : "Saved environment credential expired. Pair it again.", | ||
| 1626 | { | ||
| 1627 | cause: error, | ||
| 1628 | }, | ||
| 1629 | ); | ||
| 1630 | } | ||
| 1631 | |||
| 1632 | const issued = await issueDesktopSshBearerSession(activeRecord); | ||
| 1633 | activeRecord = issued.record; | ||
| 1634 | credential = { version: 1, method: "bearer", token: issued.bearerToken }; | ||
| 1635 | scopeHint = issued.scopes; | ||
| 1636 | await connection.dispose().catch(() => undefined); | ||
| 1637 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); | ||
| 1638 | return await ensureSavedEnvironmentConnection(activeRecord, { | ||
| 1639 | credential, | ||
| 1640 | scopes: scopeHint, | ||
| 1641 | serverConfig: options?.serverConfig ?? null, | ||
| 1642 | }); | ||
| 1643 | } | ||
| 1644 | if ( | ||
| 1645 | !pendingEntry.isCurrent() || | ||
| 1646 | pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry | ||
| 1647 | ) { | ||
| 1648 | await connection.dispose().catch(() => undefined); | ||
| 1649 | throw new EnvironmentConnectionAttemptCancelledError(activeRecord.environmentId); | ||
| 1650 | } | ||
| 1651 | registerConnection(connection); | ||
| 1652 | return connection; | ||
| 1653 | } catch (error) { | ||
| 1654 | if (error instanceof EnvironmentConnectionAttemptCancelledError) { | ||
| 1655 | throw error; | ||
| 1656 | } | ||
| 1657 | setRuntimeError(activeRecord.environmentId, error); | ||
| 1658 | const removed = await removeConnection(activeRecord.environmentId).catch(() => false); | ||
| 1659 | if (!removed) { | ||
| 1660 | await connection.dispose().catch(() => undefined); | ||
| 1661 | } | ||
| 1662 | throw error; | ||
| 1663 | } | ||
| 1664 | }), | ||
| 1665 | }; | ||
| 1666 | |||
| 1667 | pendingSavedEnvironmentConnections.set(record.environmentId, pendingEntry); | ||
| 1668 | return await pendingEntry.promise.finally(() => { | ||
| 1669 | if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) { | ||
| 1670 | pendingSavedEnvironmentConnections.delete(record.environmentId); | ||
| 1671 | savedEnvironmentConnectionAttempts.cancel(record.environmentId); | ||
| 1672 | } | ||
| 1673 | }); | ||
| 1674 | } | ||
| 1675 | |||
| 1676 | async function syncSavedEnvironmentConnections( | ||
| 1677 | records: ReadonlyArray<SavedEnvironmentRecord>, | ||
| 1678 | ): Promise<void> { | ||
| 1679 | const expectedEnvironmentIds = new Set(records.map((record) => record.environmentId)); | ||
| 1680 | const staleEnvironmentIds: EnvironmentId[] = []; | ||
| 1681 | for (const connection of environmentConnections.values()) { | ||
| 1682 | if (connection.kind !== "saved") continue; | ||
| 1683 | if (expectedEnvironmentIds.has(connection.environmentId)) continue; | ||
| 1684 | staleEnvironmentIds.push(connection.environmentId); | ||
| 1685 | } | ||
| 1686 | |||
| 1687 | await Promise.all( | ||
| 1688 | staleEnvironmentIds.map((environmentId) => disconnectSavedEnvironment(environmentId)), | ||
| 1689 | ); | ||
| 1690 | await Promise.all( | ||
| 1691 | records.map((record) => ensureSavedEnvironmentConnection(record).catch(() => undefined)), | ||
| 1692 | ); | ||
| 1693 | } | ||
| 1694 | |||
| 1695 | function stopActiveService() { | ||
| 1696 | activeService?.stop(); | ||
| 1697 | activeService = null; | ||
| 1698 | } | ||
| 1699 | |||
| 1700 | function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void { | ||
| 1701 | const now = Date.now(); | ||
| 1702 | if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) { | ||
| 1703 | return; | ||
| 1704 | } | ||
| 1705 | |||
| 1706 | for (const connection of environmentConnections.values()) { | ||
| 1707 | if (connection.client.isHeartbeatFresh()) { | ||
| 1708 | continue; | ||
| 1709 | } | ||
| 1710 | lastBrowserResumeReconnectAt = now; | ||
| 1711 | void connection.reconnect().catch((error) => { | ||
| 1712 | console.warn("Environment reconnect after browser resume failed", { | ||
| 1713 | environmentId: connection.environmentId, | ||
| 1714 | reason, | ||
| 1715 | error: error instanceof Error ? error.message : String(error), | ||
| 1716 | }); | ||
| 1717 | }); | ||
| 1718 | } | ||
| 1719 | } | ||
| 1720 | |||
| 1721 | function subscribeBrowserResumeReconnects(): () => void { | ||
| 1722 | if (typeof document === "undefined" || typeof window === "undefined") { | ||
| 1723 | return NOOP; | ||
| 1724 | } | ||
| 1725 | |||
| 1726 | const handleVisibilityChange = () => { | ||
| 1727 | if (document.visibilityState === "hidden") { | ||
| 1728 | lastBrowserHiddenAt = Date.now(); | ||
| 1729 | return; | ||
| 1730 | } | ||
| 1731 | if (document.visibilityState === "visible" && lastBrowserHiddenAt !== null) { | ||
| 1732 | lastBrowserHiddenAt = null; | ||
| 1733 | reconnectEnvironmentConnectionsAfterBrowserResume("visibilitychange"); | ||
| 1734 | } | ||
| 1735 | }; | ||
| 1736 | |||
| 1737 | const handlePageShow = (event: PageTransitionEvent) => { | ||
| 1738 | if (event.persisted || lastBrowserHiddenAt !== null) { | ||
| 1739 | lastBrowserHiddenAt = null; | ||
| 1740 | reconnectEnvironmentConnectionsAfterBrowserResume("pageshow"); | ||
| 1741 | } | ||
| 1742 | }; | ||
| 1743 | |||
| 1744 | document.addEventListener("visibilitychange", handleVisibilityChange); | ||
| 1745 | window.addEventListener("pageshow", handlePageShow); | ||
| 1746 | return () => { | ||
| 1747 | document.removeEventListener("visibilitychange", handleVisibilityChange); | ||
| 1748 | window.removeEventListener("pageshow", handlePageShow); | ||
| 1749 | }; | ||
| 1750 | } | ||
| 1751 | |||
| 1752 | export function subscribeEnvironmentConnections(listener: () => void): () => void { | ||
| 1753 | environmentConnectionListeners.add(listener); | ||
| 1754 | return () => { | ||
| 1755 | environmentConnectionListeners.delete(listener); | ||
| 1756 | }; | ||
| 1757 | } | ||
| 1758 | |||
| 1759 | export function subscribeProviderInvalidations(listener: () => void): () => void { | ||
| 1760 | providerInvalidationListeners.add(listener); | ||
| 1761 | return () => { | ||
| 1762 | providerInvalidationListeners.delete(listener); | ||
| 1763 | }; | ||
| 1764 | } | ||
| 1765 | |||
| 1766 | export function listEnvironmentConnections(): ReadonlyArray<EnvironmentConnection> { | ||
| 1767 | return [...environmentConnections.values()]; | ||
| 1768 | } | ||
| 1769 | |||
| 1770 | export function readEnvironmentConnection( | ||
| 1771 | environmentId: EnvironmentId, | ||
| 1772 | ): EnvironmentConnection | null { | ||
| 1773 | return environmentConnections.get(environmentId) ?? null; | ||
| 1774 | } | ||
| 1775 | |||
| 1776 | export function requireEnvironmentConnection(environmentId: EnvironmentId): EnvironmentConnection { | ||
| 1777 | const connection = readEnvironmentConnection(environmentId); | ||
| 1778 | if (!connection) { | ||
| 1779 | throw new Error(`No websocket client registered for environment ${environmentId}.`); | ||
| 1780 | } | ||
| 1781 | return connection; | ||
| 1782 | } | ||
| 1783 | |||
| 1784 | export function getPrimaryEnvironmentConnection(): EnvironmentConnection { | ||
| 1785 | return createPrimaryEnvironmentConnection(); | ||
| 1786 | } | ||
| 1787 | |||
| 1788 | export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | ||
| 1789 | const record = getSavedEnvironmentRecord(environmentId); | ||
| 1790 | const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId); | ||
| 1791 | if (pendingConnection) { | ||
| 1792 | savedEnvironmentConnectionAttempts.cancel(environmentId); | ||
| 1793 | pendingSavedEnvironmentConnections.delete(environmentId); | ||
| 1794 | } | ||
| 1795 | const connection = environmentConnections.get(environmentId); | ||
| 1796 | |||
| 1797 | if (connection?.kind === "saved") { | ||
| 1798 | await removeConnection(environmentId).catch(() => false); | ||
| 1799 | } | ||
| 1800 | setRuntimeDisconnected(environmentId); | ||
| 1801 | |||
| 1802 | if (record?.desktopSsh && typeof window !== "undefined") { | ||
| 1803 | await window.desktopBridge?.disconnectSshEnvironment(record.desktopSsh); | ||
| 1804 | await removeSavedEnvironmentBearerToken(environmentId); | ||
| 1805 | } | ||
| 1806 | } | ||
| 1807 | |||
| 1808 | export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | ||
| 1809 | const record = getSavedEnvironmentRecord(environmentId); | ||
| 1810 | if (!record) { | ||
| 1811 | throw new Error("Saved environment not found."); | ||
| 1812 | } | ||
| 1813 | |||
| 1814 | const connection = environmentConnections.get(environmentId); | ||
| 1815 | if (!connection) { | ||
| 1816 | setRuntimeConnecting(environmentId); | ||
| 1817 | try { | ||
| 1818 | await ensureSavedEnvironmentConnection(record); | ||
| 1819 | return; | ||
| 1820 | } catch (error) { | ||
| 1821 | if (isSavedEnvironmentConnectionCancelledError(error)) { | ||
| 1822 | return; | ||
| 1823 | } | ||
| 1824 | setRuntimeError(environmentId, error); | ||
| 1825 | throw error; | ||
| 1826 | } | ||
| 1827 | } | ||
| 1828 | |||
| 1829 | setRuntimeConnecting(environmentId); | ||
| 1830 | try { | ||
| 1831 | if (record.desktopSsh) { | ||
| 1832 | await prepareSavedEnvironmentRecordForConnection(record); | ||
| 1833 | } | ||
| 1834 | await connection.reconnect(); | ||
| 1835 | } catch (error) { | ||
| 1836 | if (record.desktopSsh) { | ||
| 1837 | try { | ||
| 1838 | const issued = await issueDesktopSshBearerSession( | ||
| 1839 | getSavedEnvironmentRecord(environmentId) ?? record, | ||
| 1840 | ); | ||
| 1841 | await removeConnection(environmentId).catch(() => false); | ||
| 1842 | await ensureSavedEnvironmentConnection(issued.record, { | ||
| 1843 | bearerToken: issued.bearerToken, | ||
| 1844 | scopes: issued.scopes, | ||
| 1845 | }); | ||
| 1846 | return; | ||
| 1847 | } catch (recoveryError) { | ||
| 1848 | if (isSavedEnvironmentConnectionCancelledError(recoveryError)) { | ||
| 1849 | return; | ||
| 1850 | } | ||
| 1851 | setRuntimeError(environmentId, recoveryError); | ||
| 1852 | throw recoveryError; | ||
| 1853 | } | ||
| 1854 | } | ||
| 1855 | setRuntimeError(environmentId, error); | ||
| 1856 | throw error; | ||
| 1857 | } | ||
| 1858 | } | ||
| 1859 | |||
| 1860 | export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | ||
| 1861 | await disconnectSavedEnvironment(environmentId); | ||
| 1862 | disposeThreadDetailSubscriptionsForEnvironment(environmentId); | ||
| 1863 | useSavedEnvironmentRegistryStore.getState().remove(environmentId); | ||
| 1864 | useSavedEnvironmentRuntimeStore.getState().clear(environmentId); | ||
| 1865 | useStore.getState().removeEnvironmentState(environmentId); | ||
| 1866 | await removeSavedEnvironmentBearerToken(environmentId); | ||
| 1867 | } | ||
| 1868 | |||
| 1869 | export async function addSavedEnvironment(input: { | ||
| 1870 | readonly label: string; | ||
| 1871 | readonly pairingUrl?: string; | ||
| 1872 | readonly host?: string; | ||
| 1873 | readonly pairingCode?: string; | ||
| 1874 | readonly desktopSsh?: DesktopSshEnvironmentTarget; | ||
| 1875 | }): Promise<SavedEnvironmentRecord> { | ||
| 1876 | const resolvedTarget = resolveRemotePairingTarget({ | ||
| 1877 | ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}), | ||
| 1878 | ...(input.host !== undefined ? { host: input.host } : {}), | ||
| 1879 | ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}), | ||
| 1880 | }); | ||
| 1881 | const descriptor = input.desktopSsh | ||
| 1882 | ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) | ||
| 1883 | : await webRuntime.runPromise( | ||
| 1884 | fetchRemoteEnvironmentDescriptor({ | ||
| 1885 | httpBaseUrl: resolvedTarget.httpBaseUrl, | ||
| 1886 | }), | ||
| 1887 | ); | ||
| 1888 | const environmentId = descriptor.environmentId; | ||
| 1889 | const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]); | ||
| 1890 | const existingRecord = | ||
| 1891 | getSavedEnvironmentRecord(environmentId) ?? | ||
| 1892 | findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh); | ||
| 1893 | const staleDesktopSshRecord = | ||
| 1894 | existingRecord && existingRecord.environmentId !== environmentId ? existingRecord : null; | ||
| 1895 | |||
| 1896 | const bearerSession = input.desktopSsh | ||
| 1897 | ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) | ||
| 1898 | : await webRuntime.runPromise( | ||
| 1899 | bootstrapRemoteBearerSession({ | ||
| 1900 | httpBaseUrl: resolvedTarget.httpBaseUrl, | ||
| 1901 | credential: resolvedTarget.credential, | ||
| 1902 | }), | ||
| 1903 | ); | ||
| 1904 | |||
| 1905 | const record: SavedEnvironmentRecord = { | ||
| 1906 | environmentId, | ||
| 1907 | label: input.label.trim() || existingRecord?.label || descriptor.label, | ||
| 1908 | wsBaseUrl: resolvedTarget.wsBaseUrl, | ||
| 1909 | httpBaseUrl: resolvedTarget.httpBaseUrl, | ||
| 1910 | createdAt: existingRecord?.createdAt ?? isoNow(), | ||
| 1911 | lastConnectedAt: isoNow(), | ||
| 1912 | ...((input.desktopSsh ?? existingRecord?.desktopSsh) | ||
| 1913 | ? { desktopSsh: input.desktopSsh ?? existingRecord?.desktopSsh } | ||
| 1914 | : {}), | ||
| 1915 | }; | ||
| 1916 | |||
| 1917 | await persistSavedEnvironmentRecord(record); | ||
| 1918 | const didPersistBearerToken = await writeSavedEnvironmentBearerToken( | ||
| 1919 | environmentId, | ||
| 1920 | bearerSession.access_token, | ||
| 1921 | ); | ||
| 1922 | if (!didPersistBearerToken) { | ||
| 1923 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); | ||
| 1924 | throw new Error("Unable to persist saved environment credentials."); | ||
| 1925 | } | ||
| 1926 | useSavedEnvironmentRegistryStore.getState().upsert(record); | ||
| 1927 | if (staleDesktopSshRecord) { | ||
| 1928 | await removeSavedEnvironment(staleDesktopSshRecord.environmentId); | ||
| 1929 | } | ||
| 1930 | await removeConnection(environmentId).catch(() => false); | ||
| 1931 | await ensureSavedEnvironmentConnection(record, { | ||
| 1932 | bearerToken: bearerSession.access_token, | ||
| 1933 | scopes: readIssuedBearerScopes(bearerSession.scope), | ||
| 1934 | }); | ||
| 1935 | return record; | ||
| 1936 | } | ||
| 1937 | |||
| 1938 | export async function addManagedRelayEnvironment(input: { | ||
| 1939 | readonly environmentId: EnvironmentId; | ||
| 1940 | readonly label: string; | ||
| 1941 | readonly httpBaseUrl: string; | ||
| 1942 | readonly wsBaseUrl: string; | ||
| 1943 | readonly relayUrl: string; | ||
| 1944 | readonly accessToken: string; | ||
| 1945 | readonly relayTraceHeaders: Headers.Headers; | ||
| 1946 | }): Promise<SavedEnvironmentRecord> { | ||
| 1947 | const existingRecord = getSavedEnvironmentRecord(input.environmentId); | ||
| 1948 | const record: SavedEnvironmentRecord = { | ||
| 1949 | environmentId: input.environmentId, | ||
| 1950 | label: input.label.trim() || existingRecord?.label || "Managed environment", | ||
| 1951 | httpBaseUrl: input.httpBaseUrl, | ||
| 1952 | wsBaseUrl: input.wsBaseUrl, | ||
| 1953 | createdAt: existingRecord?.createdAt ?? isoNow(), | ||
| 1954 | lastConnectedAt: isoNow(), | ||
| 1955 | relayManaged: { relayUrl: input.relayUrl }, | ||
| 1956 | }; | ||
| 1957 | const credential: SavedEnvironmentCredential = { | ||
| 1958 | version: 1, | ||
| 1959 | method: "dpop", | ||
| 1960 | accessToken: input.accessToken, | ||
| 1961 | }; | ||
| 1962 | |||
| 1963 | await persistSavedEnvironmentRecord(record); | ||
| 1964 | if (!(await writeSavedEnvironmentCredential(record.environmentId, credential))) { | ||
| 1965 | throw new Error("Unable to persist managed environment credentials."); | ||
| 1966 | } | ||
| 1967 | useSavedEnvironmentRegistryStore.getState().upsert(record); | ||
| 1968 | await removeConnection(record.environmentId).catch(() => false); | ||
| 1969 | await ensureSavedEnvironmentConnection(record, { | ||
| 1970 | credential, | ||
| 1971 | relayTraceHeaders: input.relayTraceHeaders, | ||
| 1972 | }); | ||
| 1973 | return record; | ||
| 1974 | } | ||
| 1975 | |||
| 1976 | export async function connectDesktopSshEnvironment( | ||
| 1977 | target: DesktopSshEnvironmentTarget, | ||
| 1978 | options?: { label?: string }, | ||
| 1979 | ): Promise<SavedEnvironmentRecord> { | ||
| 1980 | const bootstrap = await resolveDesktopSshEnvironmentBootstrap(target, { | ||
| 1981 | issuePairingToken: true, | ||
| 1982 | }); | ||
| 1983 | if (!bootstrap.pairingToken) { | ||
| 1984 | throw new Error("Desktop SSH launch did not return a pairing token."); | ||
| 1985 | } | ||
| 1986 | |||
| 1987 | return await addSavedEnvironment({ | ||
| 1988 | label: options?.label?.trim() || bootstrap.target.alias, | ||
| 1989 | host: bootstrap.httpBaseUrl, | ||
| 1990 | pairingCode: bootstrap.pairingToken, | ||
| 1991 | desktopSsh: bootstrap.target, | ||
| 1992 | }).catch((error) => { | ||
| 1993 | const detail = [ | ||
| 1994 | `local ${bootstrap.httpBaseUrl}`, | ||
| 1995 | `remote port ${bootstrap.remotePort ?? "unknown"}`, | ||
| 1996 | bootstrap.remoteServerKind ? `remote server ${bootstrap.remoteServerKind}` : null, | ||
| 1997 | ] | ||
| 1998 | .filter(Boolean) | ||
| 1999 | .join(", "); | ||
| 2000 | const message = error instanceof Error ? error.message : String(error); | ||
| 2001 | throw new Error(`${message} (${detail})`); | ||
| 2002 | }); | ||
| 2003 | } | ||
| 2004 | |||
| 2005 | export async function ensureEnvironmentConnectionBootstrapped( | ||
| 2006 | environmentId: EnvironmentId, | ||
| 2007 | ): Promise<void> { | ||
| 2008 | await environmentConnections.get(environmentId)?.ensureBootstrapped(); | ||
| 2009 | } | ||
| 2010 | |||
| 2011 | export function startEnvironmentConnectionService(queryClient: QueryClient): () => void { | ||
| 2012 | if (activeService?.queryClient === queryClient) { | ||
| 2013 | activeService.refCount += 1; | ||
| 2014 | return () => { | ||
| 2015 | if (!activeService || activeService.queryClient !== queryClient) { | ||
| 2016 | return; | ||
| 2017 | } | ||
| 2018 | activeService.refCount -= 1; | ||
| 2019 | if (activeService.refCount === 0) { | ||
| 2020 | stopActiveService(); | ||
| 2021 | } | ||
| 2022 | }; | ||
| 2023 | } | ||
| 2024 | |||
| 2025 | stopActiveService(); | ||
| 2026 | needsProviderInvalidation = false; | ||
| 2027 | const queryInvalidationThrottler = new Throttler( | ||
| 2028 | () => { | ||
| 2029 | if (!needsProviderInvalidation) { | ||
| 2030 | return; | ||
| 2031 | } | ||
| 2032 | needsProviderInvalidation = false; | ||
| 2033 | emitProviderInvalidation(); | ||
| 2034 | }, | ||
| 2035 | { | ||
| 2036 | wait: 100, | ||
| 2037 | leading: false, | ||
| 2038 | trailing: true, | ||
| 2039 | }, | ||
| 2040 | ); | ||
| 2041 | const requestSavedEnvironmentSync = createSavedEnvironmentSyncScheduler(); | ||
| 2042 | |||
| 2043 | maybeCreatePrimaryEnvironmentConnection(); | ||
| 2044 | |||
| 2045 | const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => { | ||
| 2046 | if (!hasSavedEnvironmentRegistryHydrated()) { | ||
| 2047 | return; | ||
| 2048 | } | ||
| 2049 | void requestSavedEnvironmentSync(); | ||
| 2050 | }); | ||
| 2051 | |||
| 2052 | void waitForSavedEnvironmentRegistryHydration() | ||
| 2053 | .then(() => requestSavedEnvironmentSync()) | ||
| 2054 | .catch(() => undefined); | ||
| 2055 | |||
| 2056 | const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects(); | ||
| 2057 | |||
| 2058 | activeService = { | ||
| 2059 | queryClient, | ||
| 2060 | queryInvalidationThrottler, | ||
| 2061 | refCount: 1, | ||
| 2062 | stop: () => { | ||
| 2063 | unsubscribeSavedEnvironments(); | ||
| 2064 | unsubscribeBrowserResumeReconnects(); | ||
| 2065 | queryInvalidationThrottler.cancel(); | ||
| 2066 | }, | ||
| 2067 | }; | ||
| 2068 | |||
| 2069 | return () => { | ||
| 2070 | if (!activeService || activeService.queryClient !== queryClient) { | ||
| 2071 | return; | ||
| 2072 | } | ||
| 2073 | activeService.refCount -= 1; | ||
| 2074 | if (activeService.refCount === 0) { | ||
| 2075 | stopActiveService(); | ||
| 2076 | } | ||
| 2077 | }; | ||
| 2078 | } | ||
| 2079 | |||
| 2080 | export async function resetEnvironmentServiceForTests(): Promise<void> { | ||
| 2081 | stopActiveService(); | ||
| 2082 | lastBrowserHiddenAt = null; | ||
| 2083 | lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; | ||
| 2084 | lastAppliedProjectionVersionByEnvironment.clear(); | ||
| 2085 | pendingSavedEnvironmentConnections.clear(); | ||
| 2086 | savedEnvironmentConnectionAttempts.clear(); | ||
| 2087 | for (const key of Array.from(threadDetailSubscriptions.keys())) { | ||
| 2088 | disposeThreadDetailSubscriptionByKey(key); | ||
| 2089 | } | ||
| 2090 | for (const unsubscribe of terminalMetadataSubscriptions.values()) { | ||
| 2091 | unsubscribe(); | ||
| 2092 | } | ||
| 2093 | terminalMetadataSubscriptions.clear(); | ||
| 2094 | for (const unsubscribe of portDiscoverySubscriptions.values()) { | ||
| 2095 | unsubscribe(); | ||
| 2096 | } | ||
| 2097 | portDiscoverySubscriptions.clear(); | ||
| 2098 | usePortDiscoveryStore.getState().reset(); | ||
| 2099 | terminalSessionManager.reset(); | ||
| 2100 | await Promise.all( | ||
| 2101 | [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), | ||
| 2102 | ); | ||
| 2103 | } | ||
apps/web/src/historyBootstrap.test.ts+14| ... | @@ -14,6 +14,8 @@ describe("buildBootstrapInput", () => { | ... | @@ -14,6 +14,8 @@ describe("buildBootstrapInput", () => { |
| 14 | role: "user", | 14 | role: "user", |
| 15 | text: "hello", | 15 | text: "hello", |
| 16 | createdAt: "2026-02-09T00:00:00.000Z", | 16 | createdAt: "2026-02-09T00:00:00.000Z", |
| 17 | turnId: null, | ||
| 18 | updatedAt: "2026-02-09T00:00:00.000Z", | ||
| 17 | streaming: false, | 19 | streaming: false, |
| 18 | }, | 20 | }, |
| 19 | { | 21 | { |
| ... | @@ -21,6 +23,8 @@ describe("buildBootstrapInput", () => { | ... | @@ -21,6 +23,8 @@ describe("buildBootstrapInput", () => { |
| 21 | role: "assistant", | 23 | role: "assistant", |
| 22 | text: "world", | 24 | text: "world", |
| 23 | createdAt: "2026-02-09T00:00:01.000Z", | 25 | createdAt: "2026-02-09T00:00:01.000Z", |
| 26 | turnId: null, | ||
| 27 | updatedAt: "2026-02-09T00:00:01.000Z", | ||
| 24 | streaming: false, | 28 | streaming: false, |
| 25 | }, | 29 | }, |
| 26 | ], | 30 | ], |
| ... | @@ -45,6 +49,8 @@ describe("buildBootstrapInput", () => { | ... | @@ -45,6 +49,8 @@ describe("buildBootstrapInput", () => { |
| 45 | role: "user", | 49 | role: "user", |
| 46 | text: "first question with details", | 50 | text: "first question with details", |
| 47 | createdAt: "2026-02-09T00:00:00.000Z", | 51 | createdAt: "2026-02-09T00:00:00.000Z", |
| 52 | turnId: null, | ||
| 53 | updatedAt: "2026-02-09T00:00:00.000Z", | ||
| 48 | streaming: false, | 54 | streaming: false, |
| 49 | }, | 55 | }, |
| 50 | { | 56 | { |
| ... | @@ -52,6 +58,8 @@ describe("buildBootstrapInput", () => { | ... | @@ -52,6 +58,8 @@ describe("buildBootstrapInput", () => { |
| 52 | role: "assistant", | 58 | role: "assistant", |
| 53 | text: "first answer with details", | 59 | text: "first answer with details", |
| 54 | createdAt: "2026-02-09T00:00:01.000Z", | 60 | createdAt: "2026-02-09T00:00:01.000Z", |
| 61 | turnId: null, | ||
| 62 | updatedAt: "2026-02-09T00:00:01.000Z", | ||
| 55 | streaming: false, | 63 | streaming: false, |
| 56 | }, | 64 | }, |
| 57 | { | 65 | { |
| ... | @@ -59,6 +67,8 @@ describe("buildBootstrapInput", () => { | ... | @@ -59,6 +67,8 @@ describe("buildBootstrapInput", () => { |
| 59 | role: "user", | 67 | role: "user", |
| 60 | text: "second question with details", | 68 | text: "second question with details", |
| 61 | createdAt: "2026-02-09T00:00:02.000Z", | 69 | createdAt: "2026-02-09T00:00:02.000Z", |
| 70 | turnId: null, | ||
| 71 | updatedAt: "2026-02-09T00:00:02.000Z", | ||
| 62 | streaming: false, | 72 | streaming: false, |
| 63 | }, | 73 | }, |
| 64 | ], | 74 | ], |
| ... | @@ -82,6 +92,8 @@ describe("buildBootstrapInput", () => { | ... | @@ -82,6 +92,8 @@ describe("buildBootstrapInput", () => { |
| 82 | role: "user", | 92 | role: "user", |
| 83 | text: "old context", | 93 | text: "old context", |
| 84 | createdAt: "2026-02-09T00:00:00.000Z", | 94 | createdAt: "2026-02-09T00:00:00.000Z", |
| 95 | turnId: null, | ||
| 96 | updatedAt: "2026-02-09T00:00:00.000Z", | ||
| 85 | streaming: false, | 97 | streaming: false, |
| 86 | }, | 98 | }, |
| 87 | ], | 99 | ], |
| ... | @@ -112,6 +124,8 @@ describe("buildBootstrapInput", () => { | ... | @@ -112,6 +124,8 @@ describe("buildBootstrapInput", () => { |
| 112 | }, | 124 | }, |
| 113 | ], | 125 | ], |
| 114 | createdAt: "2026-02-09T00:00:00.000Z", | 126 | createdAt: "2026-02-09T00:00:00.000Z", |
| 127 | turnId: null, | ||
| 128 | updatedAt: "2026-02-09T00:00:00.000Z", | ||
| 115 | streaming: false, | 129 | streaming: false, |
| 116 | }, | 130 | }, |
| 117 | ], | 131 | ], |
apps/web/src/hooks/useCommitOnBlur.ts+8-14| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type ChangeEvent, type KeyboardEvent, useEffect, useRef, useState } from "react"; | 1 | import { type ChangeEvent, type KeyboardEvent, useState } from "react"; |
| 2 | 2 | ||
| 3 | /** | 3 | /** |
| 4 | * Buffer text input locally so keystrokes don't cause a settings-wide | 4 | * Buffer text input locally so keystrokes don't cause a settings-wide |
| ... | @@ -16,27 +16,21 @@ import { type ChangeEvent, type KeyboardEvent, useEffect, useRef, useState } fro | ... | @@ -16,27 +16,21 @@ import { type ChangeEvent, type KeyboardEvent, useEffect, useRef, useState } fro |
| 16 | * <Input {...bag} placeholder="e.g. Work" /> | 16 | * <Input {...bag} placeholder="e.g. Work" /> |
| 17 | */ | 17 | */ |
| 18 | export function useCommitOnBlur(value: string, onCommit: (next: string) => void) { | 18 | export function useCommitOnBlur(value: string, onCommit: (next: string) => void) { |
| 19 | const [draft, setDraft] = useState(value); | 19 | const [draft, setDraft] = useState<string | null>(null); |
| 20 | const focusedRef = useRef(false); | ||
| 21 | |||
| 22 | useEffect(() => { | ||
| 23 | if (!focusedRef.current) { | ||
| 24 | setDraft(value); | ||
| 25 | } | ||
| 26 | }, [value]); | ||
| 27 | 20 | ||
| 28 | return { | 21 | return { |
| 29 | value: draft, | 22 | value: draft ?? value, |
| 30 | onChange: (event: ChangeEvent<HTMLInputElement>) => { | 23 | onChange: (event: ChangeEvent<HTMLInputElement>) => { |
| 31 | setDraft(event.target.value); | 24 | setDraft(event.target.value); |
| 32 | }, | 25 | }, |
| 33 | onFocus: () => { | 26 | onFocus: () => { |
| 34 | focusedRef.current = true; | 27 | setDraft(value); |
| 35 | }, | 28 | }, |
| 36 | onBlur: () => { | 29 | onBlur: () => { |
| 37 | focusedRef.current = false; | 30 | const next = draft ?? value; |
| 38 | if (draft !== value) { | 31 | setDraft(null); |
| 39 | onCommit(draft); | 32 | if (next !== value) { |
| 33 | onCommit(next); | ||
| 40 | } | 34 | } |
| 41 | }, | 35 | }, |
| 42 | onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => { | 36 | onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => { |
apps/web/src/hooks/useHandleNewThread.ts+39-27| ... | @@ -1,9 +1,13 @@ | ... | @@ -1,9 +1,13 @@ |
| 1 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; | 1 | import { |
| 2 | scopedProjectKey, | ||
| 3 | scopeProjectRef, | ||
| 4 | scopeThreadRef, | ||
| 5 | } from "@t3tools/client-runtime/environment"; | ||
| 2 | import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; | 6 | import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; |
| 3 | import { useParams, useRouter } from "@tanstack/react-router"; | 7 | import { useParams, useRouter } from "@tanstack/react-router"; |
| 4 | import { useCallback, useMemo } from "react"; | 8 | import { useCallback, useMemo } from "react"; |
| 5 | import { useShallow } from "zustand/react/shallow"; | ||
| 6 | import { | 9 | import { |
| 10 | markPromotedDraftThreadByRef, | ||
| 7 | type DraftThreadEnvMode, | 11 | type DraftThreadEnvMode, |
| 8 | type DraftThreadState, | 12 | type DraftThreadState, |
| 9 | useComposerDraftStore, | 13 | useComposerDraftStore, |
| ... | @@ -15,14 +19,13 @@ import { | ... | @@ -15,14 +19,13 @@ import { |
| 15 | getProjectOrderKey, | 19 | getProjectOrderKey, |
| 16 | selectProjectGroupingSettings, | 20 | selectProjectGroupingSettings, |
| 17 | } from "../logicalProject"; | 21 | } from "../logicalProject"; |
| 18 | import { selectProjectsAcrossEnvironments, useStore } from "../store"; | 22 | import { readThreadShell, useProjects, useThread } from "../state/entities"; |
| 19 | import { createThreadSelectorByRef } from "../storeSelectors"; | ||
| 20 | import { resolveThreadRouteTarget } from "../threadRoutes"; | 23 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 21 | import { useUiStateStore } from "../uiStateStore"; | 24 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 22 | import { useSettings } from "./useSettings"; | 25 | import { useSettings } from "./useSettings"; |
| 23 | 26 | ||
| 24 | function useNewThreadState() { | 27 | export function useNewThreadHandler() { |
| 25 | const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); | 28 | const projects = useProjects(); |
| 26 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); | 29 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); |
| 27 | const router = useRouter(); | 30 | const router = useRouter(); |
| 28 | const getCurrentRouteTarget = useCallback(() => { | 31 | const getCurrentRouteTarget = useCallback(() => { |
| ... | @@ -60,32 +63,47 @@ function useNewThreadState() { | ... | @@ -60,32 +63,47 @@ function useNewThreadState() { |
| 60 | const hasWorktreePathOption = options?.worktreePath !== undefined; | 63 | const hasWorktreePathOption = options?.worktreePath !== undefined; |
| 61 | const hasEnvModeOption = options?.envMode !== undefined; | 64 | const hasEnvModeOption = options?.envMode !== undefined; |
| 62 | const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); | 65 | const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); |
| 66 | const storedDraftThreadRef = storedDraftThread | ||
| 67 | ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) | ||
| 68 | : null; | ||
| 69 | const reusableStoredDraftThread = | ||
| 70 | storedDraftThreadRef && readThreadShell(storedDraftThreadRef) !== null | ||
| 71 | ? null | ||
| 72 | : storedDraftThread; | ||
| 73 | if (storedDraftThreadRef && reusableStoredDraftThread === null) { | ||
| 74 | markPromotedDraftThreadByRef(storedDraftThreadRef); | ||
| 75 | } | ||
| 63 | const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget | 76 | const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget |
| 64 | ? currentRouteTarget.kind === "server" | 77 | ? currentRouteTarget.kind === "server" |
| 65 | ? getDraftThread(currentRouteTarget.threadRef) | 78 | ? getDraftThread(currentRouteTarget.threadRef) |
| 66 | : getDraftSession(currentRouteTarget.draftId) | 79 | : getDraftSession(currentRouteTarget.draftId) |
| 67 | : null; | 80 | : null; |
| 68 | if (storedDraftThread) { | 81 | if (reusableStoredDraftThread) { |
| 69 | return (async () => { | 82 | return (async () => { |
| 70 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { | 83 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { |
| 71 | setDraftThreadContext(storedDraftThread.draftId, { | 84 | setDraftThreadContext(reusableStoredDraftThread.draftId, { |
| 72 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | 85 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), |
| 73 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | 86 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), |
| 74 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | 87 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), |
| 75 | }); | 88 | }); |
| 76 | } | 89 | } |
| 77 | setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, { | 90 | setLogicalProjectDraftThreadId( |
| 78 | threadId: storedDraftThread.threadId, | 91 | logicalProjectKey, |
| 79 | }); | 92 | projectRef, |
| 93 | reusableStoredDraftThread.draftId, | ||
| 94 | { | ||
| 95 | threadId: reusableStoredDraftThread.threadId, | ||
| 96 | }, | ||
| 97 | ); | ||
| 80 | if ( | 98 | if ( |
| 81 | currentRouteTarget?.kind === "draft" && | 99 | currentRouteTarget?.kind === "draft" && |
| 82 | currentRouteTarget.draftId === storedDraftThread.draftId | 100 | currentRouteTarget.draftId === reusableStoredDraftThread.draftId |
| 83 | ) { | 101 | ) { |
| 84 | return; | 102 | return; |
| 85 | } | 103 | } |
| 86 | await router.navigate({ | 104 | await router.navigate({ |
| 87 | to: "/draft/$draftId", | 105 | to: "/draft/$draftId", |
| 88 | params: { draftId: storedDraftThread.draftId }, | 106 | params: { draftId: reusableStoredDraftThread.draftId }, |
| 89 | }); | 107 | }); |
| 90 | })(); | 108 | })(); |
| 91 | } | 109 | } |
| ... | @@ -139,14 +157,6 @@ function useNewThreadState() { | ... | @@ -139,14 +157,6 @@ function useNewThreadState() { |
| 139 | ); | 157 | ); |
| 140 | } | 158 | } |
| 141 | 159 | ||
| 142 | export function useNewThreadHandler() { | ||
| 143 | const handleNewThread = useNewThreadState(); | ||
| 144 | |||
| 145 | return { | ||
| 146 | handleNewThread, | ||
| 147 | }; | ||
| 148 | } | ||
| 149 | |||
| 150 | export function useHandleNewThread() { | 160 | export function useHandleNewThread() { |
| 151 | const projectOrder = useUiStateStore((store) => store.projectOrder); | 161 | const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 152 | const routeTarget = useParams({ | 162 | const routeTarget = useParams({ |
| ... | @@ -154,9 +164,7 @@ export function useHandleNewThread() { | ... | @@ -154,9 +164,7 @@ export function useHandleNewThread() { |
| 154 | select: (params) => resolveThreadRouteTarget(params), | 164 | select: (params) => resolveThreadRouteTarget(params), |
| 155 | }); | 165 | }); |
| 156 | const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; | 166 | const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; |
| 157 | const activeThread = useStore( | 167 | const activeThread = useThread(routeThreadRef); |
| 158 | useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), | ||
| 159 | ); | ||
| 160 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | 168 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); |
| 161 | const activeDraftThread = useComposerDraftStore(() => | 169 | const activeDraftThread = useComposerDraftStore(() => |
| 162 | routeTarget | 170 | routeTarget |
| ... | @@ -165,15 +173,19 @@ export function useHandleNewThread() { | ... | @@ -165,15 +173,19 @@ export function useHandleNewThread() { |
| 165 | : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) | 173 | : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) |
| 166 | : null, | 174 | : null, |
| 167 | ); | 175 | ); |
| 168 | const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); | 176 | const projects = useProjects(); |
| 169 | const orderedProjects = useMemo(() => { | 177 | const orderedProjects = useMemo(() => { |
| 170 | return orderItemsByPreferredIds({ | 178 | return orderItemsByPreferredIds({ |
| 171 | items: projects, | 179 | items: projects, |
| 172 | preferredIds: projectOrder, | 180 | preferredIds: projectOrder, |
| 173 | getId: getProjectOrderKey, | 181 | getId: getProjectOrderKey, |
| 182 | getPreferenceIds: (project) => [ | ||
| 183 | getProjectOrderKey(project), | ||
| 184 | legacyProjectCwdPreferenceKey(project.workspaceRoot), | ||
| 185 | ], | ||
| 174 | }); | 186 | }); |
| 175 | }, [projectOrder, projects]); | 187 | }, [projectOrder, projects]); |
| 176 | const handleNewThread = useNewThreadState(); | 188 | const handleNewThread = useNewThreadHandler(); |
| 177 | 189 | ||
| 178 | return { | 190 | return { |
| 179 | activeDraftThread, | 191 | activeDraftThread, |
apps/web/src/hooks/useLocalStorage.ts+50-66| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as Record from "effect/Record"; | 2 | import * as Record from "effect/Record"; |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; | 3 | import { useCallback, useMemo, useSyncExternalStore } from "react"; |
| 4 | 4 | ||
| 5 | const isomorphicLocalStorage: Storage = | 5 | const isomorphicLocalStorage: Storage = |
| 6 | typeof window !== "undefined" | 6 | typeof window !== "undefined" |
| ... | @@ -63,85 +63,69 @@ export function useLocalStorage<T, E>( | ... | @@ -63,85 +63,69 @@ export function useLocalStorage<T, E>( |
| 63 | initialValue: T, | 63 | initialValue: T, |
| 64 | schema: Schema.Codec<T, E>, | 64 | schema: Schema.Codec<T, E>, |
| 65 | ): [T, (value: T | ((val: T) => T)) => void] { | 65 | ): [T, (value: T | ((val: T) => T)) => void] { |
| 66 | // Get the initial value from localStorage or use the provided initialValue | 66 | const getSnapshot = useCallback(() => { |
| 67 | const [storedValue, setStoredValue] = useState<T>(() => { | ||
| 68 | try { | 67 | try { |
| 69 | const item = getLocalStorageItem(key, schema); | 68 | return isomorphicLocalStorage.getItem(key); |
| 70 | return item ?? initialValue; | 69 | } catch (error) { |
| 70 | console.error("[LOCALSTORAGE] Error:", error); | ||
| 71 | return null; | ||
| 72 | } | ||
| 73 | }, [key]); | ||
| 74 | |||
| 75 | const subscribe = useCallback( | ||
| 76 | (onStoreChange: () => void) => { | ||
| 77 | const handleStorageChange = (event: StorageEvent) => { | ||
| 78 | if (event.key === key) { | ||
| 79 | onStoreChange(); | ||
| 80 | } | ||
| 81 | }; | ||
| 82 | const handleLocalChange = (event: CustomEvent<LocalStorageChangeDetail>) => { | ||
| 83 | if (event.detail.key === key) { | ||
| 84 | onStoreChange(); | ||
| 85 | } | ||
| 86 | }; | ||
| 87 | |||
| 88 | window.addEventListener("storage", handleStorageChange); | ||
| 89 | window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); | ||
| 90 | return () => { | ||
| 91 | window.removeEventListener("storage", handleStorageChange); | ||
| 92 | window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); | ||
| 93 | }; | ||
| 94 | }, | ||
| 95 | [key], | ||
| 96 | ); | ||
| 97 | |||
| 98 | const serializedValue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); | ||
| 99 | const storedValue = useMemo(() => { | ||
| 100 | if (serializedValue === null) { | ||
| 101 | return initialValue; | ||
| 102 | } | ||
| 103 | try { | ||
| 104 | return decode(schema, serializedValue); | ||
| 71 | } catch (error) { | 105 | } catch (error) { |
| 72 | console.error("[LOCALSTORAGE] Error:", error); | 106 | console.error("[LOCALSTORAGE] Error:", error); |
| 73 | return initialValue; | 107 | return initialValue; |
| 74 | } | 108 | } |
| 75 | }); | 109 | }, [initialValue, schema, serializedValue]); |
| 76 | 110 | ||
| 77 | // Return a wrapped version of useState's setter function that persists the new value to localStorage | ||
| 78 | const setValue = useCallback( | 111 | const setValue = useCallback( |
| 79 | (value: T | ((val: T) => T)) => { | 112 | (value: T | ((val: T) => T)) => { |
| 80 | try { | 113 | try { |
| 81 | setStoredValue((prev) => { | 114 | const currentValue = getLocalStorageItem(key, schema) ?? initialValue; |
| 82 | const valueToStore = typeof value === "function" ? (value as (val: T) => T)(prev) : value; | 115 | const valueToStore = |
| 83 | if (valueToStore === null) { | 116 | typeof value === "function" ? (value as (val: T) => T)(currentValue) : value; |
| 84 | removeLocalStorageItem(key); | 117 | if (valueToStore === null) { |
| 85 | } else { | 118 | removeLocalStorageItem(key); |
| 86 | setLocalStorageItem(key, valueToStore, schema); | 119 | } else { |
| 87 | } | 120 | setLocalStorageItem(key, valueToStore, schema); |
| 88 | // Dispatch event after state update completes to avoid nested state updates | 121 | } |
| 89 | queueMicrotask(() => dispatchLocalStorageChange(key)); | 122 | dispatchLocalStorageChange(key); |
| 90 | return valueToStore; | ||
| 91 | }); | ||
| 92 | } catch (error) { | 123 | } catch (error) { |
| 93 | console.error("[LOCALSTORAGE] Error:", error); | 124 | console.error("[LOCALSTORAGE] Error:", error); |
| 94 | } | 125 | } |
| 95 | }, | 126 | }, |
| 96 | [key, schema], | 127 | [initialValue, key, schema], |
| 97 | ); | 128 | ); |
| 98 | 129 | ||
| 99 | const prevKeyRef = useRef(key); | ||
| 100 | |||
| 101 | // Re-sync from localStorage when key changes | ||
| 102 | useEffect(() => { | ||
| 103 | if (prevKeyRef.current !== key) { | ||
| 104 | prevKeyRef.current = key; | ||
| 105 | try { | ||
| 106 | const newValue = getLocalStorageItem(key, schema); | ||
| 107 | setStoredValue(newValue ?? initialValue); | ||
| 108 | } catch (error) { | ||
| 109 | console.error("[LOCALSTORAGE] Error:", error); | ||
| 110 | } | ||
| 111 | } | ||
| 112 | }, [key, initialValue, schema]); | ||
| 113 | |||
| 114 | // Listen for storage events from other tabs AND custom events from the same tab | ||
| 115 | useEffect(() => { | ||
| 116 | const syncFromStorage = () => { | ||
| 117 | try { | ||
| 118 | const newValue = getLocalStorageItem(key, schema); | ||
| 119 | setStoredValue(newValue ?? initialValue); | ||
| 120 | } catch (error) { | ||
| 121 | console.error("[LOCALSTORAGE] Error:", error); | ||
| 122 | } | ||
| 123 | }; | ||
| 124 | |||
| 125 | const handleStorageChange = (event: StorageEvent) => { | ||
| 126 | if (event.key === key) { | ||
| 127 | syncFromStorage(); | ||
| 128 | } | ||
| 129 | }; | ||
| 130 | |||
| 131 | const handleLocalChange = (event: CustomEvent<LocalStorageChangeDetail>) => { | ||
| 132 | if (event.detail.key === key) { | ||
| 133 | syncFromStorage(); | ||
| 134 | } | ||
| 135 | }; | ||
| 136 | |||
| 137 | window.addEventListener("storage", handleStorageChange); | ||
| 138 | window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); | ||
| 139 | |||
| 140 | return () => { | ||
| 141 | window.removeEventListener("storage", handleStorageChange); | ||
| 142 | window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); | ||
| 143 | }; | ||
| 144 | }, [key, initialValue, schema]); | ||
| 145 | |||
| 146 | return [storedValue, setValue]; | 130 | return [storedValue, setValue]; |
| 147 | } | 131 | } |
apps/web/src/hooks/useResizableWidth.ts+6-15| ... | @@ -1,11 +1,5 @@ | ... | @@ -1,11 +1,5 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { | 2 | import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from "react"; |
| 3 | type PointerEvent as ReactPointerEvent, | ||
| 4 | useCallback, | ||
| 5 | useEffect, | ||
| 6 | useRef, | ||
| 7 | useState, | ||
| 8 | } from "react"; | ||
| 9 | 3 | ||
| 10 | import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; | 4 | import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; |
| 11 | 5 | ||
| ... | @@ -66,10 +60,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { | ... | @@ -66,10 +60,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 66 | } | 60 | } |
| 67 | }); | 61 | }); |
| 68 | 62 | ||
| 69 | // Re-clamp if min/max change at runtime (e.g. window resize narrows max). | 63 | const clampedWidth = clamp(width); |
| 70 | useEffect(() => { | ||
| 71 | setWidth((current) => clamp(current)); | ||
| 72 | }, [clamp]); | ||
| 73 | 64 | ||
| 74 | const dragStateRef = useRef<{ | 65 | const dragStateRef = useRef<{ |
| 75 | pointerId: number; | 66 | pointerId: number; |
| ... | @@ -114,13 +105,13 @@ export function useResizableWidth(options: UseResizableWidthOptions): { | ... | @@ -114,13 +105,13 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 114 | dragStateRef.current = { | 105 | dragStateRef.current = { |
| 115 | pointerId: event.pointerId, | 106 | pointerId: event.pointerId, |
| 116 | startX: event.clientX, | 107 | startX: event.clientX, |
| 117 | startWidth: width, | 108 | startWidth: clampedWidth, |
| 118 | pending: width, | 109 | pending: clampedWidth, |
| 119 | rafId: null, | 110 | rafId: null, |
| 120 | target, | 111 | target, |
| 121 | }; | 112 | }; |
| 122 | }, | 113 | }, |
| 123 | [width], | 114 | [clampedWidth], |
| 124 | ); | 115 | ); |
| 125 | 116 | ||
| 126 | const onPointerMove = useCallback( | 117 | const onPointerMove = useCallback( |
| ... | @@ -170,7 +161,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { | ... | @@ -170,7 +161,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 170 | ); | 161 | ); |
| 171 | 162 | ||
| 172 | return { | 163 | return { |
| 173 | width, | 164 | width: clampedWidth, |
| 174 | handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, | 165 | handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, |
| 175 | }; | 166 | }; |
| 176 | } | 167 | } |
apps/web/src/hooks/useSettings.ts+47-30| ... | @@ -10,18 +10,19 @@ | ... | @@ -10,18 +10,19 @@ |
| 10 | * store. | 10 | * store. |
| 11 | */ | 11 | */ |
| 12 | import { useCallback, useMemo, useSyncExternalStore } from "react"; | 12 | import { useCallback, useMemo, useSyncExternalStore } from "react"; |
| 13 | import { useAtomValue } from "@effect/atom-react"; | ||
| 13 | import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; | 14 | import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; |
| 14 | import { | 15 | import { |
| 15 | type ClientSettingsPatch, | 16 | type ClientSettingsPatch, |
| 16 | type ClientSettings, | 17 | type ClientSettings, |
| 17 | DEFAULT_CLIENT_SETTINGS, | 18 | DEFAULT_CLIENT_SETTINGS, |
| 18 | DEFAULT_UNIFIED_SETTINGS, | ||
| 19 | UnifiedSettings, | 19 | UnifiedSettings, |
| 20 | } from "@t3tools/contracts/settings"; | 20 | } from "@t3tools/contracts/settings"; |
| 21 | import { ensureLocalApi } from "~/localApi"; | 21 | import { ensureLocalApi } from "~/localApi"; |
| 22 | import * as Struct from "effect/Struct"; | 22 | import * as Struct from "effect/Struct"; |
| 23 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | 23 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; |
| 24 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; | 24 | import { usePrimaryEnvironment } from "~/state/environments"; |
| 25 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 25 | 26 | ||
| 26 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; | 27 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| 27 | 28 | ||
| ... | @@ -30,6 +31,7 @@ const clientSettingsHydrationListeners = new Set<() => void>(); | ... | @@ -30,6 +31,7 @@ const clientSettingsHydrationListeners = new Set<() => void>(); |
| 30 | let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | 31 | let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; |
| 31 | let clientSettingsHydrated = false; | 32 | let clientSettingsHydrated = false; |
| 32 | let clientSettingsHydrationPromise: Promise<void> | null = null; | 33 | let clientSettingsHydrationPromise: Promise<void> | null = null; |
| 34 | let clientSettingsHydrationGeneration = 0; | ||
| 33 | 35 | ||
| 34 | function emitClientSettingsChange() { | 36 | function emitClientSettingsChange() { |
| 35 | for (const listener of clientSettingsListeners) { | 37 | for (const listener of clientSettingsListeners) { |
| ... | @@ -88,16 +90,22 @@ async function hydrateClientSettings(): Promise<void> { | ... | @@ -88,16 +90,22 @@ async function hydrateClientSettings(): Promise<void> { |
| 88 | return clientSettingsHydrationPromise; | 90 | return clientSettingsHydrationPromise; |
| 89 | } | 91 | } |
| 90 | 92 | ||
| 93 | const hydrationGeneration = clientSettingsHydrationGeneration; | ||
| 91 | const nextHydration = (async () => { | 94 | const nextHydration = (async () => { |
| 92 | try { | 95 | try { |
| 93 | const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); | 96 | const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); |
| 97 | if (hydrationGeneration !== clientSettingsHydrationGeneration) { | ||
| 98 | return; | ||
| 99 | } | ||
| 94 | if (persistedSettings) { | 100 | if (persistedSettings) { |
| 95 | replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); | 101 | replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); |
| 96 | } | 102 | } |
| 97 | } catch (error) { | 103 | } catch (error) { |
| 98 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); | 104 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); |
| 99 | } finally { | 105 | } finally { |
| 100 | setClientSettingsHydrated(true); | 106 | if (hydrationGeneration === clientSettingsHydrationGeneration) { |
| 107 | setClientSettingsHydrated(true); | ||
| 108 | } | ||
| 101 | } | 109 | } |
| 102 | })(); | 110 | })(); |
| 103 | 111 | ||
| ... | @@ -168,7 +176,7 @@ export function useClientSettingsHydrated(): boolean { | ... | @@ -168,7 +176,7 @@ export function useClientSettingsHydrated(): boolean { |
| 168 | } | 176 | } |
| 169 | 177 | ||
| 170 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { | 178 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { |
| 171 | const serverSettings = useServerSettings(); | 179 | const serverSettings = useAtomValue(primaryServerSettingsAtom); |
| 172 | const clientSettings = useSyncExternalStore( | 180 | const clientSettings = useSyncExternalStore( |
| 173 | subscribeClientSettings, | 181 | subscribeClientSettings, |
| 174 | getClientSettingsSnapshot, | 182 | getClientSettingsSnapshot, |
| ... | @@ -193,40 +201,49 @@ export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) | ... | @@ -193,40 +201,49 @@ export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) |
| 193 | * persisted via RPC. Client keys go through client persistence. | 201 | * persisted via RPC. Client keys go through client persistence. |
| 194 | */ | 202 | */ |
| 195 | export function useUpdateSettings() { | 203 | export function useUpdateSettings() { |
| 196 | const updateSettings = useCallback((patch: Partial<UnifiedSettings>) => { | 204 | const persistServerSettings = useAtomCommand( |
| 197 | const { serverPatch, clientPatch } = splitPatch(patch); | 205 | serverEnvironment.updateSettings, |
| 198 | 206 | "server settings update", | |
| 199 | if (Object.keys(serverPatch).length > 0) { | 207 | ); |
| 200 | const currentServerConfig = getServerConfig(); | 208 | const primaryEnvironment = usePrimaryEnvironment(); |
| 201 | if (currentServerConfig) { | 209 | const updateSettings = useCallback( |
| 202 | applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); | 210 | (patch: Partial<UnifiedSettings>) => { |
| 211 | const { serverPatch, clientPatch } = splitPatch(patch); | ||
| 212 | |||
| 213 | if (Object.keys(serverPatch).length > 0) { | ||
| 214 | if (primaryEnvironment) { | ||
| 215 | void persistServerSettings({ | ||
| 216 | environmentId: primaryEnvironment.environmentId, | ||
| 217 | input: { patch: serverPatch }, | ||
| 218 | }); | ||
| 219 | } | ||
| 203 | } | 220 | } |
| 204 | // Fire-and-forget RPC — push will reconcile on success | ||
| 205 | void ensureLocalApi().server.updateSettings(serverPatch); | ||
| 206 | } | ||
| 207 | |||
| 208 | if (Object.keys(clientPatch).length > 0) { | ||
| 209 | persistClientSettings({ | ||
| 210 | ...getClientSettingsSnapshot(), | ||
| 211 | ...clientPatch, | ||
| 212 | }); | ||
| 213 | } | ||
| 214 | }, []); | ||
| 215 | 221 | ||
| 216 | const resetSettings = useCallback(() => { | 222 | if (Object.keys(clientPatch).length > 0) { |
| 217 | updateSettings(DEFAULT_UNIFIED_SETTINGS); | 223 | persistClientSettings({ |
| 218 | }, [updateSettings]); | 224 | ...getClientSettingsSnapshot(), |
| 225 | ...clientPatch, | ||
| 226 | }); | ||
| 227 | } | ||
| 228 | }, | ||
| 229 | [persistServerSettings, primaryEnvironment], | ||
| 230 | ); | ||
| 219 | 231 | ||
| 220 | return { | 232 | return updateSettings; |
| 221 | updateSettings, | ||
| 222 | resetSettings, | ||
| 223 | }; | ||
| 224 | } | 233 | } |
| 225 | 234 | ||
| 226 | export function __resetClientSettingsPersistenceForTests(): void { | 235 | export function __resetClientSettingsPersistenceForTests(): void { |
| 236 | clientSettingsHydrationGeneration += 1; | ||
| 227 | clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | 237 | clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; |
| 228 | clientSettingsHydrated = false; | 238 | clientSettingsHydrated = false; |
| 229 | clientSettingsHydrationPromise = null; | 239 | clientSettingsHydrationPromise = null; |
| 230 | clientSettingsListeners.clear(); | 240 | clientSettingsListeners.clear(); |
| 231 | clientSettingsHydrationListeners.clear(); | 241 | clientSettingsHydrationListeners.clear(); |
| 232 | } | 242 | } |
| 243 | |||
| 244 | export function __setClientSettingsForTests(settings: ClientSettings): void { | ||
| 245 | clientSettingsHydrationGeneration += 1; | ||
| 246 | clientSettingsSnapshot = settings; | ||
| 247 | clientSettingsHydrated = true; | ||
| 248 | clientSettingsHydrationPromise = null; | ||
| 249 | } |
apps/web/src/hooks/useThreadActions.ts+197-113| ... | @@ -1,29 +1,54 @@ | ... | @@ -1,29 +1,54 @@ |
| 1 | import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { |
| 2 | parseScopedThreadKey, | ||
| 3 | scopeProjectRef, | ||
| 4 | scopeThreadRef, | ||
| 5 | } from "@t3tools/client-runtime/environment"; | ||
| 6 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 7 | import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 8 | import * as Cause from "effect/Cause"; | ||
| 9 | import * as Data from "effect/Data"; | ||
| 10 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | import { useRouter } from "@tanstack/react-router"; | 11 | import { useRouter } from "@tanstack/react-router"; |
| 4 | import { useCallback, useRef } from "react"; | 12 | import { useCallback, useMemo, useRef } from "react"; |
| 5 | 13 | ||
| 6 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; | 14 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; |
| 7 | import { useComposerDraftStore } from "../composerDraftStore"; | 15 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 16 | import { terminalEnvironment } from "../state/terminal"; | ||
| 17 | import { threadEnvironment } from "../state/threads"; | ||
| 18 | import { vcsEnvironment } from "../state/vcs"; | ||
| 8 | import { useNewThreadHandler } from "./useHandleNewThread"; | 19 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 9 | import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; | ||
| 10 | import { invalidateSourceControlState } from "../lib/sourceControlActions"; | ||
| 11 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; | 20 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; |
| 12 | import { newCommandId } from "../lib/utils"; | ||
| 13 | import { readLocalApi } from "../localApi"; | 21 | import { readLocalApi } from "../localApi"; |
| 14 | import { | 22 | import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; |
| 15 | selectProjectByRef, | ||
| 16 | selectThreadByRef, | ||
| 17 | selectThreadsForEnvironment, | ||
| 18 | useStore, | ||
| 19 | } from "../store"; | ||
| 20 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; | 23 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 21 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | 24 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 22 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 25 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 23 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | 26 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 24 | import { useSettings } from "./useSettings"; | 27 | import { useSettings } from "./useSettings"; |
| 28 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 29 | |||
| 30 | export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBlockedError")<{ | ||
| 31 | readonly message: string; | ||
| 32 | }> {} | ||
| 25 | 33 | ||
| 26 | export function useThreadActions() { | 34 | export function useThreadActions() { |
| 35 | const closeTerminal = useAtomCommand(terminalEnvironment.close); | ||
| 36 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { | ||
| 37 | reportFailure: false, | ||
| 38 | }); | ||
| 39 | const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, { | ||
| 40 | reportFailure: false, | ||
| 41 | }); | ||
| 42 | const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { | ||
| 43 | reportFailure: false, | ||
| 44 | }); | ||
| 45 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); | ||
| 46 | const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { | ||
| 47 | reportFailure: false, | ||
| 48 | }); | ||
| 49 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { | ||
| 50 | reportFailure: false, | ||
| 51 | }); | ||
| 27 | const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); | 52 | const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); |
| 28 | const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); | 53 | const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); |
| 29 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); | 54 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| ... | @@ -32,7 +57,7 @@ export function useThreadActions() { | ... | @@ -32,7 +57,7 @@ export function useThreadActions() { |
| 32 | ); | 57 | ); |
| 33 | const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); | 58 | const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); |
| 34 | const router = useRouter(); | 59 | const router = useRouter(); |
| 35 | const { handleNewThread } = useNewThreadHandler(); | 60 | const handleNewThread = useNewThreadHandler(); |
| 36 | // Keep a ref so archiveThread can call handleNewThread without appearing in | 61 | // Keep a ref so archiveThread can call handleNewThread without appearing in |
| 37 | // its dependency array — handleNewThread is inherently unstable (depends on | 62 | // its dependency array — handleNewThread is inherently unstable (depends on |
| 38 | // the projects list) and would otherwise cascade new references into every | 63 | // the projects list) and would otherwise cascade new references into every |
| ... | @@ -41,8 +66,7 @@ export function useThreadActions() { | ... | @@ -41,8 +66,7 @@ export function useThreadActions() { |
| 41 | handleNewThreadRef.current = handleNewThread; | 66 | handleNewThreadRef.current = handleNewThread; |
| 42 | 67 | ||
| 43 | const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { | 68 | const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { |
| 44 | const state = useStore.getState(); | 69 | const thread = readThreadShell(target); |
| 45 | const thread = selectThreadByRef(state, target); | ||
| 46 | if (!thread) { | 70 | if (!thread) { |
| 47 | return null; | 71 | return null; |
| 48 | } | 72 | } |
| ... | @@ -58,65 +82,82 @@ export function useThreadActions() { | ... | @@ -58,65 +82,82 @@ export function useThreadActions() { |
| 58 | 82 | ||
| 59 | const archiveThread = useCallback( | 83 | const archiveThread = useCallback( |
| 60 | async (target: ScopedThreadRef) => { | 84 | async (target: ScopedThreadRef) => { |
| 61 | const api = readEnvironmentApi(target.environmentId); | ||
| 62 | if (!api) return; | ||
| 63 | const resolved = resolveThreadTarget(target); | 85 | const resolved = resolveThreadTarget(target); |
| 64 | if (!resolved) return; | 86 | if (!resolved) return AsyncResult.success(undefined); |
| 65 | const { thread, threadRef } = resolved; | 87 | const { thread, threadRef } = resolved; |
| 66 | if (thread.session?.status === "running" && thread.session.activeTurnId != null) { | 88 | if (thread.session?.status === "running" && thread.session.activeTurnId != null) { |
| 67 | throw new Error("Cannot archive a running thread."); | 89 | return AsyncResult.failure( |
| 90 | Cause.fail( | ||
| 91 | new ThreadArchiveBlockedError({ | ||
| 92 | message: "Cannot archive a running thread.", | ||
| 93 | }), | ||
| 94 | ), | ||
| 95 | ); | ||
| 68 | } | 96 | } |
| 69 | 97 | ||
| 70 | const currentRouteThreadRef = getCurrentRouteThreadRef(); | 98 | const currentRouteThreadRef = getCurrentRouteThreadRef(); |
| 71 | const shouldNavigateToDraft = | 99 | const shouldNavigateToDraft = |
| 72 | currentRouteThreadRef?.threadId === threadRef.threadId && | 100 | currentRouteThreadRef?.threadId === threadRef.threadId && |
| 73 | currentRouteThreadRef.environmentId === threadRef.environmentId; | 101 | currentRouteThreadRef.environmentId === threadRef.environmentId; |
| 74 | const archiveCommand = api.orchestration.dispatchCommand({ | 102 | const archiveResult = await archiveThreadMutation({ |
| 75 | type: "thread.archive", | 103 | environmentId: threadRef.environmentId, |
| 76 | commandId: newCommandId(), | 104 | input: { threadId: threadRef.threadId }, |
| 77 | threadId: threadRef.threadId, | ||
| 78 | }); | 105 | }); |
| 106 | if (archiveResult._tag === "Failure") { | ||
| 107 | return archiveResult; | ||
| 108 | } | ||
| 79 | 109 | ||
| 80 | if (shouldNavigateToDraft) { | 110 | if (shouldNavigateToDraft) { |
| 81 | await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); | 111 | const navigationResult = await settlePromise(() => |
| 112 | handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), | ||
| 113 | ); | ||
| 114 | if (navigationResult._tag === "Failure") { | ||
| 115 | return navigationResult; | ||
| 116 | } | ||
| 117 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | ||
| 118 | return archiveResult; | ||
| 82 | } | 119 | } |
| 83 | 120 | ||
| 84 | await archiveCommand; | ||
| 85 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | 121 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); |
| 122 | return archiveResult; | ||
| 86 | }, | 123 | }, |
| 87 | [getCurrentRouteThreadRef, resolveThreadTarget], | 124 | [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], |
| 88 | ); | 125 | ); |
| 89 | 126 | ||
| 90 | const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { | 127 | const unarchiveThread = useCallback( |
| 91 | const api = readEnvironmentApi(target.environmentId); | 128 | async (target: ScopedThreadRef) => { |
| 92 | if (!api) return; | 129 | const result = await unarchiveThreadMutation({ |
| 93 | await api.orchestration.dispatchCommand({ | 130 | environmentId: target.environmentId, |
| 94 | type: "thread.unarchive", | 131 | input: { threadId: target.threadId }, |
| 95 | commandId: newCommandId(), | 132 | }); |
| 96 | threadId: target.threadId, | 133 | if (result._tag === "Success") { |
| 97 | }); | 134 | refreshArchivedThreadsForEnvironment(target.environmentId); |
| 98 | refreshArchivedThreadsForEnvironment(target.environmentId); | 135 | } |
| 99 | }, []); | 136 | return result; |
| 137 | }, | ||
| 138 | [unarchiveThreadMutation], | ||
| 139 | ); | ||
| 100 | 140 | ||
| 101 | const deleteThread = useCallback( | 141 | const deleteThread = useCallback( |
| 102 | async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => { | 142 | async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => { |
| 103 | const api = readEnvironmentApi(target.environmentId); | ||
| 104 | if (!api) return; | ||
| 105 | const resolved = resolveThreadTarget(target); | 143 | const resolved = resolveThreadTarget(target); |
| 106 | if (!resolved) { | 144 | if (!resolved) { |
| 107 | // Thread not in main store (e.g. archived thread) — dispatch delete directly. | 145 | // Thread not in main store (e.g. archived thread) — dispatch delete directly. |
| 108 | await api.orchestration.dispatchCommand({ | 146 | const result = await deleteThreadMutation({ |
| 109 | type: "thread.delete", | 147 | environmentId: target.environmentId, |
| 110 | commandId: newCommandId(), | 148 | input: { threadId: target.threadId }, |
| 111 | threadId: target.threadId, | ||
| 112 | }); | 149 | }); |
| 113 | refreshArchivedThreadsForEnvironment(target.environmentId); | 150 | if (result._tag === "Success") { |
| 114 | return; | 151 | refreshArchivedThreadsForEnvironment(target.environmentId); |
| 152 | } | ||
| 153 | return result; | ||
| 115 | } | 154 | } |
| 116 | const { thread, threadRef } = resolved; | 155 | const { thread, threadRef } = resolved; |
| 117 | const state = useStore.getState(); | 156 | const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { |
| 118 | const threads = selectThreadsForEnvironment(state, threadRef.environmentId); | 157 | const shell = readThreadShell(ref); |
| 119 | const threadProject = selectProjectByRef(state, { | 158 | return shell === null ? [] : [shell]; |
| 159 | }); | ||
| 160 | const threadProject = readProject({ | ||
| 120 | environmentId: threadRef.environmentId, | 161 | environmentId: threadRef.environmentId, |
| 121 | projectId: thread.projectId, | 162 | projectId: thread.projectId, |
| 122 | }); | 163 | }); |
| ... | @@ -140,37 +181,38 @@ export function useThreadActions() { | ... | @@ -140,37 +181,38 @@ export function useThreadActions() { |
| 140 | const displayWorktreePath = orphanedWorktreePath | 181 | const displayWorktreePath = orphanedWorktreePath |
| 141 | ? formatWorktreePathForDisplay(orphanedWorktreePath) | 182 | ? formatWorktreePathForDisplay(orphanedWorktreePath) |
| 142 | : null; | 183 | : null; |
| 143 | const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; | 184 | const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; |
| 144 | const localApi = readLocalApi(); | 185 | const localApi = readLocalApi(); |
| 145 | const shouldDeleteWorktree = | 186 | let shouldDeleteWorktree = false; |
| 146 | canDeleteWorktree && | 187 | if (canDeleteWorktree && localApi) { |
| 147 | localApi && | 188 | const confirmationResult = await settlePromise(() => |
| 148 | (await localApi.dialogs.confirm( | 189 | localApi.dialogs.confirm( |
| 149 | [ | 190 | [ |
| 150 | "This thread is the only one linked to this worktree:", | 191 | "This thread is the only one linked to this worktree:", |
| 151 | displayWorktreePath ?? orphanedWorktreePath, | 192 | displayWorktreePath ?? orphanedWorktreePath, |
| 152 | "", | 193 | "", |
| 153 | "Delete the worktree too?", | 194 | "Delete the worktree too?", |
| 154 | ].join("\n"), | 195 | ].join("\n"), |
| 155 | )); | 196 | ), |
| 156 | 197 | ); | |
| 157 | if (thread.session && thread.session.status !== "closed") { | 198 | if (confirmationResult._tag === "Failure") { |
| 158 | await api.orchestration | 199 | return confirmationResult; |
| 159 | .dispatchCommand({ | 200 | } |
| 160 | type: "thread.session.stop", | 201 | shouldDeleteWorktree = confirmationResult.value; |
| 161 | commandId: newCommandId(), | ||
| 162 | threadId: threadRef.threadId, | ||
| 163 | createdAt: new Date().toISOString(), | ||
| 164 | }) | ||
| 165 | .catch(() => undefined); | ||
| 166 | } | 202 | } |
| 167 | 203 | ||
| 168 | try { | 204 | if (thread.session && thread.session.status !== "stopped") { |
| 169 | await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); | 205 | await stopThreadSession({ |
| 170 | } catch { | 206 | environmentId: threadRef.environmentId, |
| 171 | // Terminal may already be closed. | 207 | input: { threadId: threadRef.threadId }, |
| 208 | }); | ||
| 172 | } | 209 | } |
| 173 | 210 | ||
| 211 | await closeTerminal({ | ||
| 212 | environmentId: threadRef.environmentId, | ||
| 213 | input: { threadId: threadRef.threadId, deleteHistory: true }, | ||
| 214 | }); | ||
| 215 | |||
| 174 | const deletedThreadIds = deletedIds ?? new Set<ThreadId>(); | 216 | const deletedThreadIds = deletedIds ?? new Set<ThreadId>(); |
| 175 | const currentRouteThreadRef = getCurrentRouteThreadRef(); | 217 | const currentRouteThreadRef = getCurrentRouteThreadRef(); |
| 176 | const shouldNavigateToFallback = | 218 | const shouldNavigateToFallback = |
| ... | @@ -182,11 +224,13 @@ export function useThreadActions() { | ... | @@ -182,11 +224,13 @@ export function useThreadActions() { |
| 182 | deletedThreadIds, | 224 | deletedThreadIds, |
| 183 | sortOrder: sidebarThreadSortOrder, | 225 | sortOrder: sidebarThreadSortOrder, |
| 184 | }); | 226 | }); |
| 185 | await api.orchestration.dispatchCommand({ | 227 | const deleteResult = await deleteThreadMutation({ |
| 186 | type: "thread.delete", | 228 | environmentId: threadRef.environmentId, |
| 187 | commandId: newCommandId(), | 229 | input: { threadId: threadRef.threadId }, |
| 188 | threadId: threadRef.threadId, | ||
| 189 | }); | 230 | }); |
| 231 | if (deleteResult._tag === "Failure") { | ||
| 232 | return deleteResult; | ||
| 233 | } | ||
| 190 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | 234 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); |
| 191 | clearComposerDraftForThread(threadRef); | 235 | clearComposerDraftForThread(threadRef); |
| 192 | clearProjectDraftThreadById( | 236 | clearProjectDraftThreadById( |
| ... | @@ -197,44 +241,71 @@ export function useThreadActions() { | ... | @@ -197,44 +241,71 @@ export function useThreadActions() { |
| 197 | 241 | ||
| 198 | if (shouldNavigateToFallback) { | 242 | if (shouldNavigateToFallback) { |
| 199 | if (fallbackThreadId) { | 243 | if (fallbackThreadId) { |
| 200 | const fallbackThread = selectThreadByRef( | 244 | const fallbackThread = readThreadShell( |
| 201 | useStore.getState(), | ||
| 202 | scopeThreadRef(threadRef.environmentId, fallbackThreadId), | 245 | scopeThreadRef(threadRef.environmentId, fallbackThreadId), |
| 203 | ); | 246 | ); |
| 204 | if (fallbackThread) { | 247 | if (fallbackThread) { |
| 205 | await router.navigate({ | 248 | const navigationResult = await settlePromise(() => |
| 206 | to: "/$environmentId/$threadId", | 249 | router.navigate({ |
| 207 | params: buildThreadRouteParams( | 250 | to: "/$environmentId/$threadId", |
| 208 | scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), | 251 | params: buildThreadRouteParams( |
| 209 | ), | 252 | scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), |
| 210 | replace: true, | 253 | ), |
| 211 | }); | 254 | replace: true, |
| 255 | }), | ||
| 256 | ); | ||
| 257 | if (navigationResult._tag === "Failure") { | ||
| 258 | return navigationResult; | ||
| 259 | } | ||
| 212 | } else { | 260 | } else { |
| 213 | await router.navigate({ to: "/", replace: true }); | 261 | const navigationResult = await settlePromise(() => |
| 262 | router.navigate({ to: "/", replace: true }), | ||
| 263 | ); | ||
| 264 | if (navigationResult._tag === "Failure") { | ||
| 265 | return navigationResult; | ||
| 266 | } | ||
| 214 | } | 267 | } |
| 215 | } else { | 268 | } else { |
| 216 | await router.navigate({ to: "/", replace: true }); | 269 | const navigationResult = await settlePromise(() => |
| 270 | router.navigate({ to: "/", replace: true }), | ||
| 271 | ); | ||
| 272 | if (navigationResult._tag === "Failure") { | ||
| 273 | return navigationResult; | ||
| 274 | } | ||
| 217 | } | 275 | } |
| 218 | } | 276 | } |
| 219 | 277 | ||
| 220 | if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { | 278 | if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { |
| 221 | return; | 279 | return deleteResult; |
| 222 | } | 280 | } |
| 223 | 281 | ||
| 224 | try { | 282 | const removeResult = await removeWorktree({ |
| 225 | await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({ | 283 | environmentId: threadRef.environmentId, |
| 226 | cwd: threadProject.cwd, | 284 | input: { |
| 285 | cwd: threadProject.workspaceRoot, | ||
| 227 | path: orphanedWorktreePath, | 286 | path: orphanedWorktreePath, |
| 228 | force: true, | 287 | force: true, |
| 229 | }); | 288 | }, |
| 230 | await invalidateSourceControlState({ | 289 | }); |
| 231 | environmentId: threadRef.environmentId, | 290 | const refreshResult = |
| 232 | }); | 291 | removeResult._tag === "Success" |
| 233 | } catch (error) { | 292 | ? await refreshVcsStatus({ |
| 293 | environmentId: threadRef.environmentId, | ||
| 294 | input: { cwd: threadProject.workspaceRoot }, | ||
| 295 | }) | ||
| 296 | : null; | ||
| 297 | const cleanupFailure = | ||
| 298 | removeResult._tag === "Failure" | ||
| 299 | ? removeResult | ||
| 300 | : refreshResult?._tag === "Failure" | ||
| 301 | ? refreshResult | ||
| 302 | : null; | ||
| 303 | if (cleanupFailure) { | ||
| 304 | const error = squashAtomCommandFailure(cleanupFailure); | ||
| 234 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; | 305 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; |
| 235 | console.error("Failed to remove orphaned worktree after thread deletion", { | 306 | console.error("Failed to remove orphaned worktree after thread deletion", { |
| 236 | threadId: threadRef.threadId, | 307 | threadId: threadRef.threadId, |
| 237 | projectCwd: threadProject.cwd, | 308 | projectCwd: threadProject.workspaceRoot, |
| 238 | worktreePath: orphanedWorktreePath, | 309 | worktreePath: orphanedWorktreePath, |
| 239 | error, | 310 | error, |
| 240 | }); | 311 | }); |
| ... | @@ -245,48 +316,61 @@ export function useThreadActions() { | ... | @@ -245,48 +316,61 @@ export function useThreadActions() { |
| 245 | description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, | 316 | description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, |
| 246 | }), | 317 | }), |
| 247 | ); | 318 | ); |
| 319 | return cleanupFailure; | ||
| 248 | } | 320 | } |
| 321 | return deleteResult; | ||
| 249 | }, | 322 | }, |
| 250 | [ | 323 | [ |
| 251 | clearComposerDraftForThread, | 324 | clearComposerDraftForThread, |
| 252 | clearProjectDraftThreadById, | 325 | clearProjectDraftThreadById, |
| 253 | clearTerminalUiState, | 326 | clearTerminalUiState, |
| 327 | closeTerminal, | ||
| 328 | deleteThreadMutation, | ||
| 254 | getCurrentRouteThreadRef, | 329 | getCurrentRouteThreadRef, |
| 330 | refreshVcsStatus, | ||
| 331 | removeWorktree, | ||
| 255 | router, | 332 | router, |
| 256 | resolveThreadTarget, | 333 | resolveThreadTarget, |
| 257 | sidebarThreadSortOrder, | 334 | sidebarThreadSortOrder, |
| 335 | stopThreadSession, | ||
| 258 | ], | 336 | ], |
| 259 | ); | 337 | ); |
| 260 | 338 | ||
| 261 | const confirmAndDeleteThread = useCallback( | 339 | const confirmAndDeleteThread = useCallback( |
| 262 | async (target: ScopedThreadRef) => { | 340 | async (target: ScopedThreadRef) => { |
| 263 | const api = readEnvironmentApi(target.environmentId); | ||
| 264 | if (!api) return; | ||
| 265 | const localApi = readLocalApi(); | 341 | const localApi = readLocalApi(); |
| 266 | const resolved = resolveThreadTarget(target); | 342 | const resolved = resolveThreadTarget(target); |
| 267 | 343 | ||
| 268 | if (confirmThreadDelete && localApi) { | 344 | if (confirmThreadDelete && localApi) { |
| 269 | const title = resolved?.thread.title ?? "this thread"; | 345 | const title = resolved?.thread.title ?? "this thread"; |
| 270 | const confirmed = await localApi.dialogs.confirm( | 346 | const confirmationResult = await settlePromise(() => |
| 271 | [ | 347 | localApi.dialogs.confirm( |
| 272 | `Delete thread "${title}"?`, | 348 | [ |
| 273 | "This permanently clears conversation history for this thread.", | 349 | `Delete thread "${title}"?`, |
| 274 | ].join("\n"), | 350 | "This permanently clears conversation history for this thread.", |
| 351 | ].join("\n"), | ||
| 352 | ), | ||
| 275 | ); | 353 | ); |
| 276 | if (!confirmed) { | 354 | if (confirmationResult._tag === "Failure") { |
| 277 | return; | 355 | return confirmationResult; |
| 356 | } | ||
| 357 | if (!confirmationResult.value) { | ||
| 358 | return AsyncResult.success(undefined); | ||
| 278 | } | 359 | } |
| 279 | } | 360 | } |
| 280 | 361 | ||
| 281 | await deleteThread(target); | 362 | return deleteThread(target); |
| 282 | }, | 363 | }, |
| 283 | [confirmThreadDelete, deleteThread, resolveThreadTarget], | 364 | [confirmThreadDelete, deleteThread, resolveThreadTarget], |
| 284 | ); | 365 | ); |
| 285 | 366 | ||
| 286 | return { | 367 | return useMemo( |
| 287 | archiveThread, | 368 | () => ({ |
| 288 | unarchiveThread, | 369 | archiveThread, |
| 289 | deleteThread, | 370 | unarchiveThread, |
| 290 | confirmAndDeleteThread, | 371 | deleteThread, |
| 291 | }; | 372 | confirmAndDeleteThread, |
| 373 | }), | ||
| 374 | [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], | ||
| 375 | ); | ||
| 292 | } | 376 | } |
apps/web/src/hooks/useTurnDiffSummaries.ts+4-4| ... | @@ -1,13 +1,13 @@ | ... | @@ -1,13 +1,13 @@ |
| 1 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | import { inferCheckpointTurnCountByTurnId } from "../session-logic"; | 2 | import { inferCheckpointTurnCountByTurnId } from "../session-logic"; |
| 3 | import type { Thread } from "../types"; | 3 | import type { Thread, TurnDiffSummary } from "../types"; |
| 4 | 4 | ||
| 5 | export function useTurnDiffSummaries(activeThread: Thread | undefined) { | 5 | export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { |
| 6 | const turnDiffSummaries = useMemo(() => { | 6 | const turnDiffSummaries = useMemo<ReadonlyArray<TurnDiffSummary>>(() => { |
| 7 | if (!activeThread) { | 7 | if (!activeThread) { |
| 8 | return []; | 8 | return []; |
| 9 | } | 9 | } |
| 10 | return activeThread.turnDiffSummaries; | 10 | return activeThread.checkpoints; |
| 11 | }, [activeThread]); | 11 | }, [activeThread]); |
| 12 | 12 | ||
| 13 | const inferredCheckpointTurnCountByTurnId = useMemo( | 13 | const inferredCheckpointTurnCountByTurnId = useMemo( |
apps/web/src/lib/archivedThreadsState.ts+53-13| ... | @@ -1,23 +1,62 @@ | ... | @@ -1,23 +1,62 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 3 | type ArchivedSnapshotEntry, | 3 | type ArchivedSnapshotEntry, |
| 4 | createArchivedThreadsManager, | ||
| 5 | makeArchivedThreadsEnvironmentKey, | 4 | makeArchivedThreadsEnvironmentKey, |
| 6 | readArchivedThreadsSnapshotState, | 5 | parseArchivedThreadsEnvironmentKey, |
| 7 | } from "@t3tools/client-runtime"; | 6 | } from "@t3tools/client-runtime/state/threads"; |
| 8 | import type { EnvironmentId } from "@t3tools/contracts"; | 7 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 8 | import * as Cause from "effect/Cause"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 9 | import { useCallback, useMemo } from "react"; | 11 | import { useCallback, useMemo } from "react"; |
| 10 | 12 | ||
| 11 | import { readEnvironmentApi } from "../environmentApi"; | 13 | import { orchestrationEnvironment } from "../state/orchestration"; |
| 12 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 14 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 13 | 15 | ||
| 14 | const archivedThreadsManager = createArchivedThreadsManager({ | 16 | const archivedSnapshotsAtom = Atom.family((environmentKey: string) => |
| 15 | getRegistry: () => appAtomRegistry, | 17 | Atom.make((get) => { |
| 16 | getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, | 18 | const snapshots: ArchivedSnapshotEntry[] = []; |
| 17 | }); | 19 | let error: string | null = null; |
| 20 | let isLoading = false; | ||
| 21 | |||
| 22 | for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) { | ||
| 23 | const result = get( | ||
| 24 | orchestrationEnvironment.archivedShellSnapshot({ | ||
| 25 | environmentId, | ||
| 26 | input: {}, | ||
| 27 | }), | ||
| 28 | ); | ||
| 29 | isLoading ||= result.waiting; | ||
| 30 | const snapshot = Option.getOrNull(AsyncResult.value(result)); | ||
| 31 | if (snapshot !== null) { | ||
| 32 | snapshots.push({ environmentId, snapshot }); | ||
| 33 | } | ||
| 34 | if (error === null && result._tag === "Failure") { | ||
| 35 | const cause = Cause.squash(result.cause); | ||
| 36 | error = | ||
| 37 | cause instanceof Error && cause.message.trim().length > 0 | ||
| 38 | ? cause.message | ||
| 39 | : "Failed to load archived threads."; | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | return { | ||
| 44 | snapshots, | ||
| 45 | error, | ||
| 46 | isLoading, | ||
| 47 | }; | ||
| 48 | }).pipe(Atom.withLabel(`web:archived-thread-snapshots:${environmentKey}`)), | ||
| 49 | ); | ||
| 50 | |||
| 51 | function archivedSnapshotAtom(environmentId: EnvironmentId) { | ||
| 52 | return orchestrationEnvironment.archivedShellSnapshot({ | ||
| 53 | environmentId, | ||
| 54 | input: {}, | ||
| 55 | }); | ||
| 56 | } | ||
| 18 | 57 | ||
| 19 | export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { | 58 | export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { |
| 20 | archivedThreadsManager.refreshForEnvironment(environmentId); | 59 | appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); |
| 21 | } | 60 | } |
| 22 | 61 | ||
| 23 | export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): { | 62 | export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): { |
| ... | @@ -30,14 +69,15 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<Environ | ... | @@ -30,14 +69,15 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<Environ |
| 30 | () => makeArchivedThreadsEnvironmentKey(environmentIds), | 69 | () => makeArchivedThreadsEnvironmentKey(environmentIds), |
| 31 | [environmentIds], | 70 | [environmentIds], |
| 32 | ); | 71 | ); |
| 33 | const atom = archivedThreadsManager.getAtom(environmentKey); | 72 | const result = useAtomValue(archivedSnapshotsAtom(environmentKey)); |
| 34 | const result = useAtomValue(atom); | ||
| 35 | const refresh = useCallback(() => { | 73 | const refresh = useCallback(() => { |
| 36 | archivedThreadsManager.refresh(environmentIds); | 74 | for (const environmentId of environmentIds) { |
| 75 | appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); | ||
| 76 | } | ||
| 37 | }, [environmentIds]); | 77 | }, [environmentIds]); |
| 38 | 78 | ||
| 39 | return { | 79 | return { |
| 40 | ...readArchivedThreadsSnapshotState(result), | 80 | ...result, |
| 41 | refresh, | 81 | refresh, |
| 42 | }; | 82 | }; |
| 43 | } | 83 | } |
apps/web/src/lib/chatThreadActions.test.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | 3 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 4 | import { | 4 | import { |
apps/web/src/lib/chatThreadActions.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts"; |
| 3 | import type { DraftThreadEnvMode } from "../composerDraftStore"; | 3 | import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 4 | 4 |
apps/web/src/lib/checkpointDiffState.ts+8-53| ... | @@ -1,63 +1,18 @@ | ... | @@ -1,63 +1,18 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | 1 | import { |
| 3 | type CheckpointDiffState, | 2 | type CheckpointDiffState, |
| 4 | type CheckpointDiffTarget, | 3 | type CheckpointDiffTarget, |
| 5 | checkpointDiffStateAtom, | 4 | } from "@t3tools/client-runtime/state/threads"; |
| 6 | createCheckpointDiffManager, | ||
| 7 | EMPTY_CHECKPOINT_DIFF_ATOM, | ||
| 8 | EMPTY_CHECKPOINT_DIFF_STATE, | ||
| 9 | getCheckpointDiffTargetKey, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useEffect, useMemo } from "react"; | ||
| 12 | 5 | ||
| 13 | import { readEnvironmentApi } from "../environmentApi"; | 6 | import { useCheckpointDiff as useCheckpointDiffQuery } from "../state/queries"; |
| 14 | import { subscribeProviderInvalidations } from "../environments/runtime"; | ||
| 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 16 | |||
| 17 | const checkpointDiffManager = createCheckpointDiffManager({ | ||
| 18 | getRegistry: () => appAtomRegistry, | ||
| 19 | getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, | ||
| 20 | }); | ||
| 21 | |||
| 22 | export function invalidateCheckpointDiffs(): void { | ||
| 23 | checkpointDiffManager.invalidate(); | ||
| 24 | } | ||
| 25 | |||
| 26 | subscribeProviderInvalidations(invalidateCheckpointDiffs); | ||
| 27 | 7 | ||
| 28 | export function useCheckpointDiff( | 8 | export function useCheckpointDiff( |
| 29 | target: CheckpointDiffTarget, | 9 | target: CheckpointDiffTarget, |
| 30 | options?: { readonly enabled?: boolean }, | 10 | options?: { readonly enabled?: boolean }, |
| 31 | ): CheckpointDiffState { | 11 | ): CheckpointDiffState { |
| 32 | const stableTarget = useMemo( | 12 | const state = useCheckpointDiffQuery(target, options); |
| 33 | () => ({ | 13 | return { |
| 34 | environmentId: target.environmentId, | 14 | data: state.data, |
| 35 | threadId: target.threadId, | 15 | error: state.error, |
| 36 | fromTurnCount: target.fromTurnCount, | 16 | isPending: state.isPending, |
| 37 | toTurnCount: target.toTurnCount, | 17 | }; |
| 38 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 39 | cacheScope: target.cacheScope ?? null, | ||
| 40 | }), | ||
| 41 | [ | ||
| 42 | target.cacheScope, | ||
| 43 | target.environmentId, | ||
| 44 | target.fromTurnCount, | ||
| 45 | target.ignoreWhitespace, | ||
| 46 | target.threadId, | ||
| 47 | target.toTurnCount, | ||
| 48 | ], | ||
| 49 | ); | ||
| 50 | const targetKey = getCheckpointDiffTargetKey(stableTarget); | ||
| 51 | |||
| 52 | useEffect(() => { | ||
| 53 | if (targetKey === null || options?.enabled === false) { | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | void checkpointDiffManager.load(stableTarget); | ||
| 57 | }, [options?.enabled, stableTarget, targetKey]); | ||
| 58 | |||
| 59 | const state = useAtomValue( | ||
| 60 | targetKey !== null ? checkpointDiffStateAtom(targetKey) : EMPTY_CHECKPOINT_DIFF_ATOM, | ||
| 61 | ); | ||
| 62 | return targetKey === null || options?.enabled === false ? EMPTY_CHECKPOINT_DIFF_STATE : state; | ||
| 63 | } | 18 | } |
apps/web/src/lib/composerPathSearchState.ts+11-50| ... | @@ -1,57 +1,18 @@ | ... | @@ -1,57 +1,18 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | 1 | import { |
| 3 | type ComposerPathSearchState, | 2 | type ComposerPathSearchState, |
| 4 | type ComposerPathSearchTarget, | 3 | type ComposerPathSearchTarget, |
| 5 | EMPTY_COMPOSER_PATH_SEARCH_ATOM, | 4 | } from "@t3tools/client-runtime/state/threads"; |
| 6 | EMPTY_COMPOSER_PATH_SEARCH_STATE, | ||
| 7 | composerPathSearchStateAtom, | ||
| 8 | createComposerPathSearchManager, | ||
| 9 | getComposerPathSearchTargetKey, | ||
| 10 | normalizeComposerPathSearchQuery, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | import { useEffect, useMemo } from "react"; | ||
| 13 | 5 | ||
| 14 | import { | 6 | import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; |
| 15 | readEnvironmentConnection, | ||
| 16 | subscribeEnvironmentConnections, | ||
| 17 | subscribeProviderInvalidations, | ||
| 18 | } from "../environments/runtime"; | ||
| 19 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 20 | |||
| 21 | const COMPOSER_PATH_SEARCH_LIMIT = 80; | ||
| 22 | const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; | ||
| 23 | const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; | ||
| 24 | |||
| 25 | const composerPathSearchManager = createComposerPathSearchManager({ | ||
| 26 | getRegistry: () => appAtomRegistry, | ||
| 27 | getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.projects ?? null, | ||
| 28 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 29 | limit: COMPOSER_PATH_SEARCH_LIMIT, | ||
| 30 | debounceMs: COMPOSER_PATH_SEARCH_DEBOUNCE_MS, | ||
| 31 | staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, | ||
| 32 | }); | ||
| 33 | |||
| 34 | export function invalidateComposerPathSearches(): void { | ||
| 35 | composerPathSearchManager.invalidate(); | ||
| 36 | } | ||
| 37 | |||
| 38 | subscribeProviderInvalidations(invalidateComposerPathSearches); | ||
| 39 | 7 | ||
| 40 | export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { | 8 | export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { |
| 41 | const stableTarget = useMemo( | 9 | const state = useComposerPathSearchQuery(target); |
| 42 | () => ({ | 10 | return { |
| 43 | environmentId: target.environmentId, | 11 | entries: state.entries.map((entry) => ({ |
| 44 | cwd: target.cwd, | 12 | path: entry.path, |
| 45 | query: normalizeComposerPathSearchQuery(target.query), | 13 | kind: entry.kind, |
| 46 | }), | 14 | })), |
| 47 | [target.cwd, target.environmentId, target.query], | 15 | error: state.error, |
| 48 | ); | 16 | isPending: state.isPending, |
| 49 | const targetKey = getComposerPathSearchTargetKey(stableTarget); | 17 | }; |
| 50 | |||
| 51 | useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); | ||
| 52 | |||
| 53 | const state = useAtomValue( | ||
| 54 | targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, | ||
| 55 | ); | ||
| 56 | return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; | ||
| 57 | } | 18 | } |
apps/web/src/lib/desktopUpdateReactQuery.test.ts deleted-50| ... | @@ -1,50 +0,0 @@ | ||
| 1 | import { QueryClient } from "@tanstack/react-query"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | import type { DesktopUpdateState } from "@t3tools/contracts"; | ||
| 4 | import { | ||
| 5 | desktopUpdateQueryKeys, | ||
| 6 | desktopUpdateStateQueryOptions, | ||
| 7 | setDesktopUpdateStateQueryData, | ||
| 8 | } from "./desktopUpdateReactQuery"; | ||
| 9 | |||
| 10 | const baseState: DesktopUpdateState = { | ||
| 11 | enabled: true, | ||
| 12 | status: "idle", | ||
| 13 | channel: "latest", | ||
| 14 | currentVersion: "1.0.0", | ||
| 15 | hostArch: "x64", | ||
| 16 | appArch: "x64", | ||
| 17 | runningUnderArm64Translation: false, | ||
| 18 | availableVersion: null, | ||
| 19 | downloadedVersion: null, | ||
| 20 | downloadPercent: null, | ||
| 21 | checkedAt: null, | ||
| 22 | message: null, | ||
| 23 | errorContext: null, | ||
| 24 | canRetry: false, | ||
| 25 | }; | ||
| 26 | |||
| 27 | describe("desktopUpdateStateQueryOptions", () => { | ||
| 28 | it("always refetches on mount so Settings does not reuse stale desktop update state", () => { | ||
| 29 | const options = desktopUpdateStateQueryOptions(); | ||
| 30 | |||
| 31 | expect(options.staleTime).toBe(Infinity); | ||
| 32 | expect(options.refetchOnMount).toBe("always"); | ||
| 33 | }); | ||
| 34 | }); | ||
| 35 | |||
| 36 | describe("setDesktopUpdateStateQueryData", () => { | ||
| 37 | it("writes desktop update state into the shared cache key", () => { | ||
| 38 | const queryClient = new QueryClient(); | ||
| 39 | const nextState: DesktopUpdateState = { | ||
| 40 | ...baseState, | ||
| 41 | status: "downloaded", | ||
| 42 | availableVersion: "1.1.0", | ||
| 43 | downloadedVersion: "1.1.0", | ||
| 44 | }; | ||
| 45 | |||
| 46 | setDesktopUpdateStateQueryData(queryClient, nextState); | ||
| 47 | |||
| 48 | expect(queryClient.getQueryData(desktopUpdateQueryKeys.state())).toEqual(nextState); | ||
| 49 | }); | ||
| 50 | }); | ||
apps/web/src/lib/desktopUpdateReactQuery.ts deleted-42| ... | @@ -1,42 +0,0 @@ | ||
| 1 | import { queryOptions, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; | ||
| 2 | import { useEffect } from "react"; | ||
| 3 | import type { DesktopUpdateState } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | export const desktopUpdateQueryKeys = { | ||
| 6 | all: ["desktop", "update"] as const, | ||
| 7 | state: () => ["desktop", "update", "state"] as const, | ||
| 8 | }; | ||
| 9 | |||
| 10 | export const setDesktopUpdateStateQueryData = ( | ||
| 11 | queryClient: QueryClient, | ||
| 12 | state: DesktopUpdateState | null, | ||
| 13 | ) => queryClient.setQueryData(desktopUpdateQueryKeys.state(), state); | ||
| 14 | |||
| 15 | export function desktopUpdateStateQueryOptions() { | ||
| 16 | return queryOptions({ | ||
| 17 | queryKey: desktopUpdateQueryKeys.state(), | ||
| 18 | queryFn: async () => { | ||
| 19 | const bridge = window.desktopBridge; | ||
| 20 | if (!bridge || typeof bridge.getUpdateState !== "function") return null; | ||
| 21 | return bridge.getUpdateState(); | ||
| 22 | }, | ||
| 23 | staleTime: Infinity, | ||
| 24 | refetchOnMount: "always", | ||
| 25 | }); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function useDesktopUpdateState() { | ||
| 29 | const queryClient = useQueryClient(); | ||
| 30 | const query = useQuery(desktopUpdateStateQueryOptions()); | ||
| 31 | |||
| 32 | useEffect(() => { | ||
| 33 | const bridge = window.desktopBridge; | ||
| 34 | if (!bridge || typeof bridge.onUpdateState !== "function") return; | ||
| 35 | |||
| 36 | return bridge.onUpdateState((nextState) => { | ||
| 37 | setDesktopUpdateStateQueryData(queryClient, nextState); | ||
| 38 | }); | ||
| 39 | }, [queryClient]); | ||
| 40 | |||
| 41 | return query; | ||
| 42 | } | ||
apps/web/src/lib/processDiagnosticsState.ts deleted-140| ... | @@ -1,140 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { | ||
| 3 | ServerProcessDiagnosticsResult, | ||
| 4 | ServerProcessResourceHistoryResult, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Cause from "effect/Cause"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 10 | import { useCallback } from "react"; | ||
| 11 | |||
| 12 | import { ensureLocalApi } from "../localApi"; | ||
| 13 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 14 | |||
| 15 | const PROCESS_DIAGNOSTICS_STALE_TIME_MS = 2_000; | ||
| 16 | const PROCESS_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000; | ||
| 17 | const PROCESS_RESOURCE_HISTORY_STALE_TIME_MS = 5_000; | ||
| 18 | const PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR = ":"; | ||
| 19 | |||
| 20 | const processDiagnosticsAtom = Atom.make( | ||
| 21 | Effect.promise(() => ensureLocalApi().server.getProcessDiagnostics()), | ||
| 22 | ).pipe( | ||
| 23 | Atom.swr({ | ||
| 24 | staleTime: PROCESS_DIAGNOSTICS_STALE_TIME_MS, | ||
| 25 | revalidateOnMount: true, | ||
| 26 | }), | ||
| 27 | Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS), | ||
| 28 | Atom.withLabel("process-diagnostics"), | ||
| 29 | ); | ||
| 30 | |||
| 31 | function formatProcessResourceHistoryKey(input: { | ||
| 32 | readonly windowMs: number; | ||
| 33 | readonly bucketMs: number; | ||
| 34 | }): string { | ||
| 35 | return `${input.windowMs}${PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR}${input.bucketMs}`; | ||
| 36 | } | ||
| 37 | |||
| 38 | function parseProcessResourceHistoryKey(key: string): { | ||
| 39 | readonly windowMs: number; | ||
| 40 | readonly bucketMs: number; | ||
| 41 | } { | ||
| 42 | const [windowMs = "0", bucketMs = "0"] = key.split(PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR); | ||
| 43 | return { | ||
| 44 | windowMs: Number(windowMs), | ||
| 45 | bucketMs: Number(bucketMs), | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | |||
| 49 | const processResourceHistoryAtom = Atom.family((key: string) => { | ||
| 50 | const input = parseProcessResourceHistoryKey(key); | ||
| 51 | return Atom.make( | ||
| 52 | Effect.promise(() => ensureLocalApi().server.getProcessResourceHistory(input)), | ||
| 53 | ).pipe( | ||
| 54 | Atom.swr({ | ||
| 55 | staleTime: PROCESS_RESOURCE_HISTORY_STALE_TIME_MS, | ||
| 56 | revalidateOnMount: true, | ||
| 57 | }), | ||
| 58 | Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS), | ||
| 59 | Atom.withLabel(`process-resource-history:${key}`), | ||
| 60 | ); | ||
| 61 | }); | ||
| 62 | |||
| 63 | export interface ProcessDiagnosticsState { | ||
| 64 | readonly data: ServerProcessDiagnosticsResult | null; | ||
| 65 | readonly error: string | null; | ||
| 66 | readonly isPending: boolean; | ||
| 67 | readonly refresh: () => void; | ||
| 68 | } | ||
| 69 | |||
| 70 | export interface ProcessResourceHistoryState { | ||
| 71 | readonly data: ServerProcessResourceHistoryResult | null; | ||
| 72 | readonly error: string | null; | ||
| 73 | readonly isPending: boolean; | ||
| 74 | readonly refresh: () => void; | ||
| 75 | } | ||
| 76 | |||
| 77 | function formatProcessDiagnosticsError(error: unknown): string { | ||
| 78 | return error instanceof Error ? error.message : "Failed to load process diagnostics."; | ||
| 79 | } | ||
| 80 | |||
| 81 | function readProcessDiagnosticsError( | ||
| 82 | result: AsyncResult.AsyncResult<ServerProcessDiagnosticsResult, unknown>, | ||
| 83 | ): string | null { | ||
| 84 | if (result._tag !== "Failure") { | ||
| 85 | return null; | ||
| 86 | } | ||
| 87 | |||
| 88 | const squashed = Cause.squash(result.cause); | ||
| 89 | return formatProcessDiagnosticsError(squashed); | ||
| 90 | } | ||
| 91 | |||
| 92 | function readProcessResourceHistoryError( | ||
| 93 | result: AsyncResult.AsyncResult<ServerProcessResourceHistoryResult, unknown>, | ||
| 94 | ): string | null { | ||
| 95 | if (result._tag !== "Failure") { | ||
| 96 | return null; | ||
| 97 | } | ||
| 98 | |||
| 99 | const squashed = Cause.squash(result.cause); | ||
| 100 | return formatProcessDiagnosticsError(squashed); | ||
| 101 | } | ||
| 102 | |||
| 103 | export function refreshProcessDiagnostics(): void { | ||
| 104 | appAtomRegistry.refresh(processDiagnosticsAtom); | ||
| 105 | } | ||
| 106 | |||
| 107 | export function useProcessDiagnostics(): ProcessDiagnosticsState { | ||
| 108 | const result = useAtomValue(processDiagnosticsAtom); | ||
| 109 | const data = Option.getOrNull(AsyncResult.value(result)); | ||
| 110 | const refresh = useCallback(() => { | ||
| 111 | refreshProcessDiagnostics(); | ||
| 112 | }, []); | ||
| 113 | |||
| 114 | return { | ||
| 115 | data, | ||
| 116 | error: readProcessDiagnosticsError(result), | ||
| 117 | isPending: result.waiting, | ||
| 118 | refresh, | ||
| 119 | }; | ||
| 120 | } | ||
| 121 | |||
| 122 | export function useProcessResourceHistory(input: { | ||
| 123 | readonly windowMs: number; | ||
| 124 | readonly bucketMs: number; | ||
| 125 | }): ProcessResourceHistoryState { | ||
| 126 | const atom = processResourceHistoryAtom(formatProcessResourceHistoryKey(input)); | ||
| 127 | const result = useAtomValue(atom); | ||
| 128 | const data = Option.getOrNull(AsyncResult.value(result)); | ||
| 129 | |||
| 130 | const refresh = useCallback(() => { | ||
| 131 | appAtomRegistry.refresh(atom); | ||
| 132 | }, [atom]); | ||
| 133 | |||
| 134 | return { | ||
| 135 | data, | ||
| 136 | error: readProcessResourceHistoryError(result), | ||
| 137 | isPending: result.waiting, | ||
| 138 | refresh, | ||
| 139 | }; | ||
| 140 | } | ||
apps/web/src/lib/projectPaths.ts+1-1| ... | @@ -14,4 +14,4 @@ export { | ... | @@ -14,4 +14,4 @@ export { |
| 14 | normalizeProjectPathForComparison, | 14 | normalizeProjectPathForComparison, |
| 15 | normalizeProjectPathForDispatch, | 15 | normalizeProjectPathForDispatch, |
| 16 | resolveProjectPathForDispatch, | 16 | resolveProjectPathForDispatch, |
| 17 | } from "@t3tools/client-runtime"; | 17 | } from "@t3tools/client-runtime/state/projects"; |
apps/web/src/lib/runtime.ts+18-14| ... | @@ -2,8 +2,9 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; | ... | @@ -2,8 +2,9 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 2 | import type * as Effect from "effect/Effect"; | 2 | import type * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import { FetchHttpClient } from "effect/unstable/http"; | 4 | import { FetchHttpClient } from "effect/unstable/http"; |
| 5 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 5 | 6 | ||
| 6 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | 7 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; |
| 7 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | 8 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; |
| 8 | import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; | 9 | import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; |
| 9 | import { | 10 | import { |
| ... | @@ -13,22 +14,22 @@ import { | ... | @@ -13,22 +14,22 @@ import { |
| 13 | import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; | 14 | import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; |
| 14 | 15 | ||
| 15 | import { browserCryptoLayer } from "../cloud/dpop"; | 16 | import { browserCryptoLayer } from "../cloud/dpop"; |
| 16 | import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; | 17 | import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; |
| 17 | import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; | 18 | import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; |
| 18 | 19 | ||
| 19 | function configuredRelayUrl(): string { | 20 | function configuredRelayUrl(): string { |
| 20 | return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; | 21 | return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; |
| 21 | } | 22 | } |
| 22 | 23 | ||
| 23 | const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch); | 24 | const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); |
| 24 | const webRelayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { | 25 | const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { |
| 25 | serviceName: "t3-web-relay-client", | 26 | serviceName: "t3-web-relay-client", |
| 26 | serviceVersion: import.meta.env.APP_VERSION, | 27 | serviceVersion: import.meta.env.APP_VERSION, |
| 27 | runtime: "browser", | 28 | runtime: "browser", |
| 28 | client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", | 29 | client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", |
| 29 | }).pipe(Layer.provide(webHttpClientLayer)); | 30 | }).pipe(Layer.provide(httpClientLayer)); |
| 30 | 31 | ||
| 31 | export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer); | 32 | export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); |
| 32 | 33 | ||
| 33 | const primaryHttpRuntime = ManagedRuntime.make( | 34 | const primaryHttpRuntime = ManagedRuntime.make( |
| 34 | primaryEnvironmentHttpClientLive.pipe( | 35 | primaryEnvironmentHttpClientLive.pipe( |
| ... | @@ -58,13 +59,16 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner) | ... | @@ -58,13 +59,16 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner) |
| 58 | primaryHttpRunner = runner ?? livePrimaryHttpRunner; | 59 | primaryHttpRunner = runner ?? livePrimaryHttpRunner; |
| 59 | } | 60 | } |
| 60 | 61 | ||
| 61 | export const webRuntime = ManagedRuntime.make( | 62 | export const runtimeLayer = Layer.mergeAll( |
| 62 | Layer.mergeAll( | 63 | httpClientLayer, |
| 63 | webHttpClientLayer, | 64 | browserCryptoLayer, |
| 64 | browserCryptoLayer, | 65 | Socket.layerWebSocketConstructorGlobal, |
| 65 | webManagedRelayClientLayer(configuredRelayUrl()).pipe( | 66 | relayTracingLayer, |
| 66 | Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)), | 67 | managedRelayClientLayer(configuredRelayUrl()).pipe( |
| 67 | Layer.provideMerge(webRelayTracingLayer), | 68 | Layer.provide(Layer.mergeAll(httpClientLayer, browserCryptoLayer)), |
| 68 | ), | ||
| 69 | ), | 69 | ), |
| 70 | ); | 70 | ); |
| 71 | |||
| 72 | export const runtime = ManagedRuntime.make(runtimeLayer); | ||
| 73 | |||
| 74 | export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); |
apps/web/src/lib/sourceControlActions.ts+10-477| ... | @@ -1,477 +1,10 @@ | ... | @@ -1,477 +1,10 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | export { |
| 2 | import { | 2 | readCachedPullRequestResolution, |
| 3 | type VcsActionOperation, | 3 | useGitStackedAction, |
| 4 | type VcsActionState, | 4 | usePreparePullRequestThreadAction, |
| 5 | EMPTY_VCS_ACTION_ATOM, | 5 | usePullRequestResolutionState as usePullRequestResolution, |
| 6 | EMPTY_VCS_ACTION_STATE, | 6 | useSourceControlActionRunning, |
| 7 | createVcsActionManager, | 7 | useSourceControlPublishRepositoryAction, |
| 8 | getVcsActionTargetKey, | 8 | useVcsInitAction, |
| 9 | vcsActionStateAtom, | 9 | useVcsPullAction, |
| 10 | } from "@t3tools/client-runtime"; | 10 | } from "../state/sourceControlActions"; |
| 11 | import { | ||
| 12 | type EnvironmentId, | ||
| 13 | type GitActionProgressEvent, | ||
| 14 | type GitRunStackedActionResult, | ||
| 15 | type GitStackedAction, | ||
| 16 | type GitResolvePullRequestResult, | ||
| 17 | type SourceControlCloneProtocol, | ||
| 18 | type SourceControlPublishRepositoryResult, | ||
| 19 | type SourceControlRepositoryVisibility, | ||
| 20 | type ThreadId, | ||
| 21 | type VcsPullResult, | ||
| 22 | } from "@t3tools/contracts"; | ||
| 23 | import { | ||
| 24 | useCallback, | ||
| 25 | useEffect, | ||
| 26 | useMemo, | ||
| 27 | useState, | ||
| 28 | useSyncExternalStore, | ||
| 29 | useTransition, | ||
| 30 | } from "react"; | ||
| 31 | |||
| 32 | import { ensureEnvironmentApi } from "../environmentApi"; | ||
| 33 | import { readEnvironmentConnection } from "../environments/runtime"; | ||
| 34 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 35 | import { getVcsStatusSnapshot, refreshVcsStatus } from "./vcsStatusState"; | ||
| 36 | import { vcsRefManager } from "./vcsRefState"; | ||
| 37 | |||
| 38 | type SourceControlActionKind = | ||
| 39 | | "init" | ||
| 40 | | "pull" | ||
| 41 | | "publishRepository" | ||
| 42 | | "runStackedAction" | ||
| 43 | | "preparePullRequestThread"; | ||
| 44 | |||
| 45 | interface SourceControlActionScope { | ||
| 46 | readonly environmentId: EnvironmentId | null; | ||
| 47 | readonly cwd: string | null; | ||
| 48 | } | ||
| 49 | |||
| 50 | interface SourceControlActionState<TArgs extends ReadonlyArray<unknown>, TResult> { | ||
| 51 | readonly isPending: boolean; | ||
| 52 | readonly error: unknown; | ||
| 53 | readonly run: (...args: TArgs) => Promise<TResult>; | ||
| 54 | readonly resetError: () => void; | ||
| 55 | } | ||
| 56 | |||
| 57 | export const vcsActionManager = createVcsActionManager({ | ||
| 58 | getRegistry: () => appAtomRegistry, | ||
| 59 | getClient: (environmentId) => { | ||
| 60 | const client = readEnvironmentConnection(environmentId)?.client; | ||
| 61 | return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; | ||
| 62 | }, | ||
| 63 | onInvalidate: (target) => invalidateSourceControlState(target), | ||
| 64 | }); | ||
| 65 | |||
| 66 | const actionListeners = new Set<() => void>(); | ||
| 67 | const activeActionCounts = new Map<string, number>(); | ||
| 68 | |||
| 69 | function notifyActionListeners(): void { | ||
| 70 | for (const listener of actionListeners) { | ||
| 71 | listener(); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | function subscribeActionState(listener: () => void): () => void { | ||
| 76 | actionListeners.add(listener); | ||
| 77 | return () => { | ||
| 78 | actionListeners.delete(listener); | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string { | ||
| 83 | return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`; | ||
| 84 | } | ||
| 85 | |||
| 86 | function beginAction(key: string): () => void { | ||
| 87 | activeActionCounts.set(key, (activeActionCounts.get(key) ?? 0) + 1); | ||
| 88 | notifyActionListeners(); | ||
| 89 | let ended = false; | ||
| 90 | return () => { | ||
| 91 | if (ended) { | ||
| 92 | return; | ||
| 93 | } | ||
| 94 | ended = true; | ||
| 95 | const next = (activeActionCounts.get(key) ?? 1) - 1; | ||
| 96 | if (next <= 0) { | ||
| 97 | activeActionCounts.delete(key); | ||
| 98 | } else { | ||
| 99 | activeActionCounts.set(key, next); | ||
| 100 | } | ||
| 101 | notifyActionListeners(); | ||
| 102 | }; | ||
| 103 | } | ||
| 104 | |||
| 105 | function isAnyActionRunning( | ||
| 106 | kinds: ReadonlyArray<SourceControlActionKind>, | ||
| 107 | scope: SourceControlActionScope, | ||
| 108 | ): boolean { | ||
| 109 | return kinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0); | ||
| 110 | } | ||
| 111 | |||
| 112 | function getVcsActionOperationForKind(kind: SourceControlActionKind): VcsActionOperation | null { | ||
| 113 | switch (kind) { | ||
| 114 | case "init": | ||
| 115 | return "init"; | ||
| 116 | case "pull": | ||
| 117 | return "pull"; | ||
| 118 | case "runStackedAction": | ||
| 119 | return "run_change_request"; | ||
| 120 | case "publishRepository": | ||
| 121 | case "preparePullRequestThread": | ||
| 122 | return null; | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | function useVcsActionStateForScope(scope: SourceControlActionScope): VcsActionState { | ||
| 127 | const targetKey = getVcsActionTargetKey(scope); | ||
| 128 | const state = useAtomValue( | ||
| 129 | targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, | ||
| 130 | ); | ||
| 131 | return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; | ||
| 132 | } | ||
| 133 | |||
| 134 | export function invalidateSourceControlState(scope?: { | ||
| 135 | readonly environmentId?: EnvironmentId | null; | ||
| 136 | readonly cwd?: string | null; | ||
| 137 | }): Promise<void> { | ||
| 138 | const environmentId = scope?.environmentId ?? null; | ||
| 139 | const cwd = scope?.cwd ?? null; | ||
| 140 | if (cwd !== null) { | ||
| 141 | vcsRefManager.invalidateScope({ environmentId, cwd }); | ||
| 142 | if (environmentId !== null) { | ||
| 143 | return refreshVcsStatus({ environmentId, cwd }).then( | ||
| 144 | () => undefined, | ||
| 145 | () => undefined, | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | return Promise.resolve(); | ||
| 149 | } | ||
| 150 | |||
| 151 | vcsRefManager.invalidate(); | ||
| 152 | return Promise.resolve(); | ||
| 153 | } | ||
| 154 | |||
| 155 | function useSourceControlAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: { | ||
| 156 | readonly kind: SourceControlActionKind; | ||
| 157 | readonly scope: SourceControlActionScope; | ||
| 158 | readonly action: (...args: TArgs) => Promise<TResult>; | ||
| 159 | readonly invalidateOnSuccess?: boolean; | ||
| 160 | }): SourceControlActionState<TArgs, TResult> { | ||
| 161 | const { action, invalidateOnSuccess = true, kind, scope } = input; | ||
| 162 | const [error, setError] = useState<unknown>(null); | ||
| 163 | const [activeCount, setActiveCount] = useState(0); | ||
| 164 | const [isTransitionPending, startTransition] = useTransition(); | ||
| 165 | const key = actionKey(kind, scope); | ||
| 166 | |||
| 167 | const resetError = useCallback(() => { | ||
| 168 | startTransition(() => setError(null)); | ||
| 169 | }, [startTransition]); | ||
| 170 | |||
| 171 | const run = useCallback( | ||
| 172 | async (...args: TArgs): Promise<TResult> => { | ||
| 173 | const endAction = beginAction(key); | ||
| 174 | startTransition(() => { | ||
| 175 | setError(null); | ||
| 176 | setActiveCount((count) => count + 1); | ||
| 177 | }); | ||
| 178 | try { | ||
| 179 | const result = await action(...args); | ||
| 180 | if (invalidateOnSuccess) { | ||
| 181 | await invalidateSourceControlState(scope); | ||
| 182 | } | ||
| 183 | return result; | ||
| 184 | } catch (nextError) { | ||
| 185 | startTransition(() => setError(nextError)); | ||
| 186 | throw nextError; | ||
| 187 | } finally { | ||
| 188 | endAction(); | ||
| 189 | startTransition(() => setActiveCount((count) => Math.max(0, count - 1))); | ||
| 190 | } | ||
| 191 | }, | ||
| 192 | [action, invalidateOnSuccess, key, scope, startTransition], | ||
| 193 | ); | ||
| 194 | |||
| 195 | return { | ||
| 196 | error, | ||
| 197 | isPending: activeCount > 0 || isTransitionPending, | ||
| 198 | resetError, | ||
| 199 | run, | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | |||
| 203 | export function useSourceControlActionRunning( | ||
| 204 | scope: SourceControlActionScope, | ||
| 205 | kinds: ReadonlyArray<SourceControlActionKind>, | ||
| 206 | ): boolean { | ||
| 207 | const stableKinds = useMemo(() => kinds.toSorted(), [kinds]); | ||
| 208 | const appActionRunning = useSyncExternalStore( | ||
| 209 | subscribeActionState, | ||
| 210 | () => isAnyActionRunning(stableKinds, scope), | ||
| 211 | () => false, | ||
| 212 | ); | ||
| 213 | const vcsActionState = useVcsActionStateForScope(scope); | ||
| 214 | const vcsActionRunning = | ||
| 215 | vcsActionState.isRunning && | ||
| 216 | stableKinds.some((kind) => getVcsActionOperationForKind(kind) === vcsActionState.operation); | ||
| 217 | |||
| 218 | return appActionRunning || vcsActionRunning; | ||
| 219 | } | ||
| 220 | |||
| 221 | function useVcsManagerAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: { | ||
| 222 | readonly operation: VcsActionOperation; | ||
| 223 | readonly scope: SourceControlActionScope; | ||
| 224 | readonly unavailableMessage: string; | ||
| 225 | readonly action: (...args: TArgs) => Promise<TResult | null>; | ||
| 226 | }): SourceControlActionState<TArgs, TResult> { | ||
| 227 | const { action, operation, scope, unavailableMessage } = input; | ||
| 228 | const vcsActionState = useVcsActionStateForScope(scope); | ||
| 229 | const [error, setError] = useState<unknown>(null); | ||
| 230 | const [isTransitionPending, startTransition] = useTransition(); | ||
| 231 | |||
| 232 | const resetError = useCallback(() => { | ||
| 233 | vcsActionManager.reset(scope); | ||
| 234 | startTransition(() => setError(null)); | ||
| 235 | }, [scope, startTransition]); | ||
| 236 | |||
| 237 | const run = useCallback( | ||
| 238 | async (...args: TArgs): Promise<TResult> => { | ||
| 239 | startTransition(() => setError(null)); | ||
| 240 | try { | ||
| 241 | const result = await action(...args); | ||
| 242 | if (result === null) { | ||
| 243 | throw new Error(unavailableMessage); | ||
| 244 | } | ||
| 245 | return result; | ||
| 246 | } catch (nextError) { | ||
| 247 | startTransition(() => setError(nextError)); | ||
| 248 | throw nextError; | ||
| 249 | } | ||
| 250 | }, | ||
| 251 | [action, startTransition, unavailableMessage], | ||
| 252 | ); | ||
| 253 | |||
| 254 | return { | ||
| 255 | error: error ?? vcsActionState.error, | ||
| 256 | isPending: | ||
| 257 | isTransitionPending || (vcsActionState.isRunning && vcsActionState.operation === operation), | ||
| 258 | resetError, | ||
| 259 | run, | ||
| 260 | }; | ||
| 261 | } | ||
| 262 | |||
| 263 | export function useVcsInitAction(scope: SourceControlActionScope) { | ||
| 264 | const action = useCallback(async () => { | ||
| 265 | if (!scope.cwd || !scope.environmentId) throw new Error("Git init is unavailable."); | ||
| 266 | return vcsActionManager.init(scope); | ||
| 267 | }, [scope]); | ||
| 268 | |||
| 269 | return useVcsManagerAction({ | ||
| 270 | operation: "init", | ||
| 271 | scope, | ||
| 272 | unavailableMessage: "Git init is unavailable.", | ||
| 273 | action, | ||
| 274 | }); | ||
| 275 | } | ||
| 276 | |||
| 277 | export function useGitStackedAction(scope: SourceControlActionScope) { | ||
| 278 | const action = useCallback( | ||
| 279 | async ({ | ||
| 280 | actionId, | ||
| 281 | action, | ||
| 282 | commitMessage, | ||
| 283 | featureBranch, | ||
| 284 | filePaths, | ||
| 285 | onProgress, | ||
| 286 | }: { | ||
| 287 | actionId: string; | ||
| 288 | action: GitStackedAction; | ||
| 289 | commitMessage?: string; | ||
| 290 | featureBranch?: boolean; | ||
| 291 | filePaths?: string[]; | ||
| 292 | onProgress?: (event: GitActionProgressEvent) => void; | ||
| 293 | }): Promise<GitRunStackedActionResult | null> => { | ||
| 294 | if (!scope.cwd || !scope.environmentId) throw new Error("Git action is unavailable."); | ||
| 295 | return vcsActionManager.runChangeRequest( | ||
| 296 | scope, | ||
| 297 | { | ||
| 298 | actionId, | ||
| 299 | action, | ||
| 300 | ...(commitMessage ? { commitMessage } : {}), | ||
| 301 | ...(featureBranch ? { featureBranch: true } : {}), | ||
| 302 | ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), | ||
| 303 | }, | ||
| 304 | { | ||
| 305 | gitStatus: getVcsStatusSnapshot(scope).data, | ||
| 306 | ...(onProgress ? { onProgress } : {}), | ||
| 307 | }, | ||
| 308 | ); | ||
| 309 | }, | ||
| 310 | [scope], | ||
| 311 | ); | ||
| 312 | |||
| 313 | return useVcsManagerAction({ | ||
| 314 | operation: "run_change_request", | ||
| 315 | scope, | ||
| 316 | unavailableMessage: "Git action is unavailable.", | ||
| 317 | action, | ||
| 318 | }); | ||
| 319 | } | ||
| 320 | |||
| 321 | export function useVcsPullAction(scope: SourceControlActionScope) { | ||
| 322 | const action = useCallback(async (): Promise<VcsPullResult | null> => { | ||
| 323 | if (!scope.cwd || !scope.environmentId) throw new Error("Git pull is unavailable."); | ||
| 324 | return vcsActionManager.pull(scope); | ||
| 325 | }, [scope]); | ||
| 326 | |||
| 327 | return useVcsManagerAction({ | ||
| 328 | operation: "pull", | ||
| 329 | scope, | ||
| 330 | unavailableMessage: "Git pull is unavailable.", | ||
| 331 | action, | ||
| 332 | }); | ||
| 333 | } | ||
| 334 | |||
| 335 | export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { | ||
| 336 | const action = useCallback( | ||
| 337 | async (args: { | ||
| 338 | provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; | ||
| 339 | repository: string; | ||
| 340 | visibility: SourceControlRepositoryVisibility; | ||
| 341 | remoteName: string; | ||
| 342 | protocol: SourceControlCloneProtocol; | ||
| 343 | }): Promise<SourceControlPublishRepositoryResult> => { | ||
| 344 | if (!scope.cwd || !scope.environmentId) { | ||
| 345 | throw new Error("Repository publishing is unavailable."); | ||
| 346 | } | ||
| 347 | return ensureEnvironmentApi(scope.environmentId).sourceControl.publishRepository({ | ||
| 348 | cwd: scope.cwd, | ||
| 349 | ...args, | ||
| 350 | }); | ||
| 351 | }, | ||
| 352 | [scope], | ||
| 353 | ); | ||
| 354 | |||
| 355 | return useSourceControlAction({ | ||
| 356 | kind: "publishRepository", | ||
| 357 | scope, | ||
| 358 | action, | ||
| 359 | }); | ||
| 360 | } | ||
| 361 | |||
| 362 | export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) { | ||
| 363 | const action = useCallback( | ||
| 364 | async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { | ||
| 365 | if (!scope.cwd || !scope.environmentId) { | ||
| 366 | throw new Error("Pull request thread preparation is unavailable."); | ||
| 367 | } | ||
| 368 | return ensureEnvironmentApi(scope.environmentId).git.preparePullRequestThread({ | ||
| 369 | cwd: scope.cwd, | ||
| 370 | reference: args.reference, | ||
| 371 | mode: args.mode, | ||
| 372 | ...(args.threadId ? { threadId: args.threadId } : {}), | ||
| 373 | }); | ||
| 374 | }, | ||
| 375 | [scope], | ||
| 376 | ); | ||
| 377 | |||
| 378 | return useSourceControlAction({ | ||
| 379 | kind: "preparePullRequestThread", | ||
| 380 | scope, | ||
| 381 | action, | ||
| 382 | }); | ||
| 383 | } | ||
| 384 | |||
| 385 | interface PullRequestResolutionTarget { | ||
| 386 | readonly environmentId: EnvironmentId | null; | ||
| 387 | readonly cwd: string | null; | ||
| 388 | readonly reference: string | null; | ||
| 389 | } | ||
| 390 | |||
| 391 | interface PullRequestResolutionState { | ||
| 392 | readonly data: GitResolvePullRequestResult | null; | ||
| 393 | readonly error: unknown; | ||
| 394 | readonly isPending: boolean; | ||
| 395 | readonly isFetching: boolean; | ||
| 396 | } | ||
| 397 | |||
| 398 | const EMPTY_PULL_REQUEST_RESOLUTION: PullRequestResolutionState = { | ||
| 399 | data: null, | ||
| 400 | error: null, | ||
| 401 | isPending: false, | ||
| 402 | isFetching: false, | ||
| 403 | }; | ||
| 404 | |||
| 405 | const pullRequestResolutionCache = new Map<string, GitResolvePullRequestResult>(); | ||
| 406 | |||
| 407 | function pullRequestResolutionKey(target: PullRequestResolutionTarget): string | null { | ||
| 408 | if (!target.environmentId || !target.cwd || !target.reference) { | ||
| 409 | return null; | ||
| 410 | } | ||
| 411 | return `${target.environmentId}:${target.cwd}:${target.reference}`; | ||
| 412 | } | ||
| 413 | |||
| 414 | export function readCachedPullRequestResolution( | ||
| 415 | target: PullRequestResolutionTarget, | ||
| 416 | ): GitResolvePullRequestResult | null { | ||
| 417 | const key = pullRequestResolutionKey(target); | ||
| 418 | return key ? (pullRequestResolutionCache.get(key) ?? null) : null; | ||
| 419 | } | ||
| 420 | |||
| 421 | export function usePullRequestResolution( | ||
| 422 | target: PullRequestResolutionTarget, | ||
| 423 | ): PullRequestResolutionState { | ||
| 424 | const stableTarget = useMemo( | ||
| 425 | () => ({ | ||
| 426 | environmentId: target.environmentId, | ||
| 427 | cwd: target.cwd, | ||
| 428 | reference: target.reference, | ||
| 429 | }), | ||
| 430 | [target.cwd, target.environmentId, target.reference], | ||
| 431 | ); | ||
| 432 | const key = pullRequestResolutionKey(stableTarget); | ||
| 433 | const [state, setState] = useState<PullRequestResolutionState>(() => { | ||
| 434 | const cached = readCachedPullRequestResolution(stableTarget); | ||
| 435 | return cached | ||
| 436 | ? { data: cached, error: null, isPending: false, isFetching: false } | ||
| 437 | : EMPTY_PULL_REQUEST_RESOLUTION; | ||
| 438 | }); | ||
| 439 | |||
| 440 | useEffect(() => { | ||
| 441 | if (!key || !stableTarget.environmentId || !stableTarget.cwd || !stableTarget.reference) { | ||
| 442 | setState(EMPTY_PULL_REQUEST_RESOLUTION); | ||
| 443 | return; | ||
| 444 | } | ||
| 445 | |||
| 446 | const cached = pullRequestResolutionCache.get(key) ?? null; | ||
| 447 | setState({ | ||
| 448 | data: cached, | ||
| 449 | error: null, | ||
| 450 | isPending: cached === null, | ||
| 451 | isFetching: true, | ||
| 452 | }); | ||
| 453 | |||
| 454 | let cancelled = false; | ||
| 455 | ensureEnvironmentApi(stableTarget.environmentId) | ||
| 456 | .git.resolvePullRequest({ cwd: stableTarget.cwd, reference: stableTarget.reference }) | ||
| 457 | .then((result) => { | ||
| 458 | if (cancelled) { | ||
| 459 | return; | ||
| 460 | } | ||
| 461 | pullRequestResolutionCache.set(key, result); | ||
| 462 | setState({ data: result, error: null, isPending: false, isFetching: false }); | ||
| 463 | }) | ||
| 464 | .catch((error: unknown) => { | ||
| 465 | if (cancelled) { | ||
| 466 | return; | ||
| 467 | } | ||
| 468 | setState({ data: cached, error, isPending: false, isFetching: false }); | ||
| 469 | }); | ||
| 470 | |||
| 471 | return () => { | ||
| 472 | cancelled = true; | ||
| 473 | }; | ||
| 474 | }, [key, stableTarget]); | ||
| 475 | |||
| 476 | return state; | ||
| 477 | } |
apps/web/src/lib/sourceControlDiscoveryState.ts deleted-95| ... | @@ -1,95 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type SourceControlDiscoveryTarget, | ||
| 4 | type SourceControlDiscoveryState, | ||
| 5 | createSourceControlDiscoveryManager, | ||
| 6 | getSourceControlDiscoveryTargetKey, | ||
| 7 | sourceControlDiscoveryStateAtom, | ||
| 8 | } from "@t3tools/client-runtime"; | ||
| 9 | import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts"; | ||
| 10 | import { useEffect } from "react"; | ||
| 11 | |||
| 12 | import { readPrimaryEnvironmentDescriptor } from "../environments/primary"; | ||
| 13 | import { | ||
| 14 | readEnvironmentConnection, | ||
| 15 | subscribeEnvironmentConnections, | ||
| 16 | } from "../environments/runtime"; | ||
| 17 | import { readLocalApi } from "../localApi"; | ||
| 18 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 19 | |||
| 20 | const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; | ||
| 21 | const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; | ||
| 22 | const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; | ||
| 23 | |||
| 24 | interface SourceControlDiscoveryTargetInput { | ||
| 25 | readonly environmentId?: EnvironmentId | null; | ||
| 26 | } | ||
| 27 | |||
| 28 | function sourceControlDiscoveryTarget( | ||
| 29 | input?: SourceControlDiscoveryTargetInput, | ||
| 30 | ): SourceControlDiscoveryTarget { | ||
| 31 | const environmentId = input?.environmentId ?? null; | ||
| 32 | if (!environmentId) { | ||
| 33 | return SOURCE_CONTROL_DISCOVERY_TARGET; | ||
| 34 | } | ||
| 35 | return readPrimaryEnvironmentDescriptor()?.environmentId === environmentId | ||
| 36 | ? SOURCE_CONTROL_DISCOVERY_TARGET | ||
| 37 | : { key: environmentId }; | ||
| 38 | } | ||
| 39 | |||
| 40 | const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ | ||
| 41 | getRegistry: () => appAtomRegistry, | ||
| 42 | getClient: (key) => { | ||
| 43 | if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { | ||
| 44 | const primaryEnvironmentId = readPrimaryEnvironmentDescriptor()?.environmentId ?? null; | ||
| 45 | const primaryConnection = primaryEnvironmentId | ||
| 46 | ? readEnvironmentConnection(primaryEnvironmentId) | ||
| 47 | : null; | ||
| 48 | if (primaryConnection) { | ||
| 49 | return primaryConnection.client.server; | ||
| 50 | } | ||
| 51 | try { | ||
| 52 | return readLocalApi()?.server ?? null; | ||
| 53 | } catch { | ||
| 54 | return null; | ||
| 55 | } | ||
| 56 | } | ||
| 57 | const environmentId = EnvironmentId.make(key); | ||
| 58 | const connection = readEnvironmentConnection(environmentId); | ||
| 59 | if (connection) { | ||
| 60 | return connection.client.server; | ||
| 61 | } | ||
| 62 | return null; | ||
| 63 | }, | ||
| 64 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 65 | staleTimeMs: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, | ||
| 66 | idleTtlMs: SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS, | ||
| 67 | }); | ||
| 68 | |||
| 69 | export function refreshSourceControlDiscovery( | ||
| 70 | input?: SourceControlDiscoveryTargetInput, | ||
| 71 | ): Promise<SourceControlDiscoveryResult | null> { | ||
| 72 | return sourceControlDiscoveryManager.refresh(sourceControlDiscoveryTarget(input)); | ||
| 73 | } | ||
| 74 | |||
| 75 | export function getSourceControlDiscoverySnapshot( | ||
| 76 | input?: SourceControlDiscoveryTargetInput, | ||
| 77 | ): SourceControlDiscoveryState { | ||
| 78 | return sourceControlDiscoveryManager.getSnapshot(sourceControlDiscoveryTarget(input)); | ||
| 79 | } | ||
| 80 | |||
| 81 | export function resetSourceControlDiscoveryStateForTests(): void { | ||
| 82 | sourceControlDiscoveryManager.reset(); | ||
| 83 | } | ||
| 84 | |||
| 85 | export function useSourceControlDiscovery( | ||
| 86 | input?: SourceControlDiscoveryTargetInput, | ||
| 87 | ): SourceControlDiscoveryState { | ||
| 88 | const targetKey = | ||
| 89 | getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ?? | ||
| 90 | SOURCE_CONTROL_DISCOVERY_TARGET.key; | ||
| 91 | |||
| 92 | useEffect(() => sourceControlDiscoveryManager.watch({ key: targetKey }), [targetKey]); | ||
| 93 | |||
| 94 | return useAtomValue(sourceControlDiscoveryStateAtom(targetKey)); | ||
| 95 | } | ||
apps/web/src/lib/terminalUiStateCleanup.test.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 |
apps/web/src/lib/threadSort.test.ts+12-10| ... | @@ -16,7 +16,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -16,7 +16,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 16 | return { | 16 | return { |
| 17 | id: ThreadId.make("thread-1"), | 17 | id: ThreadId.make("thread-1"), |
| 18 | environmentId: LOCAL_ENVIRONMENT_ID, | 18 | environmentId: LOCAL_ENVIRONMENT_ID, |
| 19 | codexThreadId: null, | ||
| 20 | projectId: PROJECT_ID, | 19 | projectId: PROJECT_ID, |
| 21 | title: "Thread", | 20 | title: "Thread", |
| 22 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | 21 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| ... | @@ -25,14 +24,14 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -25,14 +24,14 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 25 | session: null, | 24 | session: null, |
| 26 | messages: [], | 25 | messages: [], |
| 27 | proposedPlans: [], | 26 | proposedPlans: [], |
| 28 | error: null, | ||
| 29 | createdAt: "2026-03-09T10:00:00.000Z", | 27 | createdAt: "2026-03-09T10:00:00.000Z", |
| 30 | archivedAt: null, | 28 | archivedAt: null, |
| 29 | deletedAt: null, | ||
| 31 | updatedAt: "2026-03-09T10:00:00.000Z", | 30 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 32 | latestTurn: null, | 31 | latestTurn: null, |
| 33 | branch: null, | 32 | branch: null, |
| 34 | worktreePath: null, | 33 | worktreePath: null, |
| 35 | turnDiffSummaries: [], | 34 | checkpoints: [], |
| 36 | activities: [], | 35 | activities: [], |
| 37 | ...overrides, | 36 | ...overrides, |
| 38 | }; | 37 | }; |
| ... | @@ -50,9 +49,10 @@ describe("sortThreads", () => { | ... | @@ -50,9 +49,10 @@ describe("sortThreads", () => { |
| 50 | id: "message-1" as never, | 49 | id: "message-1" as never, |
| 51 | role: "user", | 50 | role: "user", |
| 52 | text: "older", | 51 | text: "older", |
| 52 | turnId: null, | ||
| 53 | createdAt: "2026-03-09T10:01:00.000Z", | 53 | createdAt: "2026-03-09T10:01:00.000Z", |
| 54 | updatedAt: "2026-03-09T10:01:00.000Z", | ||
| 54 | streaming: false, | 55 | streaming: false, |
| 55 | completedAt: "2026-03-09T10:01:00.000Z", | ||
| 56 | }, | 56 | }, |
| 57 | ], | 57 | ], |
| 58 | }), | 58 | }), |
| ... | @@ -65,9 +65,10 @@ describe("sortThreads", () => { | ... | @@ -65,9 +65,10 @@ describe("sortThreads", () => { |
| 65 | id: "message-2" as never, | 65 | id: "message-2" as never, |
| 66 | role: "user", | 66 | role: "user", |
| 67 | text: "newer", | 67 | text: "newer", |
| 68 | turnId: null, | ||
| 68 | createdAt: "2026-03-09T10:06:00.000Z", | 69 | createdAt: "2026-03-09T10:06:00.000Z", |
| 70 | updatedAt: "2026-03-09T10:06:00.000Z", | ||
| 69 | streaming: false, | 71 | streaming: false, |
| 70 | completedAt: "2026-03-09T10:06:00.000Z", | ||
| 71 | }, | 72 | }, |
| 72 | ], | 73 | ], |
| 73 | }), | 74 | }), |
| ... | @@ -92,9 +93,10 @@ describe("sortThreads", () => { | ... | @@ -92,9 +93,10 @@ describe("sortThreads", () => { |
| 92 | id: "message-1" as never, | 93 | id: "message-1" as never, |
| 93 | role: "assistant", | 94 | role: "assistant", |
| 94 | text: "assistant only", | 95 | text: "assistant only", |
| 96 | turnId: null, | ||
| 95 | createdAt: "2026-03-09T10:02:00.000Z", | 97 | createdAt: "2026-03-09T10:02:00.000Z", |
| 98 | updatedAt: "2026-03-09T10:02:00.000Z", | ||
| 96 | streaming: false, | 99 | streaming: false, |
| 97 | completedAt: "2026-03-09T10:02:00.000Z", | ||
| 98 | }, | 100 | }, |
| 99 | ], | 101 | ], |
| 100 | }), | 102 | }), |
| ... | @@ -144,14 +146,14 @@ describe("sortThreads", () => { | ... | @@ -144,14 +146,14 @@ describe("sortThreads", () => { |
| 144 | [ | 146 | [ |
| 145 | makeThread({ | 147 | makeThread({ |
| 146 | id: ThreadId.make("thread-1"), | 148 | id: ThreadId.make("thread-1"), |
| 147 | createdAt: "" as never, | 149 | createdAt: "invalid-created-at" as never, |
| 148 | updatedAt: undefined, | 150 | updatedAt: "invalid-updated-at" as never, |
| 149 | messages: [], | 151 | messages: [], |
| 150 | }), | 152 | }), |
| 151 | makeThread({ | 153 | makeThread({ |
| 152 | id: ThreadId.make("thread-2"), | 154 | id: ThreadId.make("thread-2"), |
| 153 | createdAt: "" as never, | 155 | createdAt: "invalid-created-at" as never, |
| 154 | updatedAt: undefined, | 156 | updatedAt: "invalid-updated-at" as never, |
| 155 | messages: [], | 157 | messages: [], |
| 156 | }), | 158 | }), |
| 157 | ], | 159 | ], |
apps/web/src/lib/threadSort.ts+1-1| ... | @@ -4,7 +4,7 @@ import type { Thread } from "../types"; | ... | @@ -4,7 +4,7 @@ import type { Thread } from "../types"; |
| 4 | 4 | ||
| 5 | export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & { | 5 | export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & { |
| 6 | latestUserMessageAt?: string | null; | 6 | latestUserMessageAt?: string | null; |
| 7 | messages?: Pick<Thread["messages"][number], "createdAt" | "role">[]; | 7 | messages?: ReadonlyArray<Pick<Thread["messages"][number], "createdAt" | "role">>; |
| 8 | }; | 8 | }; |
| 9 | 9 | ||
| 10 | export function toSortableTimestamp(iso: string | undefined): number | null { | 10 | export function toSortableTimestamp(iso: string | undefined): number | null { |
apps/web/src/lib/traceDiagnosticsState.ts deleted-65| ... | @@ -1,65 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { ServerTraceDiagnosticsResult } from "@t3tools/contracts"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 7 | import { useCallback } from "react"; | ||
| 8 | |||
| 9 | import { ensureLocalApi } from "../localApi"; | ||
| 10 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 11 | |||
| 12 | const TRACE_DIAGNOSTICS_STALE_TIME_MS = 5_000; | ||
| 13 | const TRACE_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000; | ||
| 14 | |||
| 15 | const traceDiagnosticsAtom = Atom.make( | ||
| 16 | Effect.promise(() => ensureLocalApi().server.getTraceDiagnostics()), | ||
| 17 | ).pipe( | ||
| 18 | Atom.swr({ | ||
| 19 | staleTime: TRACE_DIAGNOSTICS_STALE_TIME_MS, | ||
| 20 | revalidateOnMount: true, | ||
| 21 | }), | ||
| 22 | Atom.setIdleTTL(TRACE_DIAGNOSTICS_IDLE_TTL_MS), | ||
| 23 | Atom.withLabel("trace-diagnostics"), | ||
| 24 | ); | ||
| 25 | |||
| 26 | export interface TraceDiagnosticsState { | ||
| 27 | readonly data: ServerTraceDiagnosticsResult | null; | ||
| 28 | readonly error: string | null; | ||
| 29 | readonly isPending: boolean; | ||
| 30 | readonly refresh: () => void; | ||
| 31 | } | ||
| 32 | |||
| 33 | function formatTraceDiagnosticsError(error: unknown): string { | ||
| 34 | return error instanceof Error ? error.message : "Failed to load trace diagnostics."; | ||
| 35 | } | ||
| 36 | |||
| 37 | function readTraceDiagnosticsError( | ||
| 38 | result: AsyncResult.AsyncResult<ServerTraceDiagnosticsResult, unknown>, | ||
| 39 | ): string | null { | ||
| 40 | if (result._tag !== "Failure") { | ||
| 41 | return null; | ||
| 42 | } | ||
| 43 | |||
| 44 | const squashed = Cause.squash(result.cause); | ||
| 45 | return formatTraceDiagnosticsError(squashed); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function refreshTraceDiagnostics(): void { | ||
| 49 | appAtomRegistry.refresh(traceDiagnosticsAtom); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function useTraceDiagnostics(): TraceDiagnosticsState { | ||
| 53 | const result = useAtomValue(traceDiagnosticsAtom); | ||
| 54 | const data = Option.getOrNull(AsyncResult.value(result)); | ||
| 55 | const refresh = useCallback(() => { | ||
| 56 | refreshTraceDiagnostics(); | ||
| 57 | }, []); | ||
| 58 | |||
| 59 | return { | ||
| 60 | data, | ||
| 61 | error: readTraceDiagnosticsError(result), | ||
| 62 | isPending: result.waiting, | ||
| 63 | refresh, | ||
| 64 | }; | ||
| 65 | } | ||
apps/web/src/lib/turnDiffTree.test.ts+15-15| ... | @@ -5,9 +5,9 @@ import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; | ... | @@ -5,9 +5,9 @@ import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; |
| 5 | describe("summarizeTurnDiffStats", () => { | 5 | describe("summarizeTurnDiffStats", () => { |
| 6 | it("sums only files with numeric additions/deletions", () => { | 6 | it("sums only files with numeric additions/deletions", () => { |
| 7 | const stat = summarizeTurnDiffStats([ | 7 | const stat = summarizeTurnDiffStats([ |
| 8 | { path: "README.md", additions: 3, deletions: 1 }, | 8 | { path: "README.md", kind: "modified", additions: 3, deletions: 1 }, |
| 9 | { path: "docs/notes.md" }, | 9 | { path: "docs/notes.md", kind: "modified", additions: 0, deletions: 0 }, |
| 10 | { path: "src/index.ts", additions: 5, deletions: 2 }, | 10 | { path: "src/index.ts", kind: "modified", additions: 5, deletions: 2 }, |
| 11 | ]); | 11 | ]); |
| 12 | 12 | ||
| 13 | expect(stat).toEqual({ additions: 8, deletions: 3 }); | 13 | expect(stat).toEqual({ additions: 8, deletions: 3 }); |
| ... | @@ -17,9 +17,9 @@ describe("summarizeTurnDiffStats", () => { | ... | @@ -17,9 +17,9 @@ describe("summarizeTurnDiffStats", () => { |
| 17 | describe("buildTurnDiffTree", () => { | 17 | describe("buildTurnDiffTree", () => { |
| 18 | it("builds nested directory nodes with aggregated stats", () => { | 18 | it("builds nested directory nodes with aggregated stats", () => { |
| 19 | const tree = buildTurnDiffTree([ | 19 | const tree = buildTurnDiffTree([ |
| 20 | { path: "src/index.ts", additions: 2, deletions: 1 }, | 20 | { path: "src/index.ts", kind: "modified", additions: 2, deletions: 1 }, |
| 21 | { path: "src/components/Button.tsx", additions: 4, deletions: 2 }, | 21 | { path: "src/components/Button.tsx", kind: "modified", additions: 4, deletions: 2 }, |
| 22 | { path: "README.md", additions: 1, deletions: 0 }, | 22 | { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, |
| 23 | ]); | 23 | ]); |
| 24 | 24 | ||
| 25 | expect(tree).toEqual([ | 25 | expect(tree).toEqual([ |
| ... | @@ -60,10 +60,10 @@ describe("buildTurnDiffTree", () => { | ... | @@ -60,10 +60,10 @@ describe("buildTurnDiffTree", () => { |
| 60 | ]); | 60 | ]); |
| 61 | }); | 61 | }); |
| 62 | 62 | ||
| 63 | it("keeps files without stat values and excludes them from directory totals", () => { | 63 | it("keeps zero-valued file stats and includes only their numeric contribution", () => { |
| 64 | const tree = buildTurnDiffTree([ | 64 | const tree = buildTurnDiffTree([ |
| 65 | { path: "docs/notes.md" }, | 65 | { path: "docs/notes.md", kind: "modified", additions: 0, deletions: 0 }, |
| 66 | { path: "docs/todo.md", additions: 1, deletions: 1 }, | 66 | { path: "docs/todo.md", kind: "modified", additions: 1, deletions: 1 }, |
| 67 | ]); | 67 | ]); |
| 68 | 68 | ||
| 69 | expect(tree).toEqual([ | 69 | expect(tree).toEqual([ |
| ... | @@ -77,7 +77,7 @@ describe("buildTurnDiffTree", () => { | ... | @@ -77,7 +77,7 @@ describe("buildTurnDiffTree", () => { |
| 77 | kind: "file", | 77 | kind: "file", |
| 78 | name: "notes.md", | 78 | name: "notes.md", |
| 79 | path: "docs/notes.md", | 79 | path: "docs/notes.md", |
| 80 | stat: null, | 80 | stat: { additions: 0, deletions: 0 }, |
| 81 | }, | 81 | }, |
| 82 | { | 82 | { |
| 83 | kind: "file", | 83 | kind: "file", |
| ... | @@ -92,7 +92,7 @@ describe("buildTurnDiffTree", () => { | ... | @@ -92,7 +92,7 @@ describe("buildTurnDiffTree", () => { |
| 92 | 92 | ||
| 93 | it("normalizes file paths with windows separators", () => { | 93 | it("normalizes file paths with windows separators", () => { |
| 94 | const tree = buildTurnDiffTree([ | 94 | const tree = buildTurnDiffTree([ |
| 95 | { path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }, | 95 | { path: "apps\\web\\src\\index.ts", kind: "modified", additions: 2, deletions: 1 }, |
| 96 | ]); | 96 | ]); |
| 97 | 97 | ||
| 98 | expect(tree).toEqual([ | 98 | expect(tree).toEqual([ |
| ... | @@ -115,8 +115,8 @@ describe("buildTurnDiffTree", () => { | ... | @@ -115,8 +115,8 @@ describe("buildTurnDiffTree", () => { |
| 115 | 115 | ||
| 116 | it("compacts only single-directory chains and stops at branch points", () => { | 116 | it("compacts only single-directory chains and stops at branch points", () => { |
| 117 | const tree = buildTurnDiffTree([ | 117 | const tree = buildTurnDiffTree([ |
| 118 | { path: "apps/server/src/index.ts", additions: 2, deletions: 1 }, | 118 | { path: "apps/server/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, |
| 119 | { path: "apps/server/main.ts", additions: 4, deletions: 0 }, | 119 | { path: "apps/server/main.ts", kind: "modified", additions: 4, deletions: 0 }, |
| 120 | ]); | 120 | ]); |
| 121 | 121 | ||
| 122 | expect(tree).toEqual([ | 122 | expect(tree).toEqual([ |
| ... | @@ -153,8 +153,8 @@ describe("buildTurnDiffTree", () => { | ... | @@ -153,8 +153,8 @@ describe("buildTurnDiffTree", () => { |
| 153 | 153 | ||
| 154 | it("preserves leading/trailing whitespace in path segments", () => { | 154 | it("preserves leading/trailing whitespace in path segments", () => { |
| 155 | const tree = buildTurnDiffTree([ | 155 | const tree = buildTurnDiffTree([ |
| 156 | { path: "a/file.ts", additions: 1, deletions: 0 }, | 156 | { path: "a/file.ts", kind: "modified", additions: 1, deletions: 0 }, |
| 157 | { path: " a/file.ts", additions: 2, deletions: 0 }, | 157 | { path: " a/file.ts", kind: "modified", additions: 2, deletions: 0 }, |
| 158 | ]); | 158 | ]); |
| 159 | 159 | ||
| 160 | expect(tree).toHaveLength(2); | 160 | expect(tree).toHaveLength(2); |
apps/web/src/lib/vcsRefState.ts deleted-48| ... | @@ -1,48 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type VcsRefState, | ||
| 4 | type VcsRefTarget, | ||
| 5 | EMPTY_VCS_REF_ATOM, | ||
| 6 | EMPTY_VCS_REF_STATE, | ||
| 7 | createVcsRefManager, | ||
| 8 | getVcsRefTargetKey, | ||
| 9 | vcsRefStateAtom, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useEffect, useMemo } from "react"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | readEnvironmentConnection, | ||
| 15 | subscribeEnvironmentConnections, | ||
| 16 | } from "../environments/runtime"; | ||
| 17 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 18 | |||
| 19 | const VCS_REF_LIST_LIMIT = 100; | ||
| 20 | const VCS_REF_STALE_TIME_MS = 5_000; | ||
| 21 | |||
| 22 | export const vcsRefManager = createVcsRefManager({ | ||
| 23 | getRegistry: () => appAtomRegistry, | ||
| 24 | getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.vcs ?? null, | ||
| 25 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 26 | watchLimit: VCS_REF_LIST_LIMIT, | ||
| 27 | staleTimeMs: VCS_REF_STALE_TIME_MS, | ||
| 28 | onBackgroundError: (error) => { | ||
| 29 | console.warn("[vcs-refs] background refresh failed", error); | ||
| 30 | }, | ||
| 31 | }); | ||
| 32 | |||
| 33 | export function useVcsRefs(target: VcsRefTarget): VcsRefState { | ||
| 34 | const stableTarget = useMemo( | ||
| 35 | () => ({ | ||
| 36 | environmentId: target.environmentId, | ||
| 37 | cwd: target.cwd, | ||
| 38 | query: target.query ?? null, | ||
| 39 | }), | ||
| 40 | [target.cwd, target.environmentId, target.query], | ||
| 41 | ); | ||
| 42 | const targetKey = getVcsRefTargetKey(stableTarget); | ||
| 43 | |||
| 44 | useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); | ||
| 45 | |||
| 46 | const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); | ||
| 47 | return targetKey === null ? EMPTY_VCS_REF_STATE : state; | ||
| 48 | } | ||
apps/web/src/lib/vcsStatusState.ts deleted-65| ... | @@ -1,65 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type VcsStatusClient, | ||
| 4 | type VcsStatusState, | ||
| 5 | type VcsStatusTarget, | ||
| 6 | EMPTY_VCS_STATUS_ATOM, | ||
| 7 | EMPTY_VCS_STATUS_STATE, | ||
| 8 | createVcsStatusManager, | ||
| 9 | getVcsStatusDataForTarget, | ||
| 10 | getVcsStatusTargetKey, | ||
| 11 | vcsStatusStateAtom, | ||
| 12 | } from "@t3tools/client-runtime"; | ||
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 14 | import { useEffect } from "react"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | readEnvironmentConnection, | ||
| 18 | subscribeEnvironmentConnections, | ||
| 19 | } from "../environments/runtime"; | ||
| 20 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 21 | |||
| 22 | export type { VcsStatusState, VcsStatusTarget }; | ||
| 23 | export { getVcsStatusDataForTarget }; | ||
| 24 | |||
| 25 | const manager = createVcsStatusManager({ | ||
| 26 | getRegistry: () => appAtomRegistry, | ||
| 27 | getClient: (environmentId) => { | ||
| 28 | const connection = readEnvironmentConnection(environmentId as EnvironmentId); | ||
| 29 | return connection ? connection.client.vcs : null; | ||
| 30 | }, | ||
| 31 | getClientIdentity: (environmentId) => { | ||
| 32 | const connection = readEnvironmentConnection(environmentId as EnvironmentId); | ||
| 33 | return connection ? connection.environmentId : null; | ||
| 34 | }, | ||
| 35 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 36 | }); | ||
| 37 | |||
| 38 | export function getVcsStatusSnapshot(target: VcsStatusTarget): VcsStatusState { | ||
| 39 | return manager.getSnapshot(target); | ||
| 40 | } | ||
| 41 | |||
| 42 | export function watchVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient): () => void { | ||
| 43 | return manager.watch(target, client); | ||
| 44 | } | ||
| 45 | |||
| 46 | export function refreshVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient) { | ||
| 47 | return manager.refresh(target, client); | ||
| 48 | } | ||
| 49 | |||
| 50 | export function resetVcsStatusStateForTests(): void { | ||
| 51 | manager.reset(); | ||
| 52 | } | ||
| 53 | |||
| 54 | export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { | ||
| 55 | const targetKey = getVcsStatusTargetKey(target); | ||
| 56 | useEffect( | ||
| 57 | () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }), | ||
| 58 | [target.environmentId, target.cwd], | ||
| 59 | ); | ||
| 60 | |||
| 61 | const state = useAtomValue( | ||
| 62 | targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, | ||
| 63 | ); | ||
| 64 | return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; | ||
| 65 | } | ||
apps/web/src/localApi.test.ts+60-712| ... | @@ -1,23 +1,10 @@ | ... | @@ -1,23 +1,10 @@ |
| 1 | import { | 1 | import { |
| 2 | CommandId, | 2 | DEFAULT_CLIENT_SETTINGS, |
| 3 | DEFAULT_SERVER_SETTINGS, | 3 | type ContextMenuItem, |
| 4 | type DesktopBridge, | 4 | type DesktopBridge, |
| 5 | EnvironmentId, | ||
| 6 | type VcsStatusResult, | ||
| 7 | ProjectId, | ||
| 8 | type OrchestrationShellStreamItem, | ||
| 9 | ProviderDriverKind, | ||
| 10 | ProviderInstanceId, | ||
| 11 | type ServerConfig, | ||
| 12 | type ServerProvider, | ||
| 13 | type TerminalAttachStreamEvent, | ||
| 14 | type TerminalMetadataStreamEvent, | ||
| 15 | ThreadId, | ||
| 16 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 17 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 18 | 7 | ||
| 19 | import type { ContextMenuItem } from "@t3tools/contracts"; | ||
| 20 | |||
| 21 | const showContextMenuFallbackMock = | 8 | const showContextMenuFallbackMock = |
| 22 | vi.fn< | 9 | vi.fn< |
| 23 | <T extends string>( | 10 | <T extends string>( |
| ... | @@ -26,344 +13,44 @@ const showContextMenuFallbackMock = | ... | @@ -26,344 +13,44 @@ const showContextMenuFallbackMock = |
| 26 | ) => Promise<T | null> | 13 | ) => Promise<T | null> |
| 27 | >(); | 14 | >(); |
| 28 | 15 | ||
| 29 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | ||
| 30 | listeners.add(listener); | ||
| 31 | return () => { | ||
| 32 | listeners.delete(listener); | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | const terminalAttachListeners = new Set<(event: TerminalAttachStreamEvent) => void>(); | ||
| 37 | const terminalMetadataListeners = new Set<(event: TerminalMetadataStreamEvent) => void>(); | ||
| 38 | const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); | ||
| 39 | const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); | ||
| 40 | |||
| 41 | const rpcClientMock = { | ||
| 42 | dispose: vi.fn(), | ||
| 43 | terminal: { | ||
| 44 | open: vi.fn(), | ||
| 45 | attach: vi.fn((_input: unknown, listener: (event: TerminalAttachStreamEvent) => void) => | ||
| 46 | registerListener(terminalAttachListeners, listener), | ||
| 47 | ), | ||
| 48 | write: vi.fn(), | ||
| 49 | resize: vi.fn(), | ||
| 50 | clear: vi.fn(), | ||
| 51 | restart: vi.fn(), | ||
| 52 | close: vi.fn(), | ||
| 53 | onMetadata: vi.fn((listener: (event: TerminalMetadataStreamEvent) => void) => | ||
| 54 | registerListener(terminalMetadataListeners, listener), | ||
| 55 | ), | ||
| 56 | }, | ||
| 57 | projects: { | ||
| 58 | listEntries: vi.fn(), | ||
| 59 | readFile: vi.fn(), | ||
| 60 | searchEntries: vi.fn(), | ||
| 61 | writeFile: vi.fn(), | ||
| 62 | }, | ||
| 63 | filesystem: { | ||
| 64 | browse: vi.fn(), | ||
| 65 | }, | ||
| 66 | assets: { | ||
| 67 | createUrl: vi.fn(), | ||
| 68 | }, | ||
| 69 | preview: { | ||
| 70 | open: vi.fn(), | ||
| 71 | navigate: vi.fn(), | ||
| 72 | refresh: vi.fn(), | ||
| 73 | close: vi.fn(), | ||
| 74 | list: vi.fn(), | ||
| 75 | reportStatus: vi.fn(), | ||
| 76 | automation: { | ||
| 77 | connect: vi.fn(() => () => undefined), | ||
| 78 | respond: vi.fn(), | ||
| 79 | reportOwner: vi.fn(), | ||
| 80 | clearOwner: vi.fn(), | ||
| 81 | }, | ||
| 82 | onEvent: vi.fn(() => () => undefined), | ||
| 83 | subscribePorts: vi.fn(() => () => undefined), | ||
| 84 | }, | ||
| 85 | sourceControl: { | ||
| 86 | lookupRepository: vi.fn(), | ||
| 87 | cloneRepository: vi.fn(), | ||
| 88 | publishRepository: vi.fn(), | ||
| 89 | }, | ||
| 90 | shell: { | ||
| 91 | openInEditor: vi.fn(), | ||
| 92 | }, | ||
| 93 | vcs: { | ||
| 94 | pull: vi.fn(), | ||
| 95 | refreshStatus: vi.fn(), | ||
| 96 | onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => | ||
| 97 | registerListener(gitStatusListeners, listener), | ||
| 98 | ), | ||
| 99 | listRefs: vi.fn(), | ||
| 100 | createWorktree: vi.fn(), | ||
| 101 | removeWorktree: vi.fn(), | ||
| 102 | createRef: vi.fn(), | ||
| 103 | switchRef: vi.fn(), | ||
| 104 | init: vi.fn(), | ||
| 105 | }, | ||
| 106 | git: { | ||
| 107 | runStackedAction: vi.fn(), | ||
| 108 | resolvePullRequest: vi.fn(), | ||
| 109 | preparePullRequestThread: vi.fn(), | ||
| 110 | }, | ||
| 111 | review: { | ||
| 112 | getDiffPreview: vi.fn(), | ||
| 113 | }, | ||
| 114 | server: { | ||
| 115 | getConfig: vi.fn(), | ||
| 116 | refreshProviders: vi.fn(), | ||
| 117 | updateProvider: vi.fn(), | ||
| 118 | upsertKeybinding: vi.fn(), | ||
| 119 | getSettings: vi.fn(), | ||
| 120 | updateSettings: vi.fn(), | ||
| 121 | subscribeConfig: vi.fn(), | ||
| 122 | subscribeLifecycle: vi.fn(), | ||
| 123 | subscribeAuthAccess: vi.fn(), | ||
| 124 | }, | ||
| 125 | orchestration: { | ||
| 126 | dispatchCommand: vi.fn(), | ||
| 127 | getTurnDiff: vi.fn(), | ||
| 128 | getFullThreadDiff: vi.fn(), | ||
| 129 | subscribeShell: vi.fn((listener: (event: OrchestrationShellStreamItem) => void) => | ||
| 130 | registerListener(shellStreamListeners, listener), | ||
| 131 | ), | ||
| 132 | subscribeThread: vi.fn(() => () => undefined), | ||
| 133 | }, | ||
| 134 | }; | ||
| 135 | |||
| 136 | vi.mock("./environments/runtime", () => ({ | ||
| 137 | getPrimaryEnvironmentConnection: () => ({ | ||
| 138 | kind: "primary" as const, | ||
| 139 | knownEnvironment: { | ||
| 140 | id: "environment-local", | ||
| 141 | label: "Primary", | ||
| 142 | source: "manual" as const, | ||
| 143 | target: { | ||
| 144 | httpBaseUrl: "http://localhost:3000", | ||
| 145 | wsBaseUrl: "ws://localhost:3000", | ||
| 146 | }, | ||
| 147 | environmentId: EnvironmentId.make("environment-local"), | ||
| 148 | }, | ||
| 149 | client: rpcClientMock, | ||
| 150 | environmentId: EnvironmentId.make("environment-local"), | ||
| 151 | ensureBootstrapped: async () => undefined, | ||
| 152 | reconnect: async () => undefined, | ||
| 153 | dispose: async () => undefined, | ||
| 154 | }), | ||
| 155 | resetEnvironmentServiceForTests: vi.fn(), | ||
| 156 | resetSavedEnvironmentRegistryStoreForTests: vi.fn(), | ||
| 157 | resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), | ||
| 158 | subscribeEnvironmentConnections: vi.fn(() => () => undefined), | ||
| 159 | })); | ||
| 160 | |||
| 161 | vi.mock("./contextMenuFallback", () => ({ | 16 | vi.mock("./contextMenuFallback", () => ({ |
| 162 | showContextMenuFallback: showContextMenuFallbackMock, | 17 | showContextMenuFallback: showContextMenuFallbackMock, |
| 163 | })); | 18 | })); |
| 164 | 19 | ||
| 165 | function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) { | ||
| 166 | for (const listener of listeners) { | ||
| 167 | listener(event); | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { | ||
| 172 | const testGlobal = globalThis as typeof globalThis & { | ||
| 173 | window?: Window & typeof globalThis & { desktopBridge?: unknown }; | ||
| 174 | }; | ||
| 175 | if (!testGlobal.window) { | ||
| 176 | testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown }; | ||
| 177 | } | ||
| 178 | return testGlobal.window; | ||
| 179 | } | ||
| 180 | |||
| 181 | function createLocalStorageStub(): Storage { | 20 | function createLocalStorageStub(): Storage { |
| 182 | const store = new Map<string, string>(); | 21 | const values = new Map<string, string>(); |
| 183 | return { | 22 | return { |
| 184 | getItem: (key) => store.get(key) ?? null, | 23 | getItem: (key) => values.get(key) ?? null, |
| 185 | setItem: (key, value) => { | 24 | setItem: (key, value) => { |
| 186 | store.set(key, value); | 25 | values.set(key, value); |
| 187 | }, | 26 | }, |
| 188 | removeItem: (key) => { | 27 | removeItem: (key) => { |
| 189 | store.delete(key); | 28 | values.delete(key); |
| 190 | }, | 29 | }, |
| 191 | clear: () => { | 30 | clear: () => values.clear(), |
| 192 | store.clear(); | 31 | key: (index) => [...values.keys()][index] ?? null, |
| 193 | }, | ||
| 194 | key: (index) => [...store.keys()][index] ?? null, | ||
| 195 | get length() { | 32 | get length() { |
| 196 | return store.size; | 33 | return values.size; |
| 197 | }, | 34 | }, |
| 198 | }; | 35 | }; |
| 199 | } | 36 | } |
| 200 | 37 | ||
| 201 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { | 38 | function testWindow(): Window & typeof globalThis { |
| 202 | return { | 39 | return globalThis.window ?? (globalThis as unknown as Window & typeof globalThis); |
| 203 | getAppBranding: () => null, | ||
| 204 | getLocalEnvironmentBootstrap: () => null, | ||
| 205 | getClientSettings: async () => null, | ||
| 206 | setClientSettings: async () => undefined, | ||
| 207 | getSavedEnvironmentRegistry: async () => [], | ||
| 208 | setSavedEnvironmentRegistry: async () => undefined, | ||
| 209 | getSavedEnvironmentSecret: async () => null, | ||
| 210 | setSavedEnvironmentSecret: async () => true, | ||
| 211 | removeSavedEnvironmentSecret: async () => undefined, | ||
| 212 | discoverSshHosts: async () => [], | ||
| 213 | ensureSshEnvironment: async () => { | ||
| 214 | throw new Error("ensureSshEnvironment not implemented in test"); | ||
| 215 | }, | ||
| 216 | disconnectSshEnvironment: async () => undefined, | ||
| 217 | fetchSshEnvironmentDescriptor: async () => { | ||
| 218 | throw new Error("fetchSshEnvironmentDescriptor not implemented in test"); | ||
| 219 | }, | ||
| 220 | bootstrapSshBearerSession: async () => { | ||
| 221 | throw new Error("bootstrapSshBearerSession not implemented in test"); | ||
| 222 | }, | ||
| 223 | fetchSshSessionState: async () => { | ||
| 224 | throw new Error("fetchSshSessionState not implemented in test"); | ||
| 225 | }, | ||
| 226 | issueSshWebSocketTicket: async () => { | ||
| 227 | throw new Error("issueSshWebSocketTicket not implemented in test"); | ||
| 228 | }, | ||
| 229 | onSshPasswordPrompt: () => () => undefined, | ||
| 230 | resolveSshPasswordPrompt: async () => undefined, | ||
| 231 | getServerExposureState: async () => ({ | ||
| 232 | mode: "local-only", | ||
| 233 | endpointUrl: null, | ||
| 234 | advertisedHost: null, | ||
| 235 | tailscaleServeEnabled: false, | ||
| 236 | tailscaleServePort: 443, | ||
| 237 | }), | ||
| 238 | setServerExposureMode: async () => ({ | ||
| 239 | mode: "local-only", | ||
| 240 | endpointUrl: null, | ||
| 241 | advertisedHost: null, | ||
| 242 | tailscaleServeEnabled: false, | ||
| 243 | tailscaleServePort: 443, | ||
| 244 | }), | ||
| 245 | setTailscaleServeEnabled: async (input) => ({ | ||
| 246 | mode: "local-only", | ||
| 247 | endpointUrl: null, | ||
| 248 | advertisedHost: null, | ||
| 249 | tailscaleServeEnabled: input.enabled, | ||
| 250 | tailscaleServePort: input.port ?? 443, | ||
| 251 | }), | ||
| 252 | getAdvertisedEndpoints: async () => [], | ||
| 253 | pickFolder: async () => null, | ||
| 254 | confirm: async () => true, | ||
| 255 | setTheme: async () => undefined, | ||
| 256 | showContextMenu: async () => null, | ||
| 257 | openExternal: async () => true, | ||
| 258 | createCloudAuthRequest: async () => "t3code-dev://auth/callback?t3_state=test", | ||
| 259 | getCloudAuthToken: async () => null, | ||
| 260 | setCloudAuthToken: async () => true, | ||
| 261 | clearCloudAuthToken: async () => undefined, | ||
| 262 | fetchCloudAuth: async () => ({ | ||
| 263 | ok: true, | ||
| 264 | status: 200, | ||
| 265 | statusText: "OK", | ||
| 266 | headers: {}, | ||
| 267 | body: "", | ||
| 268 | }), | ||
| 269 | onCloudAuthCallback: () => () => undefined, | ||
| 270 | onMenuAction: () => () => undefined, | ||
| 271 | getUpdateState: async () => { | ||
| 272 | throw new Error("getUpdateState not implemented in test"); | ||
| 273 | }, | ||
| 274 | setUpdateChannel: async () => { | ||
| 275 | throw new Error("setUpdateChannel not implemented in test"); | ||
| 276 | }, | ||
| 277 | checkForUpdate: async () => { | ||
| 278 | throw new Error("checkForUpdate not implemented in test"); | ||
| 279 | }, | ||
| 280 | downloadUpdate: async () => { | ||
| 281 | throw new Error("downloadUpdate not implemented in test"); | ||
| 282 | }, | ||
| 283 | installUpdate: async () => { | ||
| 284 | throw new Error("installUpdate not implemented in test"); | ||
| 285 | }, | ||
| 286 | onUpdateState: () => () => undefined, | ||
| 287 | ...overrides, | ||
| 288 | }; | ||
| 289 | } | 40 | } |
| 290 | 41 | ||
| 291 | const defaultProviders: ReadonlyArray<ServerProvider> = [ | ||
| 292 | { | ||
| 293 | instanceId: ProviderInstanceId.make("codex"), | ||
| 294 | driver: ProviderDriverKind.make("codex"), | ||
| 295 | enabled: true, | ||
| 296 | installed: true, | ||
| 297 | version: "0.116.0", | ||
| 298 | status: "ready", | ||
| 299 | auth: { status: "authenticated" }, | ||
| 300 | checkedAt: "2026-01-01T00:00:00.000Z", | ||
| 301 | models: [], | ||
| 302 | slashCommands: [], | ||
| 303 | skills: [], | ||
| 304 | }, | ||
| 305 | ]; | ||
| 306 | |||
| 307 | const baseEnvironment = { | ||
| 308 | environmentId: EnvironmentId.make("environment-local"), | ||
| 309 | label: "Local environment", | ||
| 310 | platform: { | ||
| 311 | os: "darwin" as const, | ||
| 312 | arch: "arm64" as const, | ||
| 313 | }, | ||
| 314 | serverVersion: "0.0.0-test", | ||
| 315 | capabilities: { | ||
| 316 | repositoryIdentity: true, | ||
| 317 | }, | ||
| 318 | }; | ||
| 319 | |||
| 320 | const baseServerConfig: ServerConfig = { | ||
| 321 | environment: baseEnvironment, | ||
| 322 | auth: { | ||
| 323 | policy: "loopback-browser", | ||
| 324 | bootstrapMethods: ["one-time-token"], | ||
| 325 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | ||
| 326 | sessionCookieName: "t3_session", | ||
| 327 | }, | ||
| 328 | cwd: "/tmp/workspace", | ||
| 329 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | ||
| 330 | keybindings: [], | ||
| 331 | issues: [], | ||
| 332 | providers: defaultProviders, | ||
| 333 | availableEditors: ["cursor"], | ||
| 334 | observability: { | ||
| 335 | logsDirectoryPath: "/tmp/workspace/.config/logs", | ||
| 336 | localTracingEnabled: true, | ||
| 337 | otlpTracesEnabled: false, | ||
| 338 | otlpMetricsEnabled: false, | ||
| 339 | }, | ||
| 340 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 341 | }; | ||
| 342 | |||
| 343 | const baseGitStatus: VcsStatusResult = { | ||
| 344 | isRepo: true, | ||
| 345 | hasPrimaryRemote: true, | ||
| 346 | isDefaultRef: false, | ||
| 347 | refName: "feature/streamed", | ||
| 348 | hasWorkingTreeChanges: false, | ||
| 349 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 350 | hasUpstream: true, | ||
| 351 | aheadCount: 0, | ||
| 352 | behindCount: 0, | ||
| 353 | pr: null, | ||
| 354 | }; | ||
| 355 | |||
| 356 | beforeEach(() => { | 42 | beforeEach(() => { |
| 357 | vi.resetModules(); | 43 | vi.resetModules(); |
| 358 | vi.clearAllMocks(); | 44 | vi.clearAllMocks(); |
| 359 | showContextMenuFallbackMock.mockReset(); | 45 | if (globalThis.window === undefined) { |
| 360 | terminalAttachListeners.clear(); | 46 | Object.defineProperty(globalThis, "window", { |
| 361 | terminalMetadataListeners.clear(); | 47 | configurable: true, |
| 362 | shellStreamListeners.clear(); | 48 | value: globalThis, |
| 363 | gitStatusListeners.clear(); | 49 | }); |
| 364 | const testWindow = getWindowForTest(); | 50 | } |
| 365 | Reflect.deleteProperty(testWindow, "desktopBridge"); | 51 | Reflect.deleteProperty(testWindow(), "desktopBridge"); |
| 366 | Object.defineProperty(testWindow, "localStorage", { | 52 | Reflect.deleteProperty(testWindow(), "nativeApi"); |
| 53 | Object.defineProperty(testWindow(), "localStorage", { | ||
| 367 | configurable: true, | 54 | configurable: true, |
| 368 | value: createLocalStorageStub(), | 55 | value: createLocalStorageStub(), |
| 369 | }); | 56 | }); |
| ... | @@ -373,411 +60,72 @@ afterEach(() => { | ... | @@ -373,411 +60,72 @@ afterEach(() => { |
| 373 | vi.restoreAllMocks(); | 60 | vi.restoreAllMocks(); |
| 374 | }); | 61 | }); |
| 375 | 62 | ||
| 376 | describe("wsApi", () => { | 63 | describe("LocalApi", () => { |
| 377 | it("forwards server config fetches directly to the RPC client", async () => { | 64 | it("keeps backend operations unavailable in the browser facade", async () => { |
| 378 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); | ||
| 379 | const { createLocalApi } = await import("./localApi"); | ||
| 380 | |||
| 381 | const api = createLocalApi(rpcClientMock as never); | ||
| 382 | |||
| 383 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); | ||
| 384 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); | ||
| 385 | expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled(); | ||
| 386 | expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); | ||
| 387 | }); | ||
| 388 | |||
| 389 | it("forwards terminal attach, metadata, and shell stream events", async () => { | ||
| 390 | const { createEnvironmentApi } = await import("./environmentApi"); | ||
| 391 | |||
| 392 | const api = createEnvironmentApi(rpcClientMock as never); | ||
| 393 | const onTerminalAttachEvent = vi.fn(); | ||
| 394 | const onTerminalMetadataEvent = vi.fn(); | ||
| 395 | const onShellEvent = vi.fn(); | ||
| 396 | |||
| 397 | api.terminal.attach({ threadId: "thread-1", terminalId: "terminal-1" }, onTerminalAttachEvent); | ||
| 398 | api.terminal.onMetadata(onTerminalMetadataEvent); | ||
| 399 | api.orchestration.subscribeShell(onShellEvent); | ||
| 400 | |||
| 401 | const terminalAttachEvent = { | ||
| 402 | threadId: "thread-1", | ||
| 403 | terminalId: "terminal-1", | ||
| 404 | type: "output", | ||
| 405 | data: "hello", | ||
| 406 | } satisfies TerminalAttachStreamEvent; | ||
| 407 | emitEvent(terminalAttachListeners, terminalAttachEvent); | ||
| 408 | |||
| 409 | const terminalMetadataEvent = { | ||
| 410 | type: "upsert", | ||
| 411 | terminal: { | ||
| 412 | threadId: "thread-1", | ||
| 413 | terminalId: "terminal-1", | ||
| 414 | cwd: "/tmp/workspace", | ||
| 415 | worktreePath: null, | ||
| 416 | status: "running", | ||
| 417 | pid: 123, | ||
| 418 | exitCode: null, | ||
| 419 | exitSignal: null, | ||
| 420 | hasRunningSubprocess: true, | ||
| 421 | label: "terminal-1", | ||
| 422 | updatedAt: "2026-02-24T00:00:00.000Z", | ||
| 423 | }, | ||
| 424 | } satisfies TerminalMetadataStreamEvent; | ||
| 425 | emitEvent(terminalMetadataListeners, terminalMetadataEvent); | ||
| 426 | |||
| 427 | const shellEvent = { | ||
| 428 | kind: "project-upserted" as const, | ||
| 429 | sequence: 1, | ||
| 430 | project: { | ||
| 431 | id: ProjectId.make("project-1"), | ||
| 432 | title: "Project", | ||
| 433 | workspaceRoot: "/tmp/workspace", | ||
| 434 | defaultModelSelection: { | ||
| 435 | instanceId: ProviderInstanceId.make("codex"), | ||
| 436 | model: "gpt-5-codex", | ||
| 437 | }, | ||
| 438 | scripts: [], | ||
| 439 | createdAt: "2026-02-24T00:00:00.000Z", | ||
| 440 | updatedAt: "2026-02-24T00:00:00.000Z", | ||
| 441 | }, | ||
| 442 | } satisfies OrchestrationShellStreamItem; | ||
| 443 | emitEvent(shellStreamListeners, shellEvent); | ||
| 444 | |||
| 445 | expect(onTerminalAttachEvent).toHaveBeenCalledWith(terminalAttachEvent); | ||
| 446 | expect(onTerminalMetadataEvent).toHaveBeenCalledWith(terminalMetadataEvent); | ||
| 447 | expect(onShellEvent).toHaveBeenCalledWith(shellEvent); | ||
| 448 | }); | ||
| 449 | |||
| 450 | it("forwards git status stream events", async () => { | ||
| 451 | const { createEnvironmentApi } = await import("./environmentApi"); | ||
| 452 | |||
| 453 | const api = createEnvironmentApi(rpcClientMock as never); | ||
| 454 | const onStatus = vi.fn(); | ||
| 455 | |||
| 456 | api.vcs.onStatus({ cwd: "/repo" }, onStatus); | ||
| 457 | |||
| 458 | const gitStatus = baseGitStatus; | ||
| 459 | emitEvent(gitStatusListeners, gitStatus); | ||
| 460 | |||
| 461 | expect(rpcClientMock.vcs.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); | ||
| 462 | expect(onStatus).toHaveBeenCalledWith(gitStatus); | ||
| 463 | }); | ||
| 464 | |||
| 465 | it("forwards git status refreshes directly to the RPC client", async () => { | ||
| 466 | rpcClientMock.vcs.refreshStatus.mockResolvedValue(baseGitStatus); | ||
| 467 | const { createEnvironmentApi } = await import("./environmentApi"); | ||
| 468 | |||
| 469 | const api = createEnvironmentApi(rpcClientMock as never); | ||
| 470 | |||
| 471 | await api.vcs.refreshStatus({ cwd: "/repo" }); | ||
| 472 | |||
| 473 | expect(rpcClientMock.vcs.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | ||
| 474 | }); | ||
| 475 | |||
| 476 | it("forwards shell stream subscription options to the RPC client", async () => { | ||
| 477 | const { createEnvironmentApi } = await import("./environmentApi"); | ||
| 478 | |||
| 479 | const api = createEnvironmentApi(rpcClientMock as never); | ||
| 480 | const onShellEvent = vi.fn(); | ||
| 481 | const onResubscribe = vi.fn(); | ||
| 482 | |||
| 483 | api.orchestration.subscribeShell(onShellEvent, { onResubscribe }); | ||
| 484 | |||
| 485 | expect(rpcClientMock.orchestration.subscribeShell).toHaveBeenCalledWith(onShellEvent, { | ||
| 486 | onResubscribe, | ||
| 487 | }); | ||
| 488 | }); | ||
| 489 | |||
| 490 | it("sends orchestration dispatch commands as the direct RPC payload", async () => { | ||
| 491 | rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); | ||
| 492 | const { createEnvironmentApi } = await import("./environmentApi"); | ||
| 493 | |||
| 494 | const api = createEnvironmentApi(rpcClientMock as never); | ||
| 495 | const command = { | ||
| 496 | type: "project.create", | ||
| 497 | commandId: CommandId.make("cmd-1"), | ||
| 498 | projectId: ProjectId.make("project-1"), | ||
| 499 | title: "Project", | ||
| 500 | workspaceRoot: "/tmp/project", | ||
| 501 | defaultModelSelection: { | ||
| 502 | instanceId: ProviderInstanceId.make("codex"), | ||
| 503 | model: "gpt-5-codex", | ||
| 504 | }, | ||
| 505 | createdAt: "2026-02-24T00:00:00.000Z", | ||
| 506 | } as const; | ||
| 507 | await api.orchestration.dispatchCommand(command); | ||
| 508 | |||
| 509 | expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command); | ||
| 510 | }); | ||
| 511 | |||
| 512 | it("forwards workspace file writes to the project RPC", async () => { | ||
| 513 | rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); | ||
| 514 | const { createEnvironmentApi } = await import("./environmentApi"); | ||
| 515 | |||
| 516 | const api = createEnvironmentApi(rpcClientMock as never); | ||
| 517 | await api.projects.writeFile({ | ||
| 518 | cwd: "/tmp/project", | ||
| 519 | relativePath: "plan.md", | ||
| 520 | contents: "# Plan\n", | ||
| 521 | }); | ||
| 522 | |||
| 523 | expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({ | ||
| 524 | cwd: "/tmp/project", | ||
| 525 | relativePath: "plan.md", | ||
| 526 | contents: "# Plan\n", | ||
| 527 | }); | ||
| 528 | }); | ||
| 529 | |||
| 530 | it("forwards filesystem browse requests to the RPC client", async () => { | ||
| 531 | rpcClientMock.filesystem.browse.mockResolvedValue({ | ||
| 532 | parentPath: "/tmp/project/", | ||
| 533 | entries: [], | ||
| 534 | }); | ||
| 535 | const { createEnvironmentApi } = await import("./environmentApi"); | ||
| 536 | |||
| 537 | const api = createEnvironmentApi(rpcClientMock as never); | ||
| 538 | await api.filesystem.browse({ | ||
| 539 | partialPath: "/tmp/project/", | ||
| 540 | cwd: "/tmp/project", | ||
| 541 | }); | ||
| 542 | |||
| 543 | expect(rpcClientMock.filesystem.browse).toHaveBeenCalledWith({ | ||
| 544 | partialPath: "/tmp/project/", | ||
| 545 | cwd: "/tmp/project", | ||
| 546 | }); | ||
| 547 | }); | ||
| 548 | |||
| 549 | it("forwards full-thread diff requests to the orchestration RPC", async () => { | ||
| 550 | rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); | ||
| 551 | const { createEnvironmentApi } = await import("./environmentApi"); | ||
| 552 | |||
| 553 | const api = createEnvironmentApi(rpcClientMock as never); | ||
| 554 | await api.orchestration.getFullThreadDiff({ | ||
| 555 | threadId: ThreadId.make("thread-1"), | ||
| 556 | toTurnCount: 1, | ||
| 557 | }); | ||
| 558 | |||
| 559 | expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({ | ||
| 560 | threadId: "thread-1", | ||
| 561 | toTurnCount: 1, | ||
| 562 | }); | ||
| 563 | }); | ||
| 564 | |||
| 565 | it("forwards provider refreshes directly to the RPC client", async () => { | ||
| 566 | const nextProviders: ReadonlyArray<ServerProvider> = [ | ||
| 567 | { | ||
| 568 | ...defaultProviders[0]!, | ||
| 569 | checkedAt: "2026-01-03T00:00:00.000Z", | ||
| 570 | }, | ||
| 571 | ]; | ||
| 572 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); | ||
| 573 | const { createLocalApi } = await import("./localApi"); | ||
| 574 | |||
| 575 | const api = createLocalApi(rpcClientMock as never); | ||
| 576 | |||
| 577 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); | ||
| 578 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); | ||
| 579 | }); | ||
| 580 | |||
| 581 | it("forwards provider updates directly to the RPC client", async () => { | ||
| 582 | const nextProviders: ReadonlyArray<ServerProvider> = [ | ||
| 583 | { | ||
| 584 | ...defaultProviders[0]!, | ||
| 585 | updateState: { | ||
| 586 | status: "succeeded", | ||
| 587 | startedAt: "2026-01-03T00:00:00.000Z", | ||
| 588 | finishedAt: "2026-01-03T00:00:01.000Z", | ||
| 589 | message: "Provider updated.", | ||
| 590 | output: null, | ||
| 591 | }, | ||
| 592 | }, | ||
| 593 | ]; | ||
| 594 | rpcClientMock.server.updateProvider.mockResolvedValue({ providers: nextProviders }); | ||
| 595 | const { createLocalApi } = await import("./localApi"); | ||
| 596 | |||
| 597 | const api = createLocalApi(rpcClientMock as never); | ||
| 598 | |||
| 599 | await expect( | ||
| 600 | api.server.updateProvider({ provider: ProviderDriverKind.make("codex") }), | ||
| 601 | ).resolves.toEqual({ | ||
| 602 | providers: nextProviders, | ||
| 603 | }); | ||
| 604 | expect(rpcClientMock.server.updateProvider).toHaveBeenCalledWith({ | ||
| 605 | provider: ProviderDriverKind.make("codex"), | ||
| 606 | }); | ||
| 607 | }); | ||
| 608 | |||
| 609 | it("forwards server settings updates directly to the RPC client", async () => { | ||
| 610 | const nextSettings = { | ||
| 611 | ...DEFAULT_SERVER_SETTINGS, | ||
| 612 | enableAssistantStreaming: true, | ||
| 613 | }; | ||
| 614 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); | ||
| 615 | const { createLocalApi } = await import("./localApi"); | 65 | const { createLocalApi } = await import("./localApi"); |
| 66 | const api = createLocalApi(); | ||
| 616 | 67 | ||
| 617 | const api = createLocalApi(rpcClientMock as never); | 68 | await expect(api.server.getConfig()).rejects.toThrow( |
| 618 | 69 | "Local backend API is unavailable before a backend is paired.", | |
| 619 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( | ||
| 620 | nextSettings, | ||
| 621 | ); | 70 | ); |
| 622 | expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({ | 71 | await expect(api.shell.openInEditor("/tmp", "cursor")).rejects.toThrow( |
| 623 | enableAssistantStreaming: true, | 72 | "Local backend API is unavailable before a backend is paired.", |
| 624 | }); | ||
| 625 | }); | ||
| 626 | |||
| 627 | it("forwards context menu metadata to the desktop bridge", async () => { | ||
| 628 | const showContextMenu = vi.fn().mockResolvedValue("delete"); | ||
| 629 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); | ||
| 630 | |||
| 631 | const { createLocalApi } = await import("./localApi"); | ||
| 632 | const api = createLocalApi(rpcClientMock as never); | ||
| 633 | const items = [{ id: "delete", label: "Delete" }] as const; | ||
| 634 | |||
| 635 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); | ||
| 636 | expect(showContextMenu).toHaveBeenCalledWith(items, undefined); | ||
| 637 | }); | ||
| 638 | |||
| 639 | it("forwards folder picker options to the desktop bridge", async () => { | ||
| 640 | const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); | ||
| 641 | getWindowForTest().desktopBridge = makeDesktopBridge({ pickFolder }); | ||
| 642 | |||
| 643 | const { createLocalApi } = await import("./localApi"); | ||
| 644 | const api = createLocalApi(rpcClientMock as never); | ||
| 645 | |||
| 646 | await expect(api.dialogs.pickFolder({ initialPath: "/tmp/workspace" })).resolves.toBe( | ||
| 647 | "/tmp/project", | ||
| 648 | ); | 73 | ); |
| 649 | expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp/workspace" }); | ||
| 650 | }); | 74 | }); |
| 651 | 75 | ||
| 652 | it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { | 76 | it("uses the browser context-menu fallback without a desktop bridge", async () => { |
| 653 | showContextMenuFallbackMock.mockResolvedValue("rename"); | 77 | showContextMenuFallbackMock.mockResolvedValue("rename"); |
| 654 | const { createLocalApi } = await import("./localApi"); | 78 | const { createLocalApi } = await import("./localApi"); |
| 655 | |||
| 656 | const api = createLocalApi(rpcClientMock as never); | ||
| 657 | const items = [{ id: "rename", label: "Rename" }] as const; | 79 | const items = [{ id: "rename", label: "Rename" }] as const; |
| 658 | 80 | ||
| 659 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); | 81 | await expect(createLocalApi().contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); |
| 660 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); | 82 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); |
| 661 | }); | 83 | }); |
| 662 | 84 | ||
| 663 | it("reads and writes persistence through the desktop bridge when available", async () => { | 85 | it("delegates host capabilities and persistence to the desktop bridge", async () => { |
| 664 | const clientSettings = { | 86 | const showContextMenu = vi.fn().mockResolvedValue("delete"); |
| 665 | autoOpenPlanSidebar: false, | 87 | const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); |
| 666 | confirmThreadArchive: true, | 88 | const getClientSettings = vi.fn().mockResolvedValue(DEFAULT_CLIENT_SETTINGS); |
| 667 | confirmThreadDelete: false, | ||
| 668 | dismissedProviderUpdateNotificationKeys: [], | ||
| 669 | diffIgnoreWhitespace: true, | ||
| 670 | diffWordWrap: true, | ||
| 671 | favorites: [], | ||
| 672 | providerModelPreferences: {}, | ||
| 673 | sidebarProjectGroupingMode: "repository_path" as const, | ||
| 674 | sidebarProjectGroupingOverrides: { | ||
| 675 | "environment-local:/tmp/project": "separate" as const, | ||
| 676 | }, | ||
| 677 | sidebarProjectSortOrder: "manual" as const, | ||
| 678 | sidebarThreadSortOrder: "created_at" as const, | ||
| 679 | sidebarThreadPreviewCount: 6, | ||
| 680 | timestampFormat: "24-hour" as const, | ||
| 681 | }; | ||
| 682 | const getClientSettings = vi.fn().mockResolvedValue({ | ||
| 683 | ...clientSettings, | ||
| 684 | }); | ||
| 685 | const setClientSettings = vi.fn().mockResolvedValue(undefined); | 89 | const setClientSettings = vi.fn().mockResolvedValue(undefined); |
| 686 | const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); | 90 | testWindow().desktopBridge = { |
| 687 | const setSavedEnvironmentRegistry = vi.fn().mockResolvedValue(undefined); | 91 | showContextMenu, |
| 688 | const getSavedEnvironmentSecret = vi.fn().mockResolvedValue("bearer-token"); | 92 | pickFolder, |
| 689 | const setSavedEnvironmentSecret = vi.fn().mockResolvedValue(true); | ||
| 690 | const removeSavedEnvironmentSecret = vi.fn().mockResolvedValue(undefined); | ||
| 691 | getWindowForTest().desktopBridge = makeDesktopBridge({ | ||
| 692 | getClientSettings, | 93 | getClientSettings, |
| 693 | setClientSettings, | 94 | setClientSettings, |
| 694 | getSavedEnvironmentRegistry, | 95 | } as unknown as DesktopBridge; |
| 695 | setSavedEnvironmentRegistry, | ||
| 696 | getSavedEnvironmentSecret, | ||
| 697 | setSavedEnvironmentSecret, | ||
| 698 | removeSavedEnvironmentSecret, | ||
| 699 | }); | ||
| 700 | 96 | ||
| 701 | const { createLocalApi } = await import("./localApi"); | 97 | const { createLocalApi } = await import("./localApi"); |
| 702 | const api = createLocalApi(rpcClientMock as never); | 98 | const api = createLocalApi(); |
| 99 | const items = [{ id: "delete", label: "Delete" }] as const; | ||
| 703 | 100 | ||
| 704 | await api.persistence.getClientSettings(); | 101 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); |
| 705 | await api.persistence.setClientSettings(clientSettings); | 102 | await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project"); |
| 706 | await api.persistence.getSavedEnvironmentRegistry(); | 103 | await expect(api.persistence.getClientSettings()).resolves.toEqual(DEFAULT_CLIENT_SETTINGS); |
| 707 | await api.persistence.setSavedEnvironmentRegistry([]); | 104 | await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS); |
| 708 | await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")); | ||
| 709 | await api.persistence.setSavedEnvironmentSecret( | ||
| 710 | EnvironmentId.make("environment-local"), | ||
| 711 | "bearer-token", | ||
| 712 | ); | ||
| 713 | await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); | ||
| 714 | 105 | ||
| 715 | expect(getClientSettings).toHaveBeenCalledWith(); | 106 | expect(showContextMenu).toHaveBeenCalledWith(items, undefined); |
| 716 | expect(setClientSettings).toHaveBeenCalledWith(clientSettings); | 107 | expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp" }); |
| 717 | expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); | 108 | expect(getClientSettings).toHaveBeenCalledTimes(1); |
| 718 | expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); | 109 | expect(setClientSettings).toHaveBeenCalledWith(DEFAULT_CLIENT_SETTINGS); |
| 719 | expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); | ||
| 720 | expect(setSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local", "bearer-token"); | ||
| 721 | expect(removeSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); | ||
| 722 | }); | 110 | }); |
| 723 | 111 | ||
| 724 | it("falls back to browser storage for persistence when the desktop bridge is missing", async () => { | 112 | it("persists client settings in browser storage", async () => { |
| 725 | const { createLocalApi } = await import("./localApi"); | 113 | const { createLocalApi } = await import("./localApi"); |
| 726 | const api = createLocalApi(rpcClientMock as never); | 114 | const api = createLocalApi(); |
| 727 | const clientSettings = { | 115 | const settings = { |
| 728 | autoOpenPlanSidebar: false, | 116 | ...DEFAULT_CLIENT_SETTINGS, |
| 729 | confirmThreadArchive: true, | 117 | timestampFormat: "12-hour" as const, |
| 730 | confirmThreadDelete: false, | ||
| 731 | dismissedProviderUpdateNotificationKeys: [], | ||
| 732 | diffIgnoreWhitespace: true, | ||
| 733 | diffWordWrap: true, | ||
| 734 | favorites: [], | ||
| 735 | providerModelPreferences: {}, | ||
| 736 | sidebarProjectGroupingMode: "repository_path" as const, | ||
| 737 | sidebarProjectGroupingOverrides: { | ||
| 738 | "environment-local:/tmp/project": "separate" as const, | ||
| 739 | }, | ||
| 740 | sidebarProjectSortOrder: "manual" as const, | ||
| 741 | sidebarThreadSortOrder: "created_at" as const, | ||
| 742 | sidebarThreadPreviewCount: 6, | ||
| 743 | timestampFormat: "24-hour" as const, | ||
| 744 | }; | 118 | }; |
| 745 | 119 | ||
| 746 | await api.persistence.setClientSettings(clientSettings); | 120 | await api.persistence.setClientSettings(settings); |
| 747 | await api.persistence.setSavedEnvironmentRegistry([ | 121 | await expect(api.persistence.getClientSettings()).resolves.toEqual(settings); |
| 748 | { | 122 | }); |
| 749 | environmentId: EnvironmentId.make("environment-local"), | ||
| 750 | label: "Primary", | ||
| 751 | httpBaseUrl: "http://localhost:3000", | ||
| 752 | wsBaseUrl: "ws://localhost:3000", | ||
| 753 | createdAt: "2026-04-09T00:00:00.000Z", | ||
| 754 | lastConnectedAt: null, | ||
| 755 | }, | ||
| 756 | ]); | ||
| 757 | await api.persistence.setSavedEnvironmentSecret( | ||
| 758 | EnvironmentId.make("environment-local"), | ||
| 759 | "bearer-token", | ||
| 760 | ); | ||
| 761 | |||
| 762 | await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings); | ||
| 763 | await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ | ||
| 764 | { | ||
| 765 | environmentId: EnvironmentId.make("environment-local"), | ||
| 766 | label: "Primary", | ||
| 767 | httpBaseUrl: "http://localhost:3000", | ||
| 768 | wsBaseUrl: "ws://localhost:3000", | ||
| 769 | createdAt: "2026-04-09T00:00:00.000Z", | ||
| 770 | lastConnectedAt: null, | ||
| 771 | }, | ||
| 772 | ]); | ||
| 773 | await expect( | ||
| 774 | api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")), | ||
| 775 | ).resolves.toBe("bearer-token"); | ||
| 776 | 123 | ||
| 777 | await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); | 124 | it("prefers the native LocalApi when one is injected", async () => { |
| 125 | const nativeApi = { dialogs: {} }; | ||
| 126 | testWindow().nativeApi = nativeApi as never; | ||
| 127 | const { readLocalApi } = await import("./localApi"); | ||
| 778 | 128 | ||
| 779 | await expect( | 129 | expect(readLocalApi()).toBe(nativeApi); |
| 780 | api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")), | ||
| 781 | ).resolves.toBeNull(); | ||
| 782 | }); | 130 | }); |
| 783 | }); | 131 | }); |
apps/web/src/localApi.ts+18-115| ... | @@ -1,30 +1,8 @@ | ... | @@ -1,30 +1,8 @@ |
| 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; | 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; |
| 2 | import type { WsRpcClient } from "@t3tools/client-runtime"; | ||
| 3 | 2 | ||
| 4 | import { resetVcsStatusStateForTests } from "./lib/vcsStatusState"; | ||
| 5 | import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState"; | ||
| 6 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; | 3 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; |
| 7 | import { resetServerStateForTests } from "./rpc/serverState"; | ||
| 8 | import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; | ||
| 9 | import { | ||
| 10 | resetSavedEnvironmentRegistryStoreForTests, | ||
| 11 | resetSavedEnvironmentRuntimeStoreForTests, | ||
| 12 | } from "./environments/runtime"; | ||
| 13 | import { | ||
| 14 | getPrimaryEnvironmentConnection, | ||
| 15 | resetEnvironmentServiceForTests, | ||
| 16 | } from "./environments/runtime"; | ||
| 17 | import { getPrimaryKnownEnvironment } from "./environments/primary"; | ||
| 18 | import { showContextMenuFallback } from "./contextMenuFallback"; | 4 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 19 | import { | 5 | import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; |
| 20 | readBrowserClientSettings, | ||
| 21 | readBrowserSavedEnvironmentRegistry, | ||
| 22 | readBrowserSavedEnvironmentSecret, | ||
| 23 | removeBrowserSavedEnvironmentSecret, | ||
| 24 | writeBrowserClientSettings, | ||
| 25 | writeBrowserSavedEnvironmentRegistry, | ||
| 26 | writeBrowserSavedEnvironmentSecret, | ||
| 27 | } from "./clientPersistenceStorage"; | ||
| 28 | 6 | ||
| 29 | let cachedApi: LocalApi | undefined; | 7 | let cachedApi: LocalApi | undefined; |
| 30 | 8 | ||
| ... | @@ -32,7 +10,7 @@ function unavailableLocalBackendError(): Error { | ... | @@ -32,7 +10,7 @@ function unavailableLocalBackendError(): Error { |
| 32 | return new Error("Local backend API is unavailable before a backend is paired."); | 10 | return new Error("Local backend API is unavailable before a backend is paired."); |
| 33 | } | 11 | } |
| 34 | 12 | ||
| 35 | function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { | 13 | function createBrowserLocalApi(): LocalApi { |
| 36 | return { | 14 | return { |
| 37 | dialogs: { | 15 | dialogs: { |
| 38 | pickFolder: async (options) => { | 16 | pickFolder: async (options) => { |
| ... | @@ -47,10 +25,7 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { | ... | @@ -47,10 +25,7 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { |
| 47 | }, | 25 | }, |
| 48 | }, | 26 | }, |
| 49 | shell: { | 27 | shell: { |
| 50 | openInEditor: (cwd, editor) => | 28 | openInEditor: () => Promise.reject(unavailableLocalBackendError()), |
| 51 | rpcClient | ||
| 52 | ? rpcClient.shell.openInEditor({ cwd, editor }) | ||
| 53 | : Promise.reject(unavailableLocalBackendError()), | ||
| 54 | openExternal: async (url) => { | 29 | openExternal: async (url) => { |
| 55 | if (window.desktopBridge) { | 30 | if (window.desktopBridge) { |
| 56 | const opened = await window.desktopBridge.openExternal(url); | 31 | const opened = await window.desktopBridge.openExternal(url); |
| ... | @@ -87,88 +62,26 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { | ... | @@ -87,88 +62,26 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { |
| 87 | } | 62 | } |
| 88 | writeBrowserClientSettings(settings); | 63 | writeBrowserClientSettings(settings); |
| 89 | }, | 64 | }, |
| 90 | getSavedEnvironmentRegistry: async () => { | ||
| 91 | if (window.desktopBridge) { | ||
| 92 | return window.desktopBridge.getSavedEnvironmentRegistry(); | ||
| 93 | } | ||
| 94 | return readBrowserSavedEnvironmentRegistry(); | ||
| 95 | }, | ||
| 96 | setSavedEnvironmentRegistry: async (records) => { | ||
| 97 | if (window.desktopBridge) { | ||
| 98 | return window.desktopBridge.setSavedEnvironmentRegistry(records); | ||
| 99 | } | ||
| 100 | writeBrowserSavedEnvironmentRegistry(records); | ||
| 101 | }, | ||
| 102 | getSavedEnvironmentSecret: async (environmentId) => { | ||
| 103 | if (window.desktopBridge) { | ||
| 104 | return window.desktopBridge.getSavedEnvironmentSecret(environmentId); | ||
| 105 | } | ||
| 106 | return readBrowserSavedEnvironmentSecret(environmentId); | ||
| 107 | }, | ||
| 108 | setSavedEnvironmentSecret: async (environmentId, secret) => { | ||
| 109 | if (window.desktopBridge) { | ||
| 110 | return window.desktopBridge.setSavedEnvironmentSecret(environmentId, secret); | ||
| 111 | } | ||
| 112 | return writeBrowserSavedEnvironmentSecret(environmentId, secret); | ||
| 113 | }, | ||
| 114 | removeSavedEnvironmentSecret: async (environmentId) => { | ||
| 115 | if (window.desktopBridge) { | ||
| 116 | return window.desktopBridge.removeSavedEnvironmentSecret(environmentId); | ||
| 117 | } | ||
| 118 | removeBrowserSavedEnvironmentSecret(environmentId); | ||
| 119 | }, | ||
| 120 | }, | 65 | }, |
| 121 | server: { | 66 | server: { |
| 122 | getConfig: () => | 67 | getConfig: () => Promise.reject(unavailableLocalBackendError()), |
| 123 | rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), | 68 | refreshProviders: () => Promise.reject(unavailableLocalBackendError()), |
| 124 | refreshProviders: () => | 69 | updateProvider: () => Promise.reject(unavailableLocalBackendError()), |
| 125 | rpcClient | 70 | upsertKeybinding: () => Promise.reject(unavailableLocalBackendError()), |
| 126 | ? rpcClient.server.refreshProviders() | 71 | removeKeybinding: () => Promise.reject(unavailableLocalBackendError()), |
| 127 | : Promise.reject(unavailableLocalBackendError()), | 72 | getSettings: () => Promise.reject(unavailableLocalBackendError()), |
| 128 | updateProvider: (input) => | 73 | updateSettings: () => Promise.reject(unavailableLocalBackendError()), |
| 129 | rpcClient | 74 | discoverSourceControl: () => Promise.reject(unavailableLocalBackendError()), |
| 130 | ? rpcClient.server.updateProvider(input) | 75 | getTraceDiagnostics: () => Promise.reject(unavailableLocalBackendError()), |
| 131 | : Promise.reject(unavailableLocalBackendError()), | 76 | getProcessDiagnostics: () => Promise.reject(unavailableLocalBackendError()), |
| 132 | upsertKeybinding: (input) => | 77 | getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), |
| 133 | rpcClient | 78 | signalProcess: () => Promise.reject(unavailableLocalBackendError()), |
| 134 | ? rpcClient.server.upsertKeybinding(input) | ||
| 135 | : Promise.reject(unavailableLocalBackendError()), | ||
| 136 | removeKeybinding: (input) => | ||
| 137 | rpcClient | ||
| 138 | ? rpcClient.server.removeKeybinding(input) | ||
| 139 | : Promise.reject(unavailableLocalBackendError()), | ||
| 140 | getSettings: () => | ||
| 141 | rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()), | ||
| 142 | updateSettings: (patch) => | ||
| 143 | rpcClient | ||
| 144 | ? rpcClient.server.updateSettings(patch) | ||
| 145 | : Promise.reject(unavailableLocalBackendError()), | ||
| 146 | discoverSourceControl: () => | ||
| 147 | rpcClient | ||
| 148 | ? rpcClient.server.discoverSourceControl() | ||
| 149 | : Promise.reject(unavailableLocalBackendError()), | ||
| 150 | getTraceDiagnostics: () => | ||
| 151 | rpcClient | ||
| 152 | ? rpcClient.server.getTraceDiagnostics() | ||
| 153 | : Promise.reject(unavailableLocalBackendError()), | ||
| 154 | getProcessDiagnostics: () => | ||
| 155 | rpcClient | ||
| 156 | ? rpcClient.server.getProcessDiagnostics() | ||
| 157 | : Promise.reject(unavailableLocalBackendError()), | ||
| 158 | getProcessResourceHistory: (input) => | ||
| 159 | rpcClient | ||
| 160 | ? rpcClient.server.getProcessResourceHistory(input) | ||
| 161 | : Promise.reject(unavailableLocalBackendError()), | ||
| 162 | signalProcess: (input) => | ||
| 163 | rpcClient | ||
| 164 | ? rpcClient.server.signalProcess(input) | ||
| 165 | : Promise.reject(unavailableLocalBackendError()), | ||
| 166 | }, | 79 | }, |
| 167 | }; | 80 | }; |
| 168 | } | 81 | } |
| 169 | 82 | ||
| 170 | export function createLocalApi(rpcClient: WsRpcClient): LocalApi { | 83 | export function createLocalApi(): LocalApi { |
| 171 | return createBrowserLocalApi(rpcClient); | 84 | return createBrowserLocalApi(); |
| 172 | } | 85 | } |
| 173 | 86 | ||
| 174 | export function readLocalApi(): LocalApi | undefined { | 87 | export function readLocalApi(): LocalApi | undefined { |
| ... | @@ -180,10 +93,7 @@ export function readLocalApi(): LocalApi | undefined { | ... | @@ -180,10 +93,7 @@ export function readLocalApi(): LocalApi | undefined { |
| 180 | return cachedApi; | 93 | return cachedApi; |
| 181 | } | 94 | } |
| 182 | 95 | ||
| 183 | const primaryEnvironment = getPrimaryKnownEnvironment(); | 96 | cachedApi = createBrowserLocalApi(); |
| 184 | cachedApi = primaryEnvironment | ||
| 185 | ? createLocalApi(getPrimaryEnvironmentConnection().client) | ||
| 186 | : createBrowserLocalApi(); | ||
| 187 | return cachedApi; | 97 | return cachedApi; |
| 188 | } | 98 | } |
| 189 | 99 | ||
| ... | @@ -199,12 +109,5 @@ export async function __resetLocalApiForTests() { | ... | @@ -199,12 +109,5 @@ export async function __resetLocalApiForTests() { |
| 199 | cachedApi = undefined; | 109 | cachedApi = undefined; |
| 200 | const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); | 110 | const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); |
| 201 | __resetClientSettingsPersistenceForTests(); | 111 | __resetClientSettingsPersistenceForTests(); |
| 202 | await resetEnvironmentServiceForTests(); | ||
| 203 | resetVcsStatusStateForTests(); | ||
| 204 | resetSourceControlDiscoveryStateForTests(); | ||
| 205 | resetRequestLatencyStateForTests(); | 112 | resetRequestLatencyStateForTests(); |
| 206 | resetSavedEnvironmentRegistryStoreForTests(); | ||
| 207 | resetSavedEnvironmentRuntimeStoreForTests(); | ||
| 208 | resetServerStateForTests(); | ||
| 209 | resetWsConnectionStateForTests(); | ||
| 210 | } | 113 | } |
apps/web/src/logicalProject.ts+30-18| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; | 1 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; | 3 | import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 3 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; | 4 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 4 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; | 5 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; |
| 5 | import type { Project } from "./types"; | ||
| 6 | 6 | ||
| 7 | export interface ProjectGroupingSettings { | 7 | export interface ProjectGroupingSettings { |
| 8 | sidebarProjectGroupingMode: SidebarProjectGroupingMode; | 8 | sidebarProjectGroupingMode: SidebarProjectGroupingMode; |
| ... | @@ -33,14 +33,14 @@ function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): | ... | @@ -33,14 +33,14 @@ function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | function deriveRepositoryRelativeProjectPath( | 35 | function deriveRepositoryRelativeProjectPath( |
| 36 | project: Pick<Project, "cwd" | "repositoryIdentity">, | 36 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, |
| 37 | ): string | null { | 37 | ): string | null { |
| 38 | const rootPath = project.repositoryIdentity?.rootPath?.trim(); | 38 | const rootPath = project.repositoryIdentity?.rootPath?.trim(); |
| 39 | if (!rootPath) { | 39 | if (!rootPath) { |
| 40 | return null; | 40 | return null; |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd); | 43 | const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); |
| 44 | const normalizedRootPath = normalizeProjectPathForComparison(rootPath); | 44 | const normalizedRootPath = normalizeProjectPathForComparison(rootPath); |
| 45 | if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { | 45 | if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { |
| 46 | return null; | 46 | return null; |
| ... | @@ -63,25 +63,28 @@ export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: str | ... | @@ -63,25 +63,28 @@ export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: str |
| 63 | return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; | 63 | return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; |
| 64 | } | 64 | } |
| 65 | 65 | ||
| 66 | export function derivePhysicalProjectKey(project: Pick<Project, "environmentId" | "cwd">): string { | 66 | export function derivePhysicalProjectKey( |
| 67 | return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd); | 67 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, |
| 68 | ): string { | ||
| 69 | return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); | ||
| 68 | } | 70 | } |
| 69 | 71 | ||
| 70 | export function deriveProjectGroupingOverrideKey( | 72 | export function deriveProjectGroupingOverrideKey( |
| 71 | project: Pick<Project, "environmentId" | "cwd">, | 73 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, |
| 72 | ): string { | 74 | ): string { |
| 73 | return derivePhysicalProjectKey(project); | 75 | return derivePhysicalProjectKey(project); |
| 74 | } | 76 | } |
| 75 | 77 | ||
| 76 | // Key under which a project's manual sort order (projectOrder) is stored. | 78 | // Key under which a project's manual sort order (projectOrder) is stored. |
| 77 | // Must stay aligned with the writer side in `uiStateStore.syncProjects` and | 79 | // Must stay aligned with the drag handlers and readers in `Sidebar`. |
| 78 | // the drag handlers in `Sidebar` so readers and writers agree. | 80 | export function getProjectOrderKey( |
| 79 | export function getProjectOrderKey(project: Pick<Project, "environmentId" | "cwd">): string { | 81 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, |
| 82 | ): string { | ||
| 80 | return derivePhysicalProjectKey(project); | 83 | return derivePhysicalProjectKey(project); |
| 81 | } | 84 | } |
| 82 | 85 | ||
| 83 | export function resolveProjectGroupingMode( | 86 | export function resolveProjectGroupingMode( |
| 84 | project: Pick<Project, "environmentId" | "cwd">, | 87 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, |
| 85 | settings: ProjectGroupingSettings, | 88 | settings: ProjectGroupingSettings, |
| 86 | ): SidebarProjectGroupingMode { | 89 | ): SidebarProjectGroupingMode { |
| 87 | return ( | 90 | return ( |
| ... | @@ -91,7 +94,7 @@ export function resolveProjectGroupingMode( | ... | @@ -91,7 +94,7 @@ export function resolveProjectGroupingMode( |
| 91 | } | 94 | } |
| 92 | 95 | ||
| 93 | function deriveRepositoryScopedKey( | 96 | function deriveRepositoryScopedKey( |
| 94 | project: Pick<Project, "cwd" | "repositoryIdentity">, | 97 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, |
| 95 | groupingMode: SidebarProjectGroupingMode, | 98 | groupingMode: SidebarProjectGroupingMode, |
| 96 | ): string | null { | 99 | ): string | null { |
| 97 | const canonicalKey = project.repositoryIdentity?.canonicalKey; | 100 | const canonicalKey = project.repositoryIdentity?.canonicalKey; |
| ... | @@ -114,7 +117,10 @@ function deriveRepositoryScopedKey( | ... | @@ -114,7 +117,10 @@ function deriveRepositoryScopedKey( |
| 114 | } | 117 | } |
| 115 | 118 | ||
| 116 | export function deriveLogicalProjectKey( | 119 | export function deriveLogicalProjectKey( |
| 117 | project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity">, | 120 | project: Pick< |
| 121 | EnvironmentProject, | ||
| 122 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | ||
| 123 | >, | ||
| 118 | options?: { | 124 | options?: { |
| 119 | groupingMode?: SidebarProjectGroupingMode; | 125 | groupingMode?: SidebarProjectGroupingMode; |
| 120 | }, | 126 | }, |
| ... | @@ -132,7 +138,10 @@ export function deriveLogicalProjectKey( | ... | @@ -132,7 +138,10 @@ export function deriveLogicalProjectKey( |
| 132 | } | 138 | } |
| 133 | 139 | ||
| 134 | export function deriveLogicalProjectKeyFromSettings( | 140 | export function deriveLogicalProjectKeyFromSettings( |
| 135 | project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity">, | 141 | project: Pick< |
| 142 | EnvironmentProject, | ||
| 143 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | ||
| 144 | >, | ||
| 136 | settings: ProjectGroupingSettings, | 145 | settings: ProjectGroupingSettings, |
| 137 | ): string { | 146 | ): string { |
| 138 | return deriveLogicalProjectKey(project, { | 147 | return deriveLogicalProjectKey(project, { |
| ... | @@ -142,7 +151,10 @@ export function deriveLogicalProjectKeyFromSettings( | ... | @@ -142,7 +151,10 @@ export function deriveLogicalProjectKeyFromSettings( |
| 142 | 151 | ||
| 143 | export function deriveLogicalProjectKeyFromRef( | 152 | export function deriveLogicalProjectKeyFromRef( |
| 144 | projectRef: ScopedProjectRef, | 153 | projectRef: ScopedProjectRef, |
| 145 | project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity"> | null | undefined, | 154 | project: |
| 155 | | Pick<EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"> | ||
| 156 | | null | ||
| 157 | | undefined, | ||
| 146 | options?: { | 158 | options?: { |
| 147 | groupingMode?: SidebarProjectGroupingMode; | 159 | groupingMode?: SidebarProjectGroupingMode; |
| 148 | }, | 160 | }, |
| ... | @@ -151,8 +163,8 @@ export function deriveLogicalProjectKeyFromRef( | ... | @@ -151,8 +163,8 @@ export function deriveLogicalProjectKeyFromRef( |
| 151 | } | 163 | } |
| 152 | 164 | ||
| 153 | export function deriveProjectGroupLabel(input: { | 165 | export function deriveProjectGroupLabel(input: { |
| 154 | representative: Pick<Project, "name" | "repositoryIdentity">; | 166 | representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">; |
| 155 | members: ReadonlyArray<Pick<Project, "name" | "repositoryIdentity">>; | 167 | members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>; |
| 156 | }): string { | 168 | }): string { |
| 157 | const sharedDisplayNames = uniqueNonEmptyValues( | 169 | const sharedDisplayNames = uniqueNonEmptyValues( |
| 158 | input.members.map((member) => member.repositoryIdentity?.displayName), | 170 | input.members.map((member) => member.repositoryIdentity?.displayName), |
| ... | @@ -168,5 +180,5 @@ export function deriveProjectGroupLabel(input: { | ... | @@ -168,5 +180,5 @@ export function deriveProjectGroupLabel(input: { |
| 168 | return sharedRepositoryNames[0]!; | 180 | return sharedRepositoryNames[0]!; |
| 169 | } | 181 | } |
| 170 | 182 | ||
| 171 | return input.representative.name; | 183 | return input.representative.title; |
| 172 | } | 184 | } |
apps/web/src/modelPickerOpenState.ts deleted-17| ... | @@ -1,17 +0,0 @@ | ||
| 1 | import { create } from "zustand"; | ||
| 2 | |||
| 3 | const useModelPickerOpenStore = create<{ | ||
| 4 | open: boolean; | ||
| 5 | setOpen: (open: boolean) => void; | ||
| 6 | }>((set) => ({ | ||
| 7 | open: false, | ||
| 8 | setOpen: (open) => set((current) => (current.open === open ? current : { open })), | ||
| 9 | })); | ||
| 10 | |||
| 11 | export function useModelPickerOpen(): boolean { | ||
| 12 | return useModelPickerOpenStore((store) => store.open); | ||
| 13 | } | ||
| 14 | |||
| 15 | export function setModelPickerOpen(open: boolean): void { | ||
| 16 | useModelPickerOpenStore.getState().setOpen(open); | ||
| 17 | } | ||
apps/web/src/modelPickerVisibility.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | const MODEL_PICKER_CONTENT_SELECTOR = "[data-model-picker-content]"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * Model-picker visibility is already represented by the mounted popover. | ||
| 5 | * Shortcut arbitration reads that source directly instead of mirroring it in | ||
| 6 | * a second React or external store. | ||
| 7 | */ | ||
| 8 | export function isModelPickerOpen(): boolean { | ||
| 9 | return ( | ||
| 10 | typeof document !== "undefined" && | ||
| 11 | document.querySelector(MODEL_PICKER_CONTENT_SELECTOR) !== null | ||
| 12 | ); | ||
| 13 | } | ||
apps/web/src/observability/clientTracing.ts+19-20| ... | @@ -6,6 +6,7 @@ import * as Tracer from "effect/Tracer"; | ... | @@ -6,6 +6,7 @@ import * as Tracer from "effect/Tracer"; |
| 6 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | 6 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; |
| 7 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 7 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 8 | 8 | ||
| 9 | import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 9 | import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; | 10 | import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; |
| 10 | import { isElectron } from "../env"; | 11 | import { isElectron } from "../env"; |
| 11 | import { APP_VERSION } from "~/branding"; | 12 | import { APP_VERSION } from "~/branding"; |
| ... | @@ -78,8 +79,8 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo | ... | @@ -78,8 +79,8 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo |
| 78 | const runtime = ManagedRuntime.make(delegateRuntimeLayer); | 79 | const runtime = ManagedRuntime.make(delegateRuntimeLayer); |
| 79 | const scope = runtime.runSync(Scope.make()); | 80 | const scope = runtime.runSync(Scope.make()); |
| 80 | 81 | ||
| 81 | try { | 82 | const delegateResult = await settleAsyncResult(() => |
| 82 | const delegate = await runtime.runPromise( | 83 | runtime.runPromiseExit( |
| 83 | Scope.provide(scope)( | 84 | Scope.provide(scope)( |
| 84 | OtlpTracer.make({ | 85 | OtlpTracer.make({ |
| 85 | url: otlpTracesUrl, | 86 | url: otlpTracesUrl, |
| ... | @@ -87,26 +88,28 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo | ... | @@ -87,26 +88,28 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo |
| 87 | resource: CLIENT_TRACING_RESOURCE, | 88 | resource: CLIENT_TRACING_RESOURCE, |
| 88 | }), | 89 | }), |
| 89 | ), | 90 | ), |
| 90 | ); | 91 | ), |
| 91 | 92 | ); | |
| 92 | if (generation !== configurationGeneration) { | 93 | if (delegateResult._tag === "Failure") { |
| 93 | await disposeTracerRuntime(runtime, scope); | ||
| 94 | return; | ||
| 95 | } | ||
| 96 | |||
| 97 | activeDelegate = delegate; | ||
| 98 | activeRuntime = runtime; | ||
| 99 | activeScope = scope; | ||
| 100 | } catch (error) { | ||
| 101 | await disposeTracerRuntime(runtime, scope); | 94 | await disposeTracerRuntime(runtime, scope); |
| 102 | 95 | ||
| 103 | if (generation === configurationGeneration) { | 96 | if (generation === configurationGeneration) { |
| 104 | console.warn("Failed to configure client tracing exporter", { | 97 | console.warn("Failed to configure client tracing exporter", { |
| 105 | error: formatError(error), | 98 | error: formatError(squashAtomCommandFailure(delegateResult)), |
| 106 | otlpTracesUrl, | 99 | otlpTracesUrl, |
| 107 | }); | 100 | }); |
| 108 | } | 101 | } |
| 102 | return; | ||
| 109 | } | 103 | } |
| 104 | |||
| 105 | if (generation !== configurationGeneration) { | ||
| 106 | await disposeTracerRuntime(runtime, scope); | ||
| 107 | return; | ||
| 108 | } | ||
| 109 | |||
| 110 | activeDelegate = delegateResult.value; | ||
| 111 | activeRuntime = runtime; | ||
| 112 | activeScope = scope; | ||
| 110 | } | 113 | } |
| 111 | 114 | ||
| 112 | async function disposeTracerRuntime( | 115 | async function disposeTracerRuntime( |
| ... | @@ -117,12 +120,8 @@ async function disposeTracerRuntime( | ... | @@ -117,12 +120,8 @@ async function disposeTracerRuntime( |
| 117 | return; | 120 | return; |
| 118 | } | 121 | } |
| 119 | 122 | ||
| 120 | await runtime | 123 | await settleAsyncResult(() => runtime.runPromiseExit(Scope.close(scope, Exit.void))); |
| 121 | .runPromise(Scope.close(scope, Exit.void)) | 124 | runtime.dispose(); |
| 122 | .catch(() => undefined) | ||
| 123 | .finally(() => { | ||
| 124 | runtime.dispose(); | ||
| 125 | }); | ||
| 126 | } | 125 | } |
| 127 | 126 | ||
| 128 | function formatError(error: unknown): string { | 127 | function formatError(error: unknown): string { |
apps/web/src/portDiscoveryState.ts+9-44| ... | @@ -1,55 +1,20 @@ | ... | @@ -1,55 +1,20 @@ |
| 1 | import type { | 1 | import type { DiscoveredLocalServer, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | DiscoveredLocalServer, | ||
| 3 | EnvironmentApi, | ||
| 4 | EnvironmentId, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { useMemo } from "react"; | 2 | import { useMemo } from "react"; |
| 8 | import { create } from "zustand"; | ||
| 9 | 3 | ||
| 10 | const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]); | 4 | import { previewEnvironment } from "./state/preview"; |
| 11 | 5 | import { useEnvironmentQuery } from "./state/query"; | |
| 12 | interface PortDiscoveryState { | ||
| 13 | readonly byEnvironment: Record<string, ReadonlyArray<DiscoveredLocalServer>>; | ||
| 14 | setPorts: (environmentId: EnvironmentId, ports: ReadonlyArray<DiscoveredLocalServer>) => void; | ||
| 15 | clearEnvironment: (environmentId: EnvironmentId) => void; | ||
| 16 | reset: () => void; | ||
| 17 | } | ||
| 18 | 6 | ||
| 19 | export const usePortDiscoveryStore = create<PortDiscoveryState>((set) => ({ | 7 | const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]); |
| 20 | byEnvironment: {}, | ||
| 21 | setPorts: (environmentId, ports) => | ||
| 22 | set((state) => ({ | ||
| 23 | byEnvironment: { | ||
| 24 | ...state.byEnvironment, | ||
| 25 | [environmentId]: ports, | ||
| 26 | }, | ||
| 27 | })), | ||
| 28 | clearEnvironment: (environmentId) => | ||
| 29 | set((state) => { | ||
| 30 | if (!(environmentId in state.byEnvironment)) return state; | ||
| 31 | const { [environmentId]: _removed, ...byEnvironment } = state.byEnvironment; | ||
| 32 | return { byEnvironment }; | ||
| 33 | }), | ||
| 34 | reset: () => set({ byEnvironment: {} }), | ||
| 35 | })); | ||
| 36 | |||
| 37 | export function subscribePortDiscovery(input: { | ||
| 38 | readonly environmentId: EnvironmentId; | ||
| 39 | readonly previewApi: Pick<EnvironmentApi["preview"], "subscribePorts">; | ||
| 40 | }): () => void { | ||
| 41 | usePortDiscoveryStore.getState().clearEnvironment(input.environmentId); | ||
| 42 | return input.previewApi.subscribePorts((snapshot) => { | ||
| 43 | usePortDiscoveryStore.getState().setPorts(input.environmentId, snapshot.servers); | ||
| 44 | }); | ||
| 45 | } | ||
| 46 | 8 | ||
| 47 | export function useDiscoveredPorts( | 9 | export function useDiscoveredPorts( |
| 48 | environmentId: EnvironmentId | null, | 10 | environmentId: EnvironmentId | null, |
| 49 | ): ReadonlyArray<DiscoveredLocalServer> { | 11 | ): ReadonlyArray<DiscoveredLocalServer> { |
| 50 | return usePortDiscoveryStore( | 12 | const query = useEnvironmentQuery( |
| 51 | (state) => (environmentId ? state.byEnvironment[environmentId] : undefined) ?? EMPTY_PORTS, | 13 | environmentId === null |
| 14 | ? null | ||
| 15 | : previewEnvironment.discoveredServers({ environmentId, input: {} }), | ||
| 52 | ); | 16 | ); |
| 17 | return query.data?.servers ?? EMPTY_PORTS; | ||
| 53 | } | 18 | } |
| 54 | 19 | ||
| 55 | export function useThreadDiscoveredPorts(input: { | 20 | export function useThreadDiscoveredPorts(input: { |
apps/web/src/previewStateStore.test.ts+74-64| ... | @@ -1,11 +1,24 @@ | ... | @@ -1,11 +1,24 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; | 2 | import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; |
| 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore"; | 5 | import { |
| 6 | __testing, | ||
| 7 | applyPreviewDesktopState, | ||
| 8 | applyPreviewServerEvent, | ||
| 9 | applyPreviewServerSnapshot, | ||
| 10 | previewStateAtom, | ||
| 11 | readThreadPreviewState, | ||
| 12 | rememberPreviewUrl, | ||
| 13 | removePreviewSession, | ||
| 14 | removePreviewThread, | ||
| 15 | resetPreviewStateForTests, | ||
| 16 | setActivePreviewTab, | ||
| 17 | } from "./previewStateStore"; | ||
| 6 | 18 | ||
| 7 | const environmentId = "env-1" as EnvironmentId; | 19 | const environmentId = "env-1" as EnvironmentId; |
| 8 | const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); | 20 | const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); |
| 21 | const otherRef = scopeThreadRef(environmentId, ThreadId.make("thread-2")); | ||
| 9 | 22 | ||
| 10 | const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewSessionSnapshot => ({ | 23 | const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewSessionSnapshot => ({ |
| 11 | threadId: "thread-1", | 24 | threadId: "thread-1", |
| ... | @@ -18,20 +31,31 @@ const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewS | ... | @@ -18,20 +31,31 @@ const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewS |
| 18 | }); | 31 | }); |
| 19 | 32 | ||
| 20 | beforeEach(() => { | 33 | beforeEach(() => { |
| 21 | usePreviewStateStore.setState({ byThreadKey: {} }); | 34 | resetPreviewStateForTests(); |
| 22 | }); | 35 | }); |
| 23 | 36 | ||
| 24 | describe("previewStateStore (single-tab)", () => { | 37 | describe("previewStateStore (single-tab)", () => { |
| 38 | it("keeps independent state atoms for each thread", () => { | ||
| 39 | expect(previewStateAtom(scopedThreadKey(ref))).toBe(previewStateAtom(scopedThreadKey(ref))); | ||
| 40 | expect(previewStateAtom(scopedThreadKey(ref))).not.toBe( | ||
| 41 | previewStateAtom(scopedThreadKey(otherRef)), | ||
| 42 | ); | ||
| 43 | |||
| 44 | applyPreviewServerSnapshot(ref, makeSnapshot()); | ||
| 45 | expect(readThreadPreviewState(ref).snapshot?.tabId).toBe("tab_a"); | ||
| 46 | expect(readThreadPreviewState(otherRef)).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); | ||
| 47 | }); | ||
| 48 | |||
| 25 | it("opened event seeds the snapshot and remembers the URL", () => { | 49 | it("opened event seeds the snapshot and remembers the URL", () => { |
| 26 | const snapshot = makeSnapshot(); | 50 | const snapshot = makeSnapshot(); |
| 27 | usePreviewStateStore.getState().applyServerEvent(ref, { | 51 | applyPreviewServerEvent(ref, { |
| 28 | type: "opened", | 52 | type: "opened", |
| 29 | threadId: "thread-1", | 53 | threadId: "thread-1", |
| 30 | tabId: snapshot.tabId, | 54 | tabId: snapshot.tabId, |
| 31 | createdAt: snapshot.updatedAt, | 55 | createdAt: snapshot.updatedAt, |
| 32 | snapshot, | 56 | snapshot, |
| 33 | }); | 57 | }); |
| 34 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 58 | const state = readThreadPreviewState(ref); |
| 35 | expect(state.snapshot?.tabId).toBe(snapshot.tabId); | 59 | expect(state.snapshot?.tabId).toBe(snapshot.tabId); |
| 36 | expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); | 60 | expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); |
| 37 | }); | 61 | }); |
| ... | @@ -39,36 +63,34 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -39,36 +63,34 @@ describe("previewStateStore (single-tab)", () => { |
| 39 | it("a second `opened` for a different tab replaces the rendered snapshot", () => { | 63 | it("a second `opened` for a different tab replaces the rendered snapshot", () => { |
| 40 | const a = makeSnapshot({ tabId: "tab_a" }); | 64 | const a = makeSnapshot({ tabId: "tab_a" }); |
| 41 | const b = makeSnapshot({ tabId: "tab_b" }); | 65 | const b = makeSnapshot({ tabId: "tab_b" }); |
| 42 | const store = usePreviewStateStore.getState(); | 66 | applyPreviewServerEvent(ref, { |
| 43 | store.applyServerEvent(ref, { | ||
| 44 | type: "opened", | 67 | type: "opened", |
| 45 | threadId: "thread-1", | 68 | threadId: "thread-1", |
| 46 | tabId: a.tabId, | 69 | tabId: a.tabId, |
| 47 | createdAt: a.updatedAt, | 70 | createdAt: a.updatedAt, |
| 48 | snapshot: a, | 71 | snapshot: a, |
| 49 | }); | 72 | }); |
| 50 | store.applyServerEvent(ref, { | 73 | applyPreviewServerEvent(ref, { |
| 51 | type: "opened", | 74 | type: "opened", |
| 52 | threadId: "thread-1", | 75 | threadId: "thread-1", |
| 53 | tabId: b.tabId, | 76 | tabId: b.tabId, |
| 54 | createdAt: b.updatedAt, | 77 | createdAt: b.updatedAt, |
| 55 | snapshot: b, | 78 | snapshot: b, |
| 56 | }); | 79 | }); |
| 57 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 80 | const state = readThreadPreviewState(ref); |
| 58 | expect(state.snapshot?.tabId).toBe(b.tabId); | 81 | expect(state.snapshot?.tabId).toBe(b.tabId); |
| 59 | }); | 82 | }); |
| 60 | 83 | ||
| 61 | it("navigated event updates the snapshot URL", () => { | 84 | it("navigated event updates the snapshot URL", () => { |
| 62 | const snapshot = makeSnapshot(); | 85 | const snapshot = makeSnapshot(); |
| 63 | const store = usePreviewStateStore.getState(); | 86 | applyPreviewServerEvent(ref, { |
| 64 | store.applyServerEvent(ref, { | ||
| 65 | type: "opened", | 87 | type: "opened", |
| 66 | threadId: "thread-1", | 88 | threadId: "thread-1", |
| 67 | tabId: snapshot.tabId, | 89 | tabId: snapshot.tabId, |
| 68 | createdAt: snapshot.updatedAt, | 90 | createdAt: snapshot.updatedAt, |
| 69 | snapshot, | 91 | snapshot, |
| 70 | }); | 92 | }); |
| 71 | store.applyServerEvent(ref, { | 93 | applyPreviewServerEvent(ref, { |
| 72 | type: "navigated", | 94 | type: "navigated", |
| 73 | threadId: "thread-1", | 95 | threadId: "thread-1", |
| 74 | tabId: snapshot.tabId, | 96 | tabId: snapshot.tabId, |
| ... | @@ -78,7 +100,7 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -78,7 +100,7 @@ describe("previewStateStore (single-tab)", () => { |
| 78 | navStatus: { _tag: "Success", url: "http://localhost:5173/about", title: "About" }, | 100 | navStatus: { _tag: "Success", url: "http://localhost:5173/about", title: "About" }, |
| 79 | }, | 101 | }, |
| 80 | }); | 102 | }); |
| 81 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 103 | const state = readThreadPreviewState(ref); |
| 82 | expect(state.snapshot?.navStatus._tag).toBe("Success"); | 104 | expect(state.snapshot?.navStatus._tag).toBe("Success"); |
| 83 | if (state.snapshot?.navStatus._tag === "Success") { | 105 | if (state.snapshot?.navStatus._tag === "Success") { |
| 84 | expect(state.snapshot.navStatus.url).toBe("http://localhost:5173/about"); | 106 | expect(state.snapshot.navStatus.url).toBe("http://localhost:5173/about"); |
| ... | @@ -87,15 +109,14 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -87,15 +109,14 @@ describe("previewStateStore (single-tab)", () => { |
| 87 | 109 | ||
| 88 | it("failed event flips the snapshot to LoadFailed when tabId matches", () => { | 110 | it("failed event flips the snapshot to LoadFailed when tabId matches", () => { |
| 89 | const snapshot = makeSnapshot(); | 111 | const snapshot = makeSnapshot(); |
| 90 | const store = usePreviewStateStore.getState(); | 112 | applyPreviewServerEvent(ref, { |
| 91 | store.applyServerEvent(ref, { | ||
| 92 | type: "opened", | 113 | type: "opened", |
| 93 | threadId: "thread-1", | 114 | threadId: "thread-1", |
| 94 | tabId: snapshot.tabId, | 115 | tabId: snapshot.tabId, |
| 95 | createdAt: snapshot.updatedAt, | 116 | createdAt: snapshot.updatedAt, |
| 96 | snapshot, | 117 | snapshot, |
| 97 | }); | 118 | }); |
| 98 | store.applyServerEvent(ref, { | 119 | applyPreviewServerEvent(ref, { |
| 99 | type: "failed", | 120 | type: "failed", |
| 100 | threadId: "thread-1", | 121 | threadId: "thread-1", |
| 101 | tabId: snapshot.tabId, | 122 | tabId: snapshot.tabId, |
| ... | @@ -105,21 +126,20 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -105,21 +126,20 @@ describe("previewStateStore (single-tab)", () => { |
| 105 | code: -105, | 126 | code: -105, |
| 106 | description: "ERR_NAME_NOT_RESOLVED", | 127 | description: "ERR_NAME_NOT_RESOLVED", |
| 107 | }); | 128 | }); |
| 108 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 129 | const state = readThreadPreviewState(ref); |
| 109 | expect(state.snapshot?.navStatus._tag).toBe("LoadFailed"); | 130 | expect(state.snapshot?.navStatus._tag).toBe("LoadFailed"); |
| 110 | }); | 131 | }); |
| 111 | 132 | ||
| 112 | it("failed event for a non-active tab is ignored", () => { | 133 | it("failed event for a non-active tab is ignored", () => { |
| 113 | const snapshot = makeSnapshot({ tabId: "tab_a" }); | 134 | const snapshot = makeSnapshot({ tabId: "tab_a" }); |
| 114 | const store = usePreviewStateStore.getState(); | 135 | applyPreviewServerEvent(ref, { |
| 115 | store.applyServerEvent(ref, { | ||
| 116 | type: "opened", | 136 | type: "opened", |
| 117 | threadId: "thread-1", | 137 | threadId: "thread-1", |
| 118 | tabId: snapshot.tabId, | 138 | tabId: snapshot.tabId, |
| 119 | createdAt: snapshot.updatedAt, | 139 | createdAt: snapshot.updatedAt, |
| 120 | snapshot, | 140 | snapshot, |
| 121 | }); | 141 | }); |
| 122 | store.applyServerEvent(ref, { | 142 | applyPreviewServerEvent(ref, { |
| 123 | type: "failed", | 143 | type: "failed", |
| 124 | threadId: "thread-1", | 144 | threadId: "thread-1", |
| 125 | tabId: "tab_b", | 145 | tabId: "tab_b", |
| ... | @@ -129,27 +149,26 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -129,27 +149,26 @@ describe("previewStateStore (single-tab)", () => { |
| 129 | code: -105, | 149 | code: -105, |
| 130 | description: "ERR_NAME_NOT_RESOLVED", | 150 | description: "ERR_NAME_NOT_RESOLVED", |
| 131 | }); | 151 | }); |
| 132 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 152 | const state = readThreadPreviewState(ref); |
| 133 | expect(state.snapshot?.navStatus._tag).toBe("Loading"); | 153 | expect(state.snapshot?.navStatus._tag).toBe("Loading"); |
| 134 | }); | 154 | }); |
| 135 | 155 | ||
| 136 | it("closed event clears snapshot but retains recently-seen URLs", () => { | 156 | it("closed event clears snapshot but retains recently-seen URLs", () => { |
| 137 | const snapshot = makeSnapshot(); | 157 | const snapshot = makeSnapshot(); |
| 138 | const store = usePreviewStateStore.getState(); | 158 | applyPreviewServerEvent(ref, { |
| 139 | store.applyServerEvent(ref, { | ||
| 140 | type: "opened", | 159 | type: "opened", |
| 141 | threadId: "thread-1", | 160 | threadId: "thread-1", |
| 142 | tabId: snapshot.tabId, | 161 | tabId: snapshot.tabId, |
| 143 | createdAt: snapshot.updatedAt, | 162 | createdAt: snapshot.updatedAt, |
| 144 | snapshot, | 163 | snapshot, |
| 145 | }); | 164 | }); |
| 146 | store.applyServerEvent(ref, { | 165 | applyPreviewServerEvent(ref, { |
| 147 | type: "closed", | 166 | type: "closed", |
| 148 | threadId: "thread-1", | 167 | threadId: "thread-1", |
| 149 | tabId: snapshot.tabId, | 168 | tabId: snapshot.tabId, |
| 150 | createdAt: "2026-01-01T00:00:01.000Z", | 169 | createdAt: "2026-01-01T00:00:01.000Z", |
| 151 | }); | 170 | }); |
| 152 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 171 | const state = readThreadPreviewState(ref); |
| 153 | expect(state.snapshot).toBeNull(); | 172 | expect(state.snapshot).toBeNull(); |
| 154 | expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); | 173 | expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); |
| 155 | }); | 174 | }); |
| ... | @@ -160,13 +179,12 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -160,13 +179,12 @@ describe("previewStateStore (single-tab)", () => { |
| 160 | tabId: "tab_b", | 179 | tabId: "tab_b", |
| 161 | updatedAt: "2026-01-01T00:00:01.000Z", | 180 | updatedAt: "2026-01-01T00:00:01.000Z", |
| 162 | }); | 181 | }); |
| 163 | const store = usePreviewStateStore.getState(); | 182 | applyPreviewServerSnapshot(ref, first); |
| 164 | store.applyServerSnapshot(ref, first); | 183 | applyPreviewServerSnapshot(ref, second); |
| 165 | store.applyServerSnapshot(ref, second); | ||
| 166 | 184 | ||
| 167 | store.removeSession(ref, second.tabId); | 185 | removePreviewSession(ref, second.tabId); |
| 168 | 186 | ||
| 169 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 187 | const state = readThreadPreviewState(ref); |
| 170 | expect(Object.keys(state.sessions)).toEqual([first.tabId]); | 188 | expect(Object.keys(state.sessions)).toEqual([first.tabId]); |
| 171 | expect(state.activeTabId).toBe(first.tabId); | 189 | expect(state.activeTabId).toBe(first.tabId); |
| 172 | expect(state.snapshot?.tabId).toBe(first.tabId); | 190 | expect(state.snapshot?.tabId).toBe(first.tabId); |
| ... | @@ -174,60 +192,57 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -174,60 +192,57 @@ describe("previewStateStore (single-tab)", () => { |
| 174 | 192 | ||
| 175 | it("treats a late server close event after optimistic removal as a no-op", () => { | 193 | it("treats a late server close event after optimistic removal as a no-op", () => { |
| 176 | const snapshot = makeSnapshot(); | 194 | const snapshot = makeSnapshot(); |
| 177 | const store = usePreviewStateStore.getState(); | 195 | applyPreviewServerSnapshot(ref, snapshot); |
| 178 | store.applyServerSnapshot(ref, snapshot); | 196 | removePreviewSession(ref, snapshot.tabId); |
| 179 | store.removeSession(ref, snapshot.tabId); | ||
| 180 | 197 | ||
| 181 | store.applyServerEvent(ref, { | 198 | applyPreviewServerEvent(ref, { |
| 182 | type: "closed", | 199 | type: "closed", |
| 183 | threadId: "thread-1", | 200 | threadId: "thread-1", |
| 184 | tabId: snapshot.tabId, | 201 | tabId: snapshot.tabId, |
| 185 | createdAt: "2026-01-01T00:00:01.000Z", | 202 | createdAt: "2026-01-01T00:00:01.000Z", |
| 186 | }); | 203 | }); |
| 187 | 204 | ||
| 188 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 205 | const state = readThreadPreviewState(ref); |
| 189 | expect(state.sessions).toEqual({}); | 206 | expect(state.sessions).toEqual({}); |
| 190 | expect(state.snapshot).toBeNull(); | 207 | expect(state.snapshot).toBeNull(); |
| 191 | }); | 208 | }); |
| 192 | 209 | ||
| 193 | it("closed event for a different tab is a no-op", () => { | 210 | it("closed event for a different tab is a no-op", () => { |
| 194 | const snapshot = makeSnapshot({ tabId: "tab_a" }); | 211 | const snapshot = makeSnapshot({ tabId: "tab_a" }); |
| 195 | const store = usePreviewStateStore.getState(); | 212 | applyPreviewServerEvent(ref, { |
| 196 | store.applyServerEvent(ref, { | ||
| 197 | type: "opened", | 213 | type: "opened", |
| 198 | threadId: "thread-1", | 214 | threadId: "thread-1", |
| 199 | tabId: snapshot.tabId, | 215 | tabId: snapshot.tabId, |
| 200 | createdAt: snapshot.updatedAt, | 216 | createdAt: snapshot.updatedAt, |
| 201 | snapshot, | 217 | snapshot, |
| 202 | }); | 218 | }); |
| 203 | store.applyServerEvent(ref, { | 219 | applyPreviewServerEvent(ref, { |
| 204 | type: "closed", | 220 | type: "closed", |
| 205 | threadId: "thread-1", | 221 | threadId: "thread-1", |
| 206 | tabId: "tab_b", | 222 | tabId: "tab_b", |
| 207 | createdAt: "2026-01-01T00:00:01.000Z", | 223 | createdAt: "2026-01-01T00:00:01.000Z", |
| 208 | }); | 224 | }); |
| 209 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 225 | const state = readThreadPreviewState(ref); |
| 210 | expect(state.snapshot?.tabId).toBe(snapshot.tabId); | 226 | expect(state.snapshot?.tabId).toBe(snapshot.tabId); |
| 211 | }); | 227 | }); |
| 212 | 228 | ||
| 213 | it("desktopOverlay updates independently of snapshot", () => { | 229 | it("desktopOverlay updates independently of snapshot", () => { |
| 214 | const snapshot = makeSnapshot(); | 230 | const snapshot = makeSnapshot(); |
| 215 | const store = usePreviewStateStore.getState(); | 231 | applyPreviewServerEvent(ref, { |
| 216 | store.applyServerEvent(ref, { | ||
| 217 | type: "opened", | 232 | type: "opened", |
| 218 | threadId: "thread-1", | 233 | threadId: "thread-1", |
| 219 | tabId: snapshot.tabId, | 234 | tabId: snapshot.tabId, |
| 220 | createdAt: snapshot.updatedAt, | 235 | createdAt: snapshot.updatedAt, |
| 221 | snapshot, | 236 | snapshot, |
| 222 | }); | 237 | }); |
| 223 | store.applyDesktopState(ref, snapshot.tabId, { | 238 | applyPreviewDesktopState(ref, snapshot.tabId, { |
| 224 | canGoBack: true, | 239 | canGoBack: true, |
| 225 | canGoForward: false, | 240 | canGoForward: false, |
| 226 | loading: false, | 241 | loading: false, |
| 227 | zoomFactor: 1, | 242 | zoomFactor: 1, |
| 228 | controller: "none", | 243 | controller: "none", |
| 229 | }); | 244 | }); |
| 230 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 245 | const state = readThreadPreviewState(ref); |
| 231 | expect(state.desktopOverlay?.canGoBack).toBe(true); | 246 | expect(state.desktopOverlay?.canGoBack).toBe(true); |
| 232 | expect(state.snapshot?.canGoBack).toBe(false); | 247 | expect(state.snapshot?.canGoBack).toBe(false); |
| 233 | }); | 248 | }); |
| ... | @@ -235,19 +250,18 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -235,19 +250,18 @@ describe("previewStateStore (single-tab)", () => { |
| 235 | it("retains multiple tabs and switches active desktop state", () => { | 250 | it("retains multiple tabs and switches active desktop state", () => { |
| 236 | const first = makeSnapshot(); | 251 | const first = makeSnapshot(); |
| 237 | const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" }; | 252 | const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" }; |
| 238 | const store = usePreviewStateStore.getState(); | 253 | applyPreviewServerSnapshot(ref, first); |
| 239 | store.applyServerSnapshot(ref, first); | 254 | applyPreviewServerSnapshot(ref, second); |
| 240 | store.applyServerSnapshot(ref, second); | 255 | applyPreviewDesktopState(ref, first.tabId, { |
| 241 | store.applyDesktopState(ref, first.tabId, { | ||
| 242 | canGoBack: true, | 256 | canGoBack: true, |
| 243 | canGoForward: false, | 257 | canGoForward: false, |
| 244 | loading: false, | 258 | loading: false, |
| 245 | zoomFactor: 1, | 259 | zoomFactor: 1, |
| 246 | controller: "none", | 260 | controller: "none", |
| 247 | }); | 261 | }); |
| 248 | store.setActiveTab(ref, first.tabId); | 262 | setActivePreviewTab(ref, first.tabId); |
| 249 | 263 | ||
| 250 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 264 | const state = readThreadPreviewState(ref); |
| 251 | expect(Object.keys(state.sessions)).toEqual([first.tabId, second.tabId]); | 265 | expect(Object.keys(state.sessions)).toEqual([first.tabId, second.tabId]); |
| 252 | expect(state.snapshot?.tabId).toBe(first.tabId); | 266 | expect(state.snapshot?.tabId).toBe(first.tabId); |
| 253 | expect(state.desktopOverlay?.canGoBack).toBe(true); | 267 | expect(state.desktopOverlay?.canGoBack).toBe(true); |
| ... | @@ -255,23 +269,21 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -255,23 +269,21 @@ describe("previewStateStore (single-tab)", () => { |
| 255 | 269 | ||
| 256 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { | 270 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { |
| 257 | const snapshot = makeSnapshot(); | 271 | const snapshot = makeSnapshot(); |
| 258 | const store = usePreviewStateStore.getState(); | 272 | applyPreviewServerSnapshot(ref, snapshot); |
| 259 | store.applyServerSnapshot(ref, snapshot); | 273 | applyPreviewServerSnapshot(ref, null); |
| 260 | store.applyServerSnapshot(ref, null); | 274 | const state = readThreadPreviewState(ref); |
| 261 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | ||
| 262 | expect(state.snapshot).toBeNull(); | 275 | expect(state.snapshot).toBeNull(); |
| 263 | }); | 276 | }); |
| 264 | 277 | ||
| 265 | it("does not replace a streamed snapshot with older SWR data", () => { | 278 | it("does not replace a streamed snapshot with older SWR data", () => { |
| 266 | const store = usePreviewStateStore.getState(); | 279 | applyPreviewServerSnapshot( |
| 267 | store.applyServerSnapshot( | ||
| 268 | ref, | 280 | ref, |
| 269 | makeSnapshot({ | 281 | makeSnapshot({ |
| 270 | navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" }, | 282 | navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" }, |
| 271 | updatedAt: "2026-01-01T00:00:02.000Z", | 283 | updatedAt: "2026-01-01T00:00:02.000Z", |
| 272 | }), | 284 | }), |
| 273 | ); | 285 | ); |
| 274 | store.applyServerSnapshot( | 286 | applyPreviewServerSnapshot( |
| 275 | ref, | 287 | ref, |
| 276 | makeSnapshot({ | 288 | makeSnapshot({ |
| 277 | navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" }, | 289 | navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" }, |
| ... | @@ -279,7 +291,7 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -279,7 +291,7 @@ describe("previewStateStore (single-tab)", () => { |
| 279 | }), | 291 | }), |
| 280 | ); | 292 | ); |
| 281 | 293 | ||
| 282 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 294 | const state = readThreadPreviewState(ref); |
| 283 | expect(state.snapshot?.navStatus).toEqual({ | 295 | expect(state.snapshot?.navStatus).toEqual({ |
| 284 | _tag: "Success", | 296 | _tag: "Success", |
| 285 | url: "http://localhost:5173/new", | 297 | url: "http://localhost:5173/new", |
| ... | @@ -288,11 +300,10 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -288,11 +300,10 @@ describe("previewStateStore (single-tab)", () => { |
| 288 | }); | 300 | }); |
| 289 | 301 | ||
| 290 | it("rememberUrl dedupes and caps at limit", () => { | 302 | it("rememberUrl dedupes and caps at limit", () => { |
| 291 | const store = usePreviewStateStore.getState(); | ||
| 292 | for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { | 303 | for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { |
| 293 | store.rememberUrl(ref, `http://localhost:${5000 + i}/`); | 304 | rememberPreviewUrl(ref, `http://localhost:${5000 + i}/`); |
| 294 | } | 305 | } |
| 295 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 306 | const state = readThreadPreviewState(ref); |
| 296 | expect(state.recentlySeenUrls.length).toBeLessThanOrEqual(__testing.RECENT_URL_LIMIT); | 307 | expect(state.recentlySeenUrls.length).toBeLessThanOrEqual(__testing.RECENT_URL_LIMIT); |
| 297 | expect(state.recentlySeenUrls[0]).toBe( | 308 | expect(state.recentlySeenUrls[0]).toBe( |
| 298 | `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, | 309 | `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, |
| ... | @@ -301,10 +312,9 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -301,10 +312,9 @@ describe("previewStateStore (single-tab)", () => { |
| 301 | 312 | ||
| 302 | it("removeThread strips the entry", () => { | 313 | it("removeThread strips the entry", () => { |
| 303 | const snapshot = makeSnapshot(); | 314 | const snapshot = makeSnapshot(); |
| 304 | const store = usePreviewStateStore.getState(); | 315 | applyPreviewServerSnapshot(ref, snapshot); |
| 305 | store.applyServerSnapshot(ref, snapshot); | 316 | removePreviewThread(ref); |
| 306 | store.removeThread(ref); | 317 | const state = readThreadPreviewState(ref); |
| 307 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | ||
| 308 | expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); | 318 | expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); |
| 309 | }); | 319 | }); |
| 310 | }); | 320 | }); |
apps/web/src/previewStateStore.ts+212-202| ... | @@ -1,25 +1,21 @@ | ... | @@ -1,25 +1,21 @@ |
| 1 | /** | 1 | /** |
| 2 | * Per-thread preview UI state. | 2 | * Per-thread preview UI state. |
| 3 | * | 3 | * |
| 4 | * Single-tab model: one snapshot per thread, mirrored two ways: | 4 | * Each thread owns an independent atom. Most consumers read exactly one |
| 5 | * - `snapshot` is the server-authoritative URL/title/load-status, replayed | 5 | * thread; the desktop browser host uses the aggregate session atom because it |
| 6 | * on WS reconnect so the panel survives backend restarts. | 6 | * is the one place that must enumerate every live preview tab. |
| 7 | * - `desktopOverlay` is low-latency state from the local <webview> | ||
| 8 | * (canGoBack/canGoForward/visible/zoom/loading), used by the chrome row's | ||
| 9 | * button enablement. | ||
| 10 | * | ||
| 11 | * The schema-level `tabId` exists because the server still keys sessions by | ||
| 12 | * `(threadId, tabId)`; the client just always tracks one and ignores the rest. | ||
| 13 | */ | 7 | */ |
| 14 | import { scopedThreadKey } from "@t3tools/client-runtime"; | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | ||
| 15 | import { | 10 | import { |
| 16 | type PreviewEvent, | 11 | type PreviewEvent, |
| 17 | type PreviewSessionSnapshot, | 12 | type PreviewSessionSnapshot, |
| 18 | type ScopedThreadRef, | 13 | type ScopedThreadRef, |
| 19 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 20 | import { create } from "zustand"; | 15 | import { Atom } from "effect/unstable/reactivity"; |
| 21 | 16 | ||
| 22 | import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; | 17 | import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; |
| 18 | import { appAtomRegistry } from "./rpc/atomRegistry"; | ||
| 23 | 19 | ||
| 24 | export interface DesktopPreviewOverlay { | 20 | export interface DesktopPreviewOverlay { |
| 25 | canGoBack: boolean; | 21 | canGoBack: boolean; |
| ... | @@ -33,10 +29,8 @@ export interface ThreadPreviewState { | ... | @@ -33,10 +29,8 @@ export interface ThreadPreviewState { |
| 33 | snapshot: PreviewSessionSnapshot | null; | 29 | snapshot: PreviewSessionSnapshot | null; |
| 34 | sessions: Record<string, PreviewSessionSnapshot>; | 30 | sessions: Record<string, PreviewSessionSnapshot>; |
| 35 | activeTabId: string | null; | 31 | activeTabId: string | null; |
| 36 | /** Bridge state takes precedence over `snapshot` for nav button enablement. */ | ||
| 37 | desktopOverlay: DesktopPreviewOverlay | null; | 32 | desktopOverlay: DesktopPreviewOverlay | null; |
| 38 | desktopByTabId: Record<string, DesktopPreviewOverlay>; | 33 | desktopByTabId: Record<string, DesktopPreviewOverlay>; |
| 39 | /** Recently-visited URLs surfaced in the empty state. */ | ||
| 40 | recentlySeenUrls: string[]; | 34 | recentlySeenUrls: string[]; |
| 41 | } | 35 | } |
| 42 | 36 | ||
| ... | @@ -49,55 +43,66 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ | ... | @@ -49,55 +43,66 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ |
| 49 | recentlySeenUrls: [] as string[], | 43 | recentlySeenUrls: [] as string[], |
| 50 | }); | 44 | }); |
| 51 | 45 | ||
| 52 | const revisionByThreadKey = new Map<string, number>(); | 46 | const emptyPreviewStateAtom = Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe( |
| 47 | Atom.withLabel("preview:empty-thread"), | ||
| 48 | ); | ||
| 53 | 49 | ||
| 54 | const bumpPreviewStateRevision = (threadKey: string): void => { | 50 | export const previewStateAtom = Atom.family((threadKey: string) => |
| 55 | revisionByThreadKey.set(threadKey, (revisionByThreadKey.get(threadKey) ?? 0) + 1); | 51 | Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe( |
| 56 | }; | 52 | Atom.keepAlive, |
| 53 | Atom.withLabel(`preview:thread:${threadKey}`), | ||
| 54 | ), | ||
| 55 | ); | ||
| 57 | 56 | ||
| 58 | export function readPreviewStateRevision(ref: ScopedThreadRef): number { | 57 | // Only the Electron browser host needs a cross-thread view. Keep that index |
| 59 | return revisionByThreadKey.get(scopedThreadKey(ref)) ?? 0; | 58 | // separate so thread-local readers never subscribe to unrelated previews. |
| 59 | interface ActivePreviewThreadIndex { | ||
| 60 | readonly keys: ReadonlySet<string>; | ||
| 60 | } | 61 | } |
| 61 | 62 | ||
| 62 | export interface PreviewStateStoreState { | 63 | const activePreviewThreadKeysAtom = Atom.make<ActivePreviewThreadIndex>({ |
| 63 | byThreadKey: Record<string, ThreadPreviewState>; | 64 | keys: new Set<string>(), |
| 64 | applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; | 65 | }).pipe(Atom.keepAlive, Atom.withLabel("preview:active-thread-keys")); |
| 65 | applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; | ||
| 66 | applyDesktopState: ( | ||
| 67 | ref: ScopedThreadRef, | ||
| 68 | tabId: string, | ||
| 69 | overlay: DesktopPreviewOverlay | null, | ||
| 70 | ) => void; | ||
| 71 | removeSession: (ref: ScopedThreadRef, tabId: string) => void; | ||
| 72 | setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; | ||
| 73 | rememberUrl: (ref: ScopedThreadRef, url: string) => void; | ||
| 74 | removeThread: (ref: ScopedThreadRef) => void; | ||
| 75 | } | ||
| 76 | 66 | ||
| 77 | const ensureState = ( | 67 | const activePreviewSessionsAtom = Atom.make((get) => { |
| 78 | byThreadKey: Record<string, ThreadPreviewState>, | 68 | const byThreadKey: Record<string, ThreadPreviewState> = {}; |
| 79 | threadKey: string, | 69 | for (const threadKey of get(activePreviewThreadKeysAtom).keys) { |
| 80 | ): ThreadPreviewState => byThreadKey[threadKey] ?? EMPTY_THREAD_PREVIEW_STATE; | 70 | const state = get(previewStateAtom(threadKey)); |
| 71 | if (Object.keys(state.sessions).length > 0) { | ||
| 72 | byThreadKey[threadKey] = state; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | return byThreadKey; | ||
| 76 | }).pipe(Atom.withLabel("preview:active-sessions")); | ||
| 81 | 77 | ||
| 82 | const updateThread = ( | 78 | const changedPreviewThreadKeys = new Set<string>(); |
| 83 | state: PreviewStateStoreState, | ||
| 84 | threadKey: string, | ||
| 85 | updater: (current: ThreadPreviewState) => ThreadPreviewState, | ||
| 86 | ): PreviewStateStoreState["byThreadKey"] => { | ||
| 87 | const current = ensureState(state.byThreadKey, threadKey); | ||
| 88 | const next = updater(current); | ||
| 89 | if (next === current) return state.byThreadKey; | ||
| 90 | return { ...state.byThreadKey, [threadKey]: next }; | ||
| 91 | }; | ||
| 92 | 79 | ||
| 93 | const removeThreadKey = ( | 80 | function syncActivePreviewThread(threadKey: string, state: ThreadPreviewState): void { |
| 94 | byThreadKey: Record<string, ThreadPreviewState>, | 81 | const active = Object.keys(state.sessions).length > 0; |
| 95 | threadKey: string, | 82 | appAtomRegistry.update(activePreviewThreadKeysAtom, (current) => { |
| 96 | ): Record<string, ThreadPreviewState> => { | 83 | if (current.keys.has(threadKey) === active) return current; |
| 97 | if (!(threadKey in byThreadKey)) return byThreadKey; | 84 | const next = new Set(current.keys); |
| 98 | const { [threadKey]: _removed, ...rest } = byThreadKey; | 85 | if (active) next.add(threadKey); |
| 99 | return rest; | 86 | else next.delete(threadKey); |
| 100 | }; | 87 | return { keys: next }; |
| 88 | }); | ||
| 89 | } | ||
| 90 | |||
| 91 | function updateThreadPreviewState( | ||
| 92 | ref: ScopedThreadRef, | ||
| 93 | update: (current: ThreadPreviewState) => ThreadPreviewState, | ||
| 94 | ): void { | ||
| 95 | const threadKey = scopedThreadKey(ref); | ||
| 96 | const atom = previewStateAtom(threadKey); | ||
| 97 | let nextState = appAtomRegistry.get(atom); | ||
| 98 | const changed = appAtomRegistry.modify(atom, (current) => { | ||
| 99 | nextState = update(current); | ||
| 100 | return [nextState !== current, nextState]; | ||
| 101 | }); | ||
| 102 | if (!changed) return; | ||
| 103 | changedPreviewThreadKeys.add(threadKey); | ||
| 104 | syncActivePreviewThread(threadKey, nextState); | ||
| 105 | } | ||
| 101 | 106 | ||
| 102 | const dedupeRecentUrls = (existing: string[], url: string): string[] => { | 107 | const dedupeRecentUrls = (existing: string[], url: string): string[] => { |
| 103 | const next = [url, ...existing.filter((entry) => entry !== url)]; | 108 | const next = [url, ...existing.filter((entry) => entry !== url)]; |
| ... | @@ -125,164 +130,161 @@ const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPrevie | ... | @@ -125,164 +130,161 @@ const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPrevie |
| 125 | }; | 130 | }; |
| 126 | }; | 131 | }; |
| 127 | 132 | ||
| 128 | export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ | 133 | export function useThreadPreviewState(ref: ScopedThreadRef | null | undefined): ThreadPreviewState { |
| 129 | byThreadKey: {}, | 134 | const atom = ref ? previewStateAtom(scopedThreadKey(ref)) : emptyPreviewStateAtom; |
| 130 | applyServerEvent: (ref, event) => | 135 | return useAtomValue(atom); |
| 131 | set((state) => { | 136 | } |
| 132 | const threadKey = scopedThreadKey(ref); | 137 | |
| 133 | bumpPreviewStateRevision(threadKey); | 138 | export function useActivePreviewSessions(): Record<string, ThreadPreviewState> { |
| 134 | let nextByThread = state.byThreadKey; | 139 | return useAtomValue(activePreviewSessionsAtom); |
| 135 | switch (event.type) { | 140 | } |
| 136 | case "opened": | 141 | |
| 137 | case "navigated": | 142 | export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState { |
| 138 | nextByThread = updateThread(state, threadKey, (current) => { | 143 | return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); |
| 139 | const snapshot = event.snapshot; | 144 | } |
| 140 | const recentlySeenUrls = | 145 | |
| 141 | snapshot.navStatus._tag === "Idle" | 146 | export function subscribeThreadPreviewState( |
| 142 | ? current.recentlySeenUrls | 147 | ref: ScopedThreadRef, |
| 143 | : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); | 148 | listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void, |
| 144 | const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; | 149 | ): () => void { |
| 145 | const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; | 150 | const atom = previewStateAtom(scopedThreadKey(ref)); |
| 146 | const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; | 151 | let previous = appAtomRegistry.get(atom); |
| 147 | return { | 152 | return appAtomRegistry.subscribe(atom, (state) => { |
| 148 | ...current, | 153 | const prior = previous; |
| 149 | sessions, | 154 | previous = state; |
| 150 | activeTabId: activeTabId ?? snapshot.tabId, | 155 | listener(state, prior); |
| 151 | snapshot: activeSnapshot, | 156 | }); |
| 152 | desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, | 157 | } |
| 153 | recentlySeenUrls, | 158 | |
| 154 | }; | 159 | export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { |
| 155 | }); | 160 | updateThreadPreviewState(ref, (current) => { |
| 156 | break; | 161 | switch (event.type) { |
| 157 | case "failed": | 162 | case "opened": |
| 158 | nextByThread = updateThread(state, threadKey, (current) => { | 163 | case "navigated": { |
| 159 | const existing = current.sessions[event.tabId]; | 164 | const snapshot = event.snapshot; |
| 160 | if (!existing) return current; | ||
| 161 | const failedSnapshot = { | ||
| 162 | ...existing, | ||
| 163 | navStatus: { | ||
| 164 | _tag: "LoadFailed" as const, | ||
| 165 | url: event.url, | ||
| 166 | title: event.title, | ||
| 167 | code: event.code, | ||
| 168 | description: event.description, | ||
| 169 | }, | ||
| 170 | updatedAt: event.createdAt, | ||
| 171 | }; | ||
| 172 | const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; | ||
| 173 | return { | ||
| 174 | ...current, | ||
| 175 | sessions, | ||
| 176 | snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, | ||
| 177 | }; | ||
| 178 | }); | ||
| 179 | break; | ||
| 180 | case "closed": | ||
| 181 | nextByThread = updateThread(state, threadKey, (current) => | ||
| 182 | removeSession(current, event.tabId), | ||
| 183 | ); | ||
| 184 | break; | ||
| 185 | } | ||
| 186 | return { byThreadKey: nextByThread }; | ||
| 187 | }), | ||
| 188 | applyServerSnapshot: (ref, snapshot) => | ||
| 189 | set((state) => { | ||
| 190 | const threadKey = scopedThreadKey(ref); | ||
| 191 | bumpPreviewStateRevision(threadKey); | ||
| 192 | const nextByThread = updateThread(state, threadKey, (current) => { | ||
| 193 | if (!snapshot && current.snapshot === null) return current; | ||
| 194 | if (!snapshot) { | ||
| 195 | return { | ||
| 196 | ...current, | ||
| 197 | snapshot: null, | ||
| 198 | sessions: {}, | ||
| 199 | activeTabId: null, | ||
| 200 | desktopOverlay: null, | ||
| 201 | desktopByTabId: {}, | ||
| 202 | }; | ||
| 203 | } | ||
| 204 | const existing = current.sessions[snapshot.tabId]; | ||
| 205 | if (existing && existing.updatedAt > snapshot.updatedAt) { | ||
| 206 | return current; | ||
| 207 | } | ||
| 208 | const recentlySeenUrls = | 165 | const recentlySeenUrls = |
| 209 | snapshot && snapshot.navStatus._tag !== "Idle" | 166 | snapshot.navStatus._tag === "Idle" |
| 210 | ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) | 167 | ? current.recentlySeenUrls |
| 211 | : current.recentlySeenUrls; | 168 | : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); |
| 169 | const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; | ||
| 170 | const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; | ||
| 171 | const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; | ||
| 212 | return { | 172 | return { |
| 213 | ...current, | 173 | ...current, |
| 214 | snapshot, | 174 | sessions, |
| 215 | sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, | 175 | activeTabId: activeTabId ?? snapshot.tabId, |
| 216 | activeTabId: snapshot.tabId, | 176 | snapshot: activeSnapshot, |
| 217 | desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, | 177 | desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, |
| 218 | recentlySeenUrls, | 178 | recentlySeenUrls, |
| 219 | }; | 179 | }; |
| 220 | }); | 180 | } |
| 221 | return { byThreadKey: nextByThread }; | 181 | case "failed": { |
| 222 | }), | 182 | const existing = current.sessions[event.tabId]; |
| 223 | applyDesktopState: (ref, tabId, overlay) => | 183 | if (!existing) return current; |
| 224 | set((state) => { | 184 | const failedSnapshot = { |
| 225 | const threadKey = scopedThreadKey(ref); | 185 | ...existing, |
| 226 | const nextByThread = updateThread(state, threadKey, (current) => { | 186 | navStatus: { |
| 227 | const desktopByTabId = { ...current.desktopByTabId }; | 187 | _tag: "LoadFailed" as const, |
| 228 | if (overlay) desktopByTabId[tabId] = overlay; | 188 | url: event.url, |
| 229 | else delete desktopByTabId[tabId]; | 189 | title: event.title, |
| 230 | return { | 190 | code: event.code, |
| 231 | ...current, | 191 | description: event.description, |
| 232 | desktopByTabId, | 192 | }, |
| 233 | desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay, | 193 | updatedAt: event.createdAt, |
| 234 | }; | 194 | }; |
| 235 | }); | 195 | const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; |
| 236 | return { byThreadKey: nextByThread }; | ||
| 237 | }), | ||
| 238 | removeSession: (ref, tabId) => | ||
| 239 | set((state) => { | ||
| 240 | const threadKey = scopedThreadKey(ref); | ||
| 241 | bumpPreviewStateRevision(threadKey); | ||
| 242 | return { | ||
| 243 | byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)), | ||
| 244 | }; | ||
| 245 | }), | ||
| 246 | setActiveTab: (ref, tabId) => | ||
| 247 | set((state) => { | ||
| 248 | const threadKey = scopedThreadKey(ref); | ||
| 249 | const nextByThread = updateThread(state, threadKey, (current) => { | ||
| 250 | const snapshot = current.sessions[tabId]; | ||
| 251 | if (!snapshot || current.activeTabId === tabId) return current; | ||
| 252 | return { | 196 | return { |
| 253 | ...current, | 197 | ...current, |
| 254 | activeTabId: tabId, | 198 | sessions, |
| 255 | snapshot, | 199 | snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, |
| 256 | desktopOverlay: current.desktopByTabId[tabId] ?? null, | ||
| 257 | }; | 200 | }; |
| 258 | }); | 201 | } |
| 259 | return { byThreadKey: nextByThread }; | 202 | case "closed": |
| 260 | }), | 203 | return removeSession(current, event.tabId); |
| 261 | rememberUrl: (ref, url) => | 204 | } |
| 262 | set((state) => { | 205 | }); |
| 263 | if (url.trim().length === 0) return state; | 206 | } |
| 264 | const threadKey = scopedThreadKey(ref); | 207 | |
| 265 | const nextByThread = updateThread(state, threadKey, (current) => ({ | 208 | export function applyPreviewServerSnapshot( |
| 209 | ref: ScopedThreadRef, | ||
| 210 | snapshot: PreviewSessionSnapshot | null, | ||
| 211 | ): void { | ||
| 212 | updateThreadPreviewState(ref, (current) => { | ||
| 213 | if (!snapshot && current.snapshot === null) return current; | ||
| 214 | if (!snapshot) { | ||
| 215 | return { | ||
| 266 | ...current, | 216 | ...current, |
| 267 | recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url), | 217 | snapshot: null, |
| 268 | })); | 218 | sessions: {}, |
| 269 | return { byThreadKey: nextByThread }; | 219 | activeTabId: null, |
| 270 | }), | 220 | desktopOverlay: null, |
| 271 | removeThread: (ref) => | 221 | desktopByTabId: {}, |
| 272 | set((state) => { | 222 | }; |
| 273 | const threadKey = scopedThreadKey(ref); | 223 | } |
| 274 | bumpPreviewStateRevision(threadKey); | 224 | const existing = current.sessions[snapshot.tabId]; |
| 275 | if (!(threadKey in state.byThreadKey)) return state; | 225 | if (existing && existing.updatedAt > snapshot.updatedAt) return current; |
| 276 | return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) }; | 226 | const recentlySeenUrls = |
| 277 | }), | 227 | snapshot.navStatus._tag !== "Idle" |
| 278 | })); | 228 | ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) |
| 229 | : current.recentlySeenUrls; | ||
| 230 | return { | ||
| 231 | ...current, | ||
| 232 | snapshot, | ||
| 233 | sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, | ||
| 234 | activeTabId: snapshot.tabId, | ||
| 235 | desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, | ||
| 236 | recentlySeenUrls, | ||
| 237 | }; | ||
| 238 | }); | ||
| 239 | } | ||
| 240 | |||
| 241 | export function applyPreviewDesktopState( | ||
| 242 | ref: ScopedThreadRef, | ||
| 243 | tabId: string, | ||
| 244 | overlay: DesktopPreviewOverlay | null, | ||
| 245 | ): void { | ||
| 246 | updateThreadPreviewState(ref, (current) => { | ||
| 247 | const desktopByTabId = { ...current.desktopByTabId }; | ||
| 248 | if (overlay) desktopByTabId[tabId] = overlay; | ||
| 249 | else delete desktopByTabId[tabId]; | ||
| 250 | return { | ||
| 251 | ...current, | ||
| 252 | desktopByTabId, | ||
| 253 | desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay, | ||
| 254 | }; | ||
| 255 | }); | ||
| 256 | } | ||
| 279 | 257 | ||
| 280 | export function selectThreadPreviewState( | 258 | export function removePreviewSession(ref: ScopedThreadRef, tabId: string): void { |
| 281 | byThreadKey: Record<string, ThreadPreviewState>, | 259 | updateThreadPreviewState(ref, (current) => removeSession(current, tabId)); |
| 282 | ref: ScopedThreadRef | null | undefined, | 260 | } |
| 283 | ): ThreadPreviewState { | 261 | |
| 284 | if (!ref) return EMPTY_THREAD_PREVIEW_STATE; | 262 | export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { |
| 285 | return ensureState(byThreadKey, scopedThreadKey(ref)); | 263 | updateThreadPreviewState(ref, (current) => { |
| 264 | const snapshot = current.sessions[tabId]; | ||
| 265 | if (!snapshot || current.activeTabId === tabId) return current; | ||
| 266 | return { | ||
| 267 | ...current, | ||
| 268 | activeTabId: tabId, | ||
| 269 | snapshot, | ||
| 270 | desktopOverlay: current.desktopByTabId[tabId] ?? null, | ||
| 271 | }; | ||
| 272 | }); | ||
| 273 | } | ||
| 274 | |||
| 275 | export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { | ||
| 276 | if (url.trim().length === 0) return; | ||
| 277 | updateThreadPreviewState(ref, (current) => ({ | ||
| 278 | ...current, | ||
| 279 | recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url), | ||
| 280 | })); | ||
| 281 | } | ||
| 282 | |||
| 283 | export function removePreviewThread(ref: ScopedThreadRef): void { | ||
| 284 | const threadKey = scopedThreadKey(ref); | ||
| 285 | appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); | ||
| 286 | syncActivePreviewThread(threadKey, EMPTY_THREAD_PREVIEW_STATE); | ||
| 287 | changedPreviewThreadKeys.delete(threadKey); | ||
| 286 | } | 288 | } |
| 287 | 289 | ||
| 288 | export function isPreviewSupportedInRuntime(): boolean { | 290 | export function isPreviewSupportedInRuntime(): boolean { |
| ... | @@ -290,6 +292,14 @@ export function isPreviewSupportedInRuntime(): boolean { | ... | @@ -290,6 +292,14 @@ export function isPreviewSupportedInRuntime(): boolean { |
| 290 | return Boolean(window.desktopBridge?.preview); | 292 | return Boolean(window.desktopBridge?.preview); |
| 291 | } | 293 | } |
| 292 | 294 | ||
| 295 | export function resetPreviewStateForTests(): void { | ||
| 296 | for (const threadKey of changedPreviewThreadKeys) { | ||
| 297 | appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); | ||
| 298 | } | ||
| 299 | changedPreviewThreadKeys.clear(); | ||
| 300 | appAtomRegistry.set(activePreviewThreadKeysAtom, { keys: new Set<string>() }); | ||
| 301 | } | ||
| 302 | |||
| 293 | export const __testing = { | 303 | export const __testing = { |
| 294 | EMPTY_THREAD_PREVIEW_STATE, | 304 | EMPTY_THREAD_PREVIEW_STATE, |
| 295 | RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT, | 305 | RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT, |
apps/web/src/projectScripts.ts+1-1| ... | @@ -56,7 +56,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>) | ... | @@ -56,7 +56,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>) |
| 56 | return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); | 56 | return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); |
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { | 59 | export function primaryProjectScript(scripts: ReadonlyArray<ProjectScript>): ProjectScript | null { |
| 60 | const regular = scripts.find((script) => !script.runOnWorktreeCreate); | 60 | const regular = scripts.find((script) => !script.runOnWorktreeCreate); |
| 61 | return regular ?? scripts[0] ?? null; | 61 | return regular ?? scripts[0] ?? null; |
| 62 | } | 62 | } |
apps/web/src/rightPanelStore.test.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; | 2 | import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 4 |
apps/web/src/rightPanelStore.ts+1-1| ... | @@ -7,7 +7,7 @@ | ... | @@ -7,7 +7,7 @@ |
| 7 | * terminal surfaces point at terminal session ids, file surfaces point at | 7 | * terminal surfaces point at terminal session ids, file surfaces point at |
| 8 | * workspace paths, and diff/plan/files remain singleton surfaces. | 8 | * workspace paths, and diff/plan/files remain singleton surfaces. |
| 9 | */ | 9 | */ |
| 10 | import { scopedThreadKey } from "@t3tools/client-runtime"; | 10 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 11 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 11 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 12 | import { create } from "zustand"; | 12 | import { create } from "zustand"; |
| 13 | import { createJSONStorage, persist } from "zustand/middleware"; | 13 | import { createJSONStorage, persist } from "zustand/middleware"; |
apps/web/src/router.ts+2-12| ... | @@ -1,25 +1,15 @@ | ... | @@ -1,25 +1,15 @@ |
| 1 | import { createElement } from "react"; | 1 | import { createElement } from "react"; |
| 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | ||
| 3 | import { createRouter, RouterHistory } from "@tanstack/react-router"; | 2 | import { createRouter, RouterHistory } from "@tanstack/react-router"; |
| 4 | 3 | ||
| 5 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; | 4 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 6 | import { routeTree } from "./routeTree.gen"; | 5 | import { routeTree } from "./routeTree.gen"; |
| 7 | 6 | ||
| 8 | export function getRouter(history: RouterHistory) { | 7 | export function getRouter(history: RouterHistory) { |
| 9 | const queryClient = new QueryClient(); | ||
| 10 | |||
| 11 | return createRouter({ | 8 | return createRouter({ |
| 12 | routeTree, | 9 | routeTree, |
| 13 | history, | 10 | history, |
| 14 | context: { | 11 | context: {}, |
| 15 | queryClient, | 12 | Wrap: ({ children }) => createElement(AppAtomRegistryProvider, undefined, children), |
| 16 | }, | ||
| 17 | Wrap: ({ children }) => | ||
| 18 | createElement( | ||
| 19 | QueryClientProvider, | ||
| 20 | { client: queryClient }, | ||
| 21 | createElement(AppAtomRegistryProvider, undefined, children), | ||
| 22 | ), | ||
| 23 | }); | 13 | }); |
| 24 | } | 14 | } |
| 25 | 15 |
apps/web/src/routes/__root.tsx+112-166| ... | @@ -1,14 +1,14 @@ | ... | @@ -1,14 +1,14 @@ |
| 1 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; | 1 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; |
| 2 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; | 2 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 3 | import { | 4 | import { |
| 4 | Outlet, | 5 | Outlet, |
| 5 | createRootRouteWithContext, | 6 | createRootRoute, |
| 6 | type ErrorComponentProps, | 7 | type ErrorComponentProps, |
| 7 | useLocation, | 8 | useLocation, |
| 8 | useNavigate, | 9 | useNavigate, |
| 9 | } from "@tanstack/react-router"; | 10 | } from "@tanstack/react-router"; |
| 10 | import { useEffect, useEffectEvent, useRef } from "react"; | 11 | import { useEffect, useEffectEvent, useRef, useState } from "react"; |
| 11 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; | ||
| 12 | 12 | ||
| 13 | import { APP_DISPLAY_NAME } from "../branding"; | 13 | import { APP_DISPLAY_NAME } from "../branding"; |
| 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| ... | @@ -16,11 +16,7 @@ import { CommandPalette } from "../components/CommandPalette"; | ... | @@ -16,11 +16,7 @@ import { CommandPalette } from "../components/CommandPalette"; |
| 16 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; | 16 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; |
| 17 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; | 17 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 18 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | 18 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 19 | import { | 19 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 20 | SlowRpcAckToastCoordinator, | ||
| 21 | WebSocketConnectionCoordinator, | ||
| 22 | WebSocketConnectionSurface, | ||
| 23 | } from "../components/WebSocketConnectionSurface"; | ||
| 24 | import { Button } from "../components/ui/button"; | 20 | import { Button } from "../components/ui/button"; |
| 25 | import { | 21 | import { |
| 26 | AnchoredToastProvider, | 22 | AnchoredToastProvider, |
| ... | @@ -29,44 +25,33 @@ import { | ... | @@ -29,44 +25,33 @@ import { |
| 29 | toastManager, | 25 | toastManager, |
| 30 | } from "../components/ui/toast"; | 26 | } from "../components/ui/toast"; |
| 31 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | 27 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 32 | import { readLocalApi } from "../localApi"; | ||
| 33 | import { useSettings } from "../hooks/useSettings"; | 28 | import { useSettings } from "../hooks/useSettings"; |
| 34 | import { | 29 | import { |
| 35 | deriveLogicalProjectKeyFromSettings, | 30 | deriveLogicalProjectKeyFromSettings, |
| 36 | derivePhysicalProjectKeyFromPath, | 31 | derivePhysicalProjectKeyFromPath, |
| 37 | selectProjectGroupingSettings, | 32 | selectProjectGroupingSettings, |
| 38 | } from "../logicalProject"; | 33 | } from "../logicalProject"; |
| 39 | import { | ||
| 40 | getServerConfigUpdatedNotification, | ||
| 41 | ServerConfigUpdatedNotification, | ||
| 42 | startServerStateSync, | ||
| 43 | useServerConfig, | ||
| 44 | useServerConfigUpdatedSubscription, | ||
| 45 | useServerWelcomeSubscription, | ||
| 46 | } from "../rpc/serverState"; | ||
| 47 | import { useStore } from "../store"; | ||
| 48 | import { useUiStateStore } from "../uiStateStore"; | 34 | import { useUiStateStore } from "../uiStateStore"; |
| 49 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; | 35 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; |
| 50 | import { | ||
| 51 | ensureEnvironmentConnectionBootstrapped, | ||
| 52 | getPrimaryEnvironmentConnection, | ||
| 53 | listSavedEnvironmentRecords, | ||
| 54 | waitForSavedEnvironmentRegistryHydration, | ||
| 55 | startEnvironmentConnectionService, | ||
| 56 | useSavedEnvironmentRegistryStore, | ||
| 57 | } from "../environments/runtime"; | ||
| 58 | import { configureClientTracing } from "../observability/clientTracing"; | 36 | import { configureClientTracing } from "../observability/clientTracing"; |
| 59 | import { | 37 | import { resolveInitialServerAuthGateState } from "../environments/primary"; |
| 60 | ensurePrimaryEnvironmentReady, | ||
| 61 | getPrimaryKnownEnvironment, | ||
| 62 | resolveInitialServerAuthGateState, | ||
| 63 | updatePrimaryEnvironmentDescriptor, | ||
| 64 | } from "../environments/primary"; | ||
| 65 | import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; | 38 | import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; |
| 39 | import { shellEnvironment } from "../state/shell"; | ||
| 40 | import { useAtomValue } from "@effect/atom-react"; | ||
| 41 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 42 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; | ||
| 43 | import { | ||
| 44 | primaryServerConfigAtom, | ||
| 45 | primaryServerConfigEventAtom, | ||
| 46 | primaryServerWelcomeAtom, | ||
| 47 | } from "../state/server"; | ||
| 48 | import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; | ||
| 49 | import { | ||
| 50 | createKeybindingsUpdateToastController, | ||
| 51 | type KeybindingsUpdateToastController, | ||
| 52 | } from "../components/KeybindingsUpdateToast.logic"; | ||
| 66 | 53 | ||
| 67 | export const Route = createRootRouteWithContext<{ | 54 | export const Route = createRootRoute({ |
| 68 | queryClient: QueryClient; | ||
| 69 | }>()({ | ||
| 70 | beforeLoad: async ({ location }) => { | 55 | beforeLoad: async ({ location }) => { |
| 71 | if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { | 56 | if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { |
| 72 | return { | 57 | return { |
| ... | @@ -77,7 +62,6 @@ export const Route = createRootRouteWithContext<{ | ... | @@ -77,7 +62,6 @@ export const Route = createRootRouteWithContext<{ |
| 77 | } | 62 | } |
| 78 | 63 | ||
| 79 | if (isHostedStaticApp(new URL(window.location.href))) { | 64 | if (isHostedStaticApp(new URL(window.location.href))) { |
| 80 | await waitForSavedEnvironmentRegistryHydration(); | ||
| 81 | return { | 65 | return { |
| 82 | authGateState: { | 66 | authGateState: { |
| 83 | status: "hosted-static", | 67 | status: "hosted-static", |
| ... | @@ -85,10 +69,7 @@ export const Route = createRootRouteWithContext<{ | ... | @@ -85,10 +69,7 @@ export const Route = createRootRouteWithContext<{ |
| 85 | }; | 69 | }; |
| 86 | } | 70 | } |
| 87 | 71 | ||
| 88 | const [, authGateState] = await Promise.all([ | 72 | const authGateState = await resolveInitialServerAuthGateState(); |
| 89 | ensurePrimaryEnvironmentReady(), | ||
| 90 | resolveInitialServerAuthGateState(), | ||
| 91 | ]); | ||
| 92 | return { | 73 | return { |
| 93 | authGateState, | 74 | authGateState, |
| 94 | }; | 75 | }; |
| ... | @@ -134,47 +115,42 @@ function RootRouteView() { | ... | @@ -134,47 +115,42 @@ function RootRouteView() { |
| 134 | <ToastProvider> | 115 | <ToastProvider> |
| 135 | <AnchoredToastProvider> | 116 | <AnchoredToastProvider> |
| 136 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} | 117 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| 137 | {primaryEnvironmentAuthenticated ? <ServerStateBootstrap /> : null} | ||
| 138 | <EnvironmentConnectionManagerBootstrap /> | ||
| 139 | <RelayClientInstallDialog /> | 118 | <RelayClientInstallDialog /> |
| 140 | <SshPasswordPromptDialog /> | 119 | <SshPasswordPromptDialog /> |
| 120 | <SlowRpcRequestToastCoordinator /> | ||
| 141 | <HostedStaticEnvironmentBootstrap /> | 121 | <HostedStaticEnvironmentBootstrap /> |
| 142 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} | 122 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
| 143 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} | 123 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} |
| 144 | {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null} | 124 | {appShell} |
| 145 | {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null} | ||
| 146 | {primaryEnvironmentAuthenticated ? ( | ||
| 147 | <WebSocketConnectionSurface>{appShell}</WebSocketConnectionSurface> | ||
| 148 | ) : ( | ||
| 149 | appShell | ||
| 150 | )} | ||
| 151 | </AnchoredToastProvider> | 125 | </AnchoredToastProvider> |
| 152 | </ToastProvider> | 126 | </ToastProvider> |
| 153 | ); | 127 | ); |
| 154 | } | 128 | } |
| 155 | 129 | ||
| 156 | function HostedStaticEnvironmentBootstrap() { | 130 | function HostedStaticEnvironmentBootstrap() { |
| 157 | const savedEnvironmentCount = useSavedEnvironmentRegistryStore( | 131 | const { environments } = useEnvironments(); |
| 158 | (state) => Object.keys(state.byId).length, | 132 | const activeEnvironmentId = useActiveEnvironmentId(); |
| 159 | ); | ||
| 160 | 133 | ||
| 161 | useEffect(() => { | 134 | useEffect(() => { |
| 162 | if (getPrimaryKnownEnvironment()) { | 135 | if ( |
| 136 | environments.some( | ||
| 137 | (environment) => environment.entry.target._tag === "PrimaryConnectionTarget", | ||
| 138 | ) | ||
| 139 | ) { | ||
| 163 | return; | 140 | return; |
| 164 | } | 141 | } |
| 165 | 142 | ||
| 166 | const currentActiveEnvironmentId = useStore.getState().activeEnvironmentId; | 143 | if (activeEnvironmentId) { |
| 167 | if (currentActiveEnvironmentId) { | ||
| 168 | return; | 144 | return; |
| 169 | } | 145 | } |
| 170 | 146 | ||
| 171 | const firstSavedEnvironment = listSavedEnvironmentRecords()[0]; | 147 | const firstSavedEnvironment = environments[0]; |
| 172 | if (!firstSavedEnvironment) { | 148 | if (!firstSavedEnvironment) { |
| 173 | return; | 149 | return; |
| 174 | } | 150 | } |
| 175 | 151 | ||
| 176 | useStore.getState().setActiveEnvironmentId(firstSavedEnvironment.environmentId); | 152 | setActiveEnvironmentId(firstSavedEnvironment.environmentId); |
| 177 | }, [savedEnvironmentCount]); | 153 | }, [activeEnvironmentId, environments]); |
| 178 | 154 | ||
| 179 | return null; | 155 | return null; |
| 180 | } | 156 | } |
| ... | @@ -250,18 +226,6 @@ function errorDetails(error: unknown): string { | ... | @@ -250,18 +226,6 @@ function errorDetails(error: unknown): string { |
| 250 | } | 226 | } |
| 251 | } | 227 | } |
| 252 | 228 | ||
| 253 | function ServerStateBootstrap() { | ||
| 254 | useEffect(() => { | ||
| 255 | if (!getPrimaryKnownEnvironment()) { | ||
| 256 | return; | ||
| 257 | } | ||
| 258 | |||
| 259 | return startServerStateSync(getPrimaryEnvironmentConnection().client.server); | ||
| 260 | }, []); | ||
| 261 | |||
| 262 | return null; | ||
| 263 | } | ||
| 264 | |||
| 265 | function AuthenticatedTracingBootstrap() { | 229 | function AuthenticatedTracingBootstrap() { |
| 266 | useEffect(() => { | 230 | useEffect(() => { |
| 267 | void configureClientTracing(); | 231 | void configureClientTracing(); |
| ... | @@ -270,46 +234,35 @@ function AuthenticatedTracingBootstrap() { | ... | @@ -270,46 +234,35 @@ function AuthenticatedTracingBootstrap() { |
| 270 | return null; | 234 | return null; |
| 271 | } | 235 | } |
| 272 | 236 | ||
| 273 | function EnvironmentConnectionManagerBootstrap() { | ||
| 274 | const queryClient = useQueryClient(); | ||
| 275 | |||
| 276 | useEffect(() => { | ||
| 277 | return startEnvironmentConnectionService(queryClient); | ||
| 278 | }, [queryClient]); | ||
| 279 | |||
| 280 | return null; | ||
| 281 | } | ||
| 282 | |||
| 283 | function EventRouter() { | 237 | function EventRouter() { |
| 284 | const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); | ||
| 285 | const navigate = useNavigate(); | 238 | const navigate = useNavigate(); |
| 286 | const pathname = useLocation({ select: (loc) => loc.pathname }); | 239 | const pathname = useLocation({ select: (loc) => loc.pathname }); |
| 287 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); | 240 | const projectGroupingSettings = useSettings(selectProjectGroupingSettings); |
| 241 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 242 | const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { | ||
| 243 | reportFailure: false, | ||
| 244 | }); | ||
| 245 | const serverConfig = useAtomValue(primaryServerConfigAtom); | ||
| 246 | const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom); | ||
| 247 | const serverWelcome = useAtomValue(primaryServerWelcomeAtom); | ||
| 288 | const readPathname = useEffectEvent(() => pathname); | 248 | const readPathname = useEffectEvent(() => pathname); |
| 289 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | 249 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 290 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); | 250 | const handledConfigEventRef = useRef(serverConfigEvent); |
| 291 | const lastKeybindingsSuccessToastAtRef = useRef(0); | 251 | const [keybindingsToastController] = useState<KeybindingsUpdateToastController>(() => |
| 292 | const disposedRef = useRef(false); | 252 | createKeybindingsUpdateToastController({}), |
| 293 | const serverConfig = useServerConfig(); | 253 | ); |
| 294 | 254 | ||
| 295 | const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { | 255 | const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { |
| 296 | if (!payload) return; | 256 | if (!payload) return; |
| 297 | 257 | ||
| 298 | updatePrimaryEnvironmentDescriptor(payload.environment); | ||
| 299 | setActiveEnvironmentId(payload.environment.environmentId); | 258 | setActiveEnvironmentId(payload.environment.environmentId); |
| 300 | void (async () => { | 259 | void (async () => { |
| 301 | await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); | ||
| 302 | if (disposedRef.current) { | ||
| 303 | return; | ||
| 304 | } | ||
| 305 | |||
| 306 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { | 260 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { |
| 307 | return; | 261 | return; |
| 308 | } | 262 | } |
| 309 | const bootstrapEnvironmentState = | 263 | const bootstrapProject = readProject( |
| 310 | useStore.getState().environmentStateById[payload.environment.environmentId]; | 264 | scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), |
| 311 | const bootstrapProject = | 265 | ); |
| 312 | bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null; | ||
| 313 | const bootstrapProjectKey = | 266 | const bootstrapProjectKey = |
| 314 | (bootstrapProject | 267 | (bootstrapProject |
| 315 | ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) | 268 | ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) |
| ... | @@ -340,91 +293,84 @@ function EventRouter() { | ... | @@ -340,91 +293,84 @@ function EventRouter() { |
| 340 | })().catch(() => undefined); | 293 | })().catch(() => undefined); |
| 341 | }); | 294 | }); |
| 342 | 295 | ||
| 343 | const handleServerConfigUpdated = useEffectEvent( | 296 | const handleServerConfigUpdated = useEffectEvent(() => { |
| 344 | (notification: ServerConfigUpdatedNotification | null) => { | 297 | const decision = keybindingsToastController.handle(serverConfigEvent); |
| 345 | if (!notification) return; | 298 | if (!decision) { |
| 346 | 299 | return; | |
| 347 | const { id, payload, source } = notification; | 300 | } |
| 348 | if (id <= seenServerConfigUpdateIdRef.current) { | ||
| 349 | return; | ||
| 350 | } | ||
| 351 | seenServerConfigUpdateIdRef.current = id; | ||
| 352 | if (source !== "keybindingsUpdated") { | ||
| 353 | return; | ||
| 354 | } | ||
| 355 | 301 | ||
| 356 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); | 302 | if (decision._tag === "Success") { |
| 357 | if (!issue) { | 303 | toastManager.add({ |
| 358 | const now = Date.now(); | 304 | type: "success", |
| 359 | if (now - lastKeybindingsSuccessToastAtRef.current < 2_000) { | 305 | title: "Keybindings updated", |
| 360 | return; | 306 | description: "Keybindings configuration reloaded successfully.", |
| 361 | } | 307 | }); |
| 362 | lastKeybindingsSuccessToastAtRef.current = now; | 308 | return; |
| 363 | toastManager.add({ | 309 | } |
| 364 | type: "success", | ||
| 365 | title: "Keybindings updated", | ||
| 366 | description: "Keybindings configuration reloaded successfully.", | ||
| 367 | }); | ||
| 368 | return; | ||
| 369 | } | ||
| 370 | 310 | ||
| 371 | toastManager.add( | 311 | toastManager.add( |
| 372 | stackedThreadToast({ | 312 | stackedThreadToast({ |
| 373 | type: "warning", | 313 | type: "warning", |
| 374 | title: "Invalid keybindings configuration", | 314 | title: "Invalid keybindings configuration", |
| 375 | description: issue.message, | 315 | description: decision.message, |
| 376 | actionVariant: "outline", | 316 | actionVariant: "outline", |
| 377 | actionProps: { | 317 | actionProps: { |
| 378 | children: "Open keybindings.json", | 318 | children: "Open keybindings.json", |
| 379 | onClick: () => { | 319 | onClick: () => { |
| 380 | const api = readLocalApi(); | 320 | if (!serverConfig || !primaryEnvironment) { |
| 381 | if (!api) { | 321 | return; |
| 322 | } | ||
| 323 | |||
| 324 | const editor = resolveAndPersistPreferredEditor(serverConfig.availableEditors); | ||
| 325 | if (!editor) { | ||
| 326 | return; | ||
| 327 | } | ||
| 328 | void (async () => { | ||
| 329 | const result = await openInEditor({ | ||
| 330 | environmentId: primaryEnvironment.environmentId, | ||
| 331 | input: { | ||
| 332 | cwd: serverConfig.keybindingsConfigPath, | ||
| 333 | editor, | ||
| 334 | }, | ||
| 335 | }); | ||
| 336 | if (result._tag === "Success") { | ||
| 382 | return; | 337 | return; |
| 383 | } | 338 | } |
| 384 | 339 | const error = squashAtomCommandFailure(result); | |
| 385 | void Promise.resolve(serverConfig ?? api.server.getConfig()) | 340 | toastManager.add( |
| 386 | .then((config) => { | 341 | stackedThreadToast({ |
| 387 | const editor = resolveAndPersistPreferredEditor(config.availableEditors); | 342 | type: "error", |
| 388 | if (!editor) { | 343 | title: "Unable to open keybindings file", |
| 389 | throw new Error("No available editors found."); | 344 | description: |
| 390 | } | 345 | error instanceof Error ? error.message : "Unknown error opening file.", |
| 391 | return api.shell.openInEditor(config.keybindingsConfigPath, editor); | 346 | }), |
| 392 | }) | 347 | ); |
| 393 | .catch((error) => { | 348 | })(); |
| 394 | toastManager.add( | ||
| 395 | stackedThreadToast({ | ||
| 396 | type: "error", | ||
| 397 | title: "Unable to open keybindings file", | ||
| 398 | description: | ||
| 399 | error instanceof Error ? error.message : "Unknown error opening file.", | ||
| 400 | }), | ||
| 401 | ); | ||
| 402 | }); | ||
| 403 | }, | ||
| 404 | }, | 349 | }, |
| 405 | }), | 350 | }, |
| 406 | ); | 351 | }), |
| 407 | }, | 352 | ); |
| 408 | ); | 353 | }); |
| 409 | 354 | ||
| 410 | useEffect(() => { | 355 | useEffect(() => { |
| 411 | if (!serverConfig) { | 356 | if (!serverConfig) { |
| 412 | return; | 357 | return; |
| 413 | } | 358 | } |
| 414 | 359 | ||
| 415 | updatePrimaryEnvironmentDescriptor(serverConfig.environment); | ||
| 416 | setActiveEnvironmentId(serverConfig.environment.environmentId); | 360 | setActiveEnvironmentId(serverConfig.environment.environmentId); |
| 417 | }, [serverConfig, setActiveEnvironmentId]); | 361 | }, [serverConfig]); |
| 418 | 362 | ||
| 419 | useEffect(() => { | 363 | useEffect(() => { |
| 420 | disposedRef.current = false; | 364 | handleWelcome(serverWelcome); |
| 421 | return () => { | 365 | }, [serverWelcome]); |
| 422 | disposedRef.current = true; | ||
| 423 | }; | ||
| 424 | }, []); | ||
| 425 | 366 | ||
| 426 | useServerWelcomeSubscription(handleWelcome); | 367 | useEffect(() => { |
| 427 | useServerConfigUpdatedSubscription(handleServerConfigUpdated); | 368 | if (serverConfigEvent === null || handledConfigEventRef.current === serverConfigEvent) { |
| 369 | return; | ||
| 370 | } | ||
| 371 | handledConfigEventRef.current = serverConfigEvent; | ||
| 372 | handleServerConfigUpdated(); | ||
| 373 | }, [serverConfigEvent]); | ||
| 428 | 374 | ||
| 429 | return null; | 375 | return null; |
| 430 | } | 376 | } |
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+27-16| ... | @@ -1,28 +1,30 @@ | ... | @@ -1,28 +1,30 @@ |
| 1 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; |
| 2 | import { useEffect, useMemo } from "react"; | 2 | import { useEffect } from "react"; |
| 3 | 3 | ||
| 4 | import ChatView from "../components/ChatView"; | 4 | import ChatView from "../components/ChatView"; |
| 5 | import { threadHasStarted } from "../components/ChatView.logic"; | 5 | import { threadHasStarted } from "../components/ChatView.logic"; |
| 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; | 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; |
| 7 | import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; | 7 | import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; |
| 8 | import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; | ||
| 9 | import { createThreadSelectorByRef } from "../storeSelectors"; | ||
| 10 | import { resolveThreadRouteRef } from "../threadRoutes"; | 8 | import { resolveThreadRouteRef } from "../threadRoutes"; |
| 11 | import { SidebarInset } from "~/components/ui/sidebar"; | 9 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 10 | import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; | ||
| 11 | import { useEnvironmentQuery } from "../state/query"; | ||
| 12 | import { environmentShell } from "../state/shell"; | ||
| 12 | 13 | ||
| 13 | function ChatThreadRouteView() { | 14 | function ChatThreadRouteView() { |
| 14 | const navigate = useNavigate(); | 15 | const navigate = useNavigate(); |
| 15 | const threadRef = Route.useParams({ | 16 | const threadRef = Route.useParams({ |
| 16 | select: (params) => resolveThreadRouteRef(params), | 17 | select: (params) => resolveThreadRouteRef(params), |
| 17 | }); | 18 | }); |
| 18 | const bootstrapComplete = useStore( | 19 | const shell = useEnvironmentQuery( |
| 19 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, | 20 | threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), |
| 20 | ); | ||
| 21 | const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); | ||
| 22 | const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef)); | ||
| 23 | const environmentHasServerThreads = useStore( | ||
| 24 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, | ||
| 25 | ); | 21 | ); |
| 22 | const serverThreadShell = useThreadShell(threadRef); | ||
| 23 | const serverThreadDetail = useThreadDetail(threadRef); | ||
| 24 | const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); | ||
| 25 | const bootstrapComplete = shell.data?.snapshot._tag === "Some"; | ||
| 26 | const threadExists = serverThreadShell !== null || serverThreadDetail !== null; | ||
| 27 | const environmentHasServerThreads = environmentThreadRefs.length > 0; | ||
| 26 | const draftThreadExists = useComposerDraftStore((store) => | 28 | const draftThreadExists = useComposerDraftStore((store) => |
| 27 | threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, | 29 | threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, |
| 28 | ); | 30 | ); |
| ... | @@ -30,26 +32,35 @@ function ChatThreadRouteView() { | ... | @@ -30,26 +32,35 @@ function ChatThreadRouteView() { |
| 30 | threadRef ? store.getDraftThreadByRef(threadRef) : null, | 32 | threadRef ? store.getDraftThreadByRef(threadRef) : null, |
| 31 | ); | 33 | ); |
| 32 | const environmentHasDraftThreads = useComposerDraftStore((store) => { | 34 | const environmentHasDraftThreads = useComposerDraftStore((store) => { |
| 33 | if (!threadRef) return false; | 35 | if (!threadRef) { |
| 36 | return false; | ||
| 37 | } | ||
| 34 | return store.hasDraftThreadsInEnvironment(threadRef.environmentId); | 38 | return store.hasDraftThreadsInEnvironment(threadRef.environmentId); |
| 35 | }); | 39 | }); |
| 36 | const routeThreadExists = threadExists || draftThreadExists; | 40 | const routeThreadExists = threadExists || draftThreadExists; |
| 37 | const serverThreadStarted = threadHasStarted(serverThread); | 41 | const serverThreadStarted = threadHasStarted(serverThreadDetail); |
| 38 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; | 42 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; |
| 39 | 43 | ||
| 40 | useEffect(() => { | 44 | useEffect(() => { |
| 41 | if (!threadRef || !bootstrapComplete) return; | 45 | if (!threadRef || !bootstrapComplete) { |
| 46 | return; | ||
| 47 | } | ||
| 48 | |||
| 42 | if (!routeThreadExists && environmentHasAnyThreads) { | 49 | if (!routeThreadExists && environmentHasAnyThreads) { |
| 43 | void navigate({ to: "/", replace: true }); | 50 | void navigate({ to: "/", replace: true }); |
| 44 | } | 51 | } |
| 45 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); | 52 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); |
| 46 | 53 | ||
| 47 | useEffect(() => { | 54 | useEffect(() => { |
| 48 | if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) return; | 55 | if (!threadRef || !serverThreadStarted || !draftThread) { |
| 56 | return; | ||
| 57 | } | ||
| 49 | finalizePromotedDraftThreadByRef(threadRef); | 58 | finalizePromotedDraftThreadByRef(threadRef); |
| 50 | }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); | 59 | }, [draftThread, serverThreadStarted, threadRef]); |
| 51 | 60 | ||
| 52 | if (!threadRef || !bootstrapComplete || !routeThreadExists) return null; | 61 | if (!threadRef || !bootstrapComplete || !routeThreadExists) { |
| 62 | return null; | ||
| 63 | } | ||
| 53 | 64 | ||
| 54 | return ( | 65 | return ( |
| 55 | <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh"> | 66 | <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh"> |
apps/web/src/routes/_chat.draft.$draftId.tsx+25-24| ... | @@ -1,39 +1,40 @@ | ... | @@ -1,39 +1,40 @@ |
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { useEffect, useMemo } from "react"; | 2 | import { useEffect } from "react"; |
| 3 | import ChatView from "../components/ChatView"; | 3 | import ChatView from "../components/ChatView"; |
| 4 | import { threadHasStarted } from "../components/ChatView.logic"; | 4 | import { threadHasStarted } from "../components/ChatView.logic"; |
| 5 | import { useComposerDraftStore, DraftId } from "../composerDraftStore"; | 5 | import { |
| 6 | DraftId, | ||
| 7 | markPromotedDraftThreadByRef, | ||
| 8 | useComposerDraftStore, | ||
| 9 | } from "../composerDraftStore"; | ||
| 6 | import { SidebarInset } from "../components/ui/sidebar"; | 10 | import { SidebarInset } from "../components/ui/sidebar"; |
| 7 | import { createThreadSelectorAcrossEnvironments } from "../storeSelectors"; | ||
| 8 | import { useStore } from "../store"; | ||
| 9 | import { buildThreadRouteParams } from "../threadRoutes"; | 11 | import { buildThreadRouteParams } from "../threadRoutes"; |
| 12 | import { useThread, useThreadRefs } from "../state/entities"; | ||
| 10 | 13 | ||
| 11 | function DraftChatThreadRouteView() { | 14 | function DraftChatThreadRouteView() { |
| 12 | const navigate = useNavigate(); | 15 | const navigate = useNavigate(); |
| 13 | const { draftId: rawDraftId } = Route.useParams(); | 16 | const { draftId: rawDraftId } = Route.useParams(); |
| 14 | const draftId = DraftId.make(rawDraftId); | 17 | const draftId = DraftId.make(rawDraftId); |
| 15 | const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); | 18 | const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); |
| 16 | const serverThread = useStore( | 19 | const threadRefs = useThreadRefs(); |
| 17 | useMemo( | 20 | const inferredThreadRef = draftSession |
| 18 | () => createThreadSelectorAcrossEnvironments(draftSession?.threadId ?? null), | 21 | ? (threadRefs.find( |
| 19 | [draftSession?.threadId], | 22 | (ref) => |
| 20 | ), | 23 | ref.environmentId === draftSession.environmentId && |
| 21 | ); | 24 | ref.threadId === draftSession.threadId, |
| 25 | ) ?? null) | ||
| 26 | : null; | ||
| 27 | const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; | ||
| 28 | const serverThread = useThread(serverThreadRef); | ||
| 22 | const serverThreadStarted = threadHasStarted(serverThread); | 29 | const serverThreadStarted = threadHasStarted(serverThread); |
| 23 | const canonicalThreadRef = useMemo( | 30 | const canonicalThreadRef = serverThreadStarted ? serverThreadRef : null; |
| 24 | () => | 31 | |
| 25 | draftSession?.promotedTo | 32 | useEffect(() => { |
| 26 | ? serverThreadStarted | 33 | if (!inferredThreadRef || draftSession?.promotedTo) { |
| 27 | ? draftSession.promotedTo | 34 | return; |
| 28 | : null | 35 | } |
| 29 | : serverThread | 36 | markPromotedDraftThreadByRef(inferredThreadRef); |
| 30 | ? { | 37 | }, [draftSession?.promotedTo, inferredThreadRef]); |
| 31 | environmentId: serverThread.environmentId, | ||
| 32 | threadId: serverThread.id, | ||
| 33 | } | ||
| 34 | : null, | ||
| 35 | [draftSession?.promotedTo, serverThread, serverThreadStarted], | ||
| 36 | ); | ||
| 37 | 38 | ||
| 38 | useEffect(() => { | 39 | useEffect(() => { |
| 39 | if (!canonicalThreadRef) { | 40 | if (!canonicalThreadRef) { |
apps/web/src/routes/_chat.index.tsx+3-5| ... | @@ -5,17 +5,15 @@ import { NoActiveThreadState } from "../components/NoActiveThreadState"; | ... | @@ -5,17 +5,15 @@ import { NoActiveThreadState } from "../components/NoActiveThreadState"; |
| 5 | import { Button } from "../components/ui/button"; | 5 | import { Button } from "../components/ui/button"; |
| 6 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; | 6 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; |
| 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; | 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; |
| 8 | import { useSavedEnvironmentRegistryStore } from "../environments/runtime"; | 8 | import { useEnvironments } from "../state/environments"; |
| 9 | import { APP_DISPLAY_NAME } from "~/branding"; | 9 | import { APP_DISPLAY_NAME } from "~/branding"; |
| 10 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | 10 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 11 | 11 | ||
| 12 | function ChatIndexRouteView() { | 12 | function ChatIndexRouteView() { |
| 13 | const { authGateState } = Route.useRouteContext(); | 13 | const { authGateState } = Route.useRouteContext(); |
| 14 | const savedEnvironmentCount = useSavedEnvironmentRegistryStore( | 14 | const { environments } = useEnvironments(); |
| 15 | (state) => Object.keys(state.byId).length, | ||
| 16 | ); | ||
| 17 | 15 | ||
| 18 | if (authGateState.status === "hosted-static" && savedEnvironmentCount === 0) { | 16 | if (authGateState.status === "hosted-static" && environments.length === 0) { |
| 19 | return <HostedStaticOnboardingState />; | 17 | return <HostedStaticOnboardingState />; |
| 20 | } | 18 | } |
| 21 | 19 |
apps/web/src/routes/_chat.tsx+7-6| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; | 1 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useEffect } from "react"; | 3 | import { useEffect } from "react"; |
| 3 | 4 | ||
| 4 | import { useCommandPaletteStore } from "../commandPaletteStore"; | 5 | import { isCommandPaletteOpen } from "../commandPaletteContext"; |
| 5 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; | 6 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; |
| 6 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 7 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 7 | import { | 8 | import { |
| ... | @@ -18,14 +19,14 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; | ... | @@ -18,14 +19,14 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 18 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; | 19 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; |
| 19 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | 20 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 20 | import { useSettings } from "~/hooks/useSettings"; | 21 | import { useSettings } from "~/hooks/useSettings"; |
| 21 | import { useServerKeybindings } from "~/rpc/serverState"; | 22 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 22 | 23 | ||
| 23 | function ChatRouteGlobalShortcuts() { | 24 | function ChatRouteGlobalShortcuts() { |
| 24 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); | 25 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); |
| 25 | const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); | 26 | const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); |
| 26 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = | 27 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = |
| 27 | useHandleNewThread(); | 28 | useHandleNewThread(); |
| 28 | const keybindings = useServerKeybindings(); | 29 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 29 | const terminalOpen = useTerminalUiStateStore((state) => | 30 | const terminalOpen = useTerminalUiStateStore((state) => |
| 30 | routeThreadRef | 31 | routeThreadRef |
| 31 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen | 32 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen |
| ... | @@ -53,7 +54,7 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -53,7 +54,7 @@ function ChatRouteGlobalShortcuts() { |
| 53 | }, | 54 | }, |
| 54 | }); | 55 | }); |
| 55 | 56 | ||
| 56 | if (useCommandPaletteStore.getState().open) { | 57 | if (isCommandPaletteOpen()) { |
| 57 | return; | 58 | return; |
| 58 | } | 59 | } |
| 59 | 60 | ||
| ... | @@ -68,7 +69,7 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -68,7 +69,7 @@ function ChatRouteGlobalShortcuts() { |
| 68 | event.stopPropagation(); | 69 | event.stopPropagation(); |
| 69 | void startNewLocalThreadFromContext({ | 70 | void startNewLocalThreadFromContext({ |
| 70 | activeDraftThread, | 71 | activeDraftThread, |
| 71 | activeThread, | 72 | activeThread: activeThread ?? undefined, |
| 72 | defaultProjectRef, | 73 | defaultProjectRef, |
| 73 | defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ | 74 | defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ |
| 74 | defaultEnvMode: appSettings.defaultThreadEnvMode, | 75 | defaultEnvMode: appSettings.defaultThreadEnvMode, |
| ... | @@ -83,7 +84,7 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -83,7 +84,7 @@ function ChatRouteGlobalShortcuts() { |
| 83 | event.stopPropagation(); | 84 | event.stopPropagation(); |
| 84 | void startNewThreadFromContext({ | 85 | void startNewThreadFromContext({ |
| 85 | activeDraftThread, | 86 | activeDraftThread, |
| 86 | activeThread, | 87 | activeThread: activeThread ?? undefined, |
| 87 | defaultProjectRef, | 88 | defaultProjectRef, |
| 88 | defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ | 89 | defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ |
| 89 | defaultEnvMode: appSettings.defaultThreadEnvMode, | 90 | defaultEnvMode: appSettings.defaultThreadEnvMode, |
apps/web/src/rpc/serverState.test.ts deleted-369| ... | @@ -1,369 +0,0 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | EnvironmentId, | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 6 | ProjectId, | ||
| 7 | ThreadId, | ||
| 8 | type ServerConfig, | ||
| 9 | type ServerConfigStreamEvent, | ||
| 10 | type ServerLifecycleStreamEvent, | ||
| 11 | type ServerProvider, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | getServerConfig, | ||
| 18 | getServerKeybindings, | ||
| 19 | onProvidersUpdated, | ||
| 20 | onServerConfigUpdated, | ||
| 21 | onWelcome, | ||
| 22 | resetServerStateForTests, | ||
| 23 | startServerStateSync, | ||
| 24 | } from "./serverState"; | ||
| 25 | |||
| 26 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | ||
| 27 | listeners.add(listener); | ||
| 28 | return () => { | ||
| 29 | listeners.delete(listener); | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | function createDeferredPromise<T>() { | ||
| 34 | let resolve!: (value: T) => void; | ||
| 35 | const promise = new Promise<T>((nextResolve) => { | ||
| 36 | resolve = nextResolve; | ||
| 37 | }); | ||
| 38 | |||
| 39 | return { promise, resolve }; | ||
| 40 | } | ||
| 41 | |||
| 42 | const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>(); | ||
| 43 | const configListeners = new Set<(event: ServerConfigStreamEvent) => void>(); | ||
| 44 | |||
| 45 | const defaultProviders: ReadonlyArray<ServerProvider> = [ | ||
| 46 | { | ||
| 47 | instanceId: ProviderInstanceId.make("codex"), | ||
| 48 | driver: ProviderDriverKind.make("codex"), | ||
| 49 | enabled: true, | ||
| 50 | installed: true, | ||
| 51 | version: "0.116.0", | ||
| 52 | status: "ready", | ||
| 53 | auth: { status: "authenticated" }, | ||
| 54 | checkedAt: "2026-01-01T00:00:00.000Z", | ||
| 55 | models: [], | ||
| 56 | slashCommands: [], | ||
| 57 | skills: [], | ||
| 58 | }, | ||
| 59 | ]; | ||
| 60 | |||
| 61 | const baseEnvironment = { | ||
| 62 | environmentId: EnvironmentId.make("environment-local"), | ||
| 63 | label: "Local environment", | ||
| 64 | platform: { | ||
| 65 | os: "darwin" as const, | ||
| 66 | arch: "arm64" as const, | ||
| 67 | }, | ||
| 68 | serverVersion: "0.0.0-test", | ||
| 69 | capabilities: { | ||
| 70 | repositoryIdentity: true, | ||
| 71 | }, | ||
| 72 | }; | ||
| 73 | |||
| 74 | const baseServerConfig: ServerConfig = { | ||
| 75 | environment: baseEnvironment, | ||
| 76 | auth: { | ||
| 77 | policy: "loopback-browser", | ||
| 78 | bootstrapMethods: ["one-time-token"], | ||
| 79 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | ||
| 80 | sessionCookieName: "t3_session", | ||
| 81 | }, | ||
| 82 | cwd: "/tmp/workspace", | ||
| 83 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | ||
| 84 | keybindings: [], | ||
| 85 | issues: [], | ||
| 86 | providers: defaultProviders, | ||
| 87 | availableEditors: ["cursor"], | ||
| 88 | observability: { | ||
| 89 | logsDirectoryPath: "/tmp/workspace/.config/logs", | ||
| 90 | localTracingEnabled: true, | ||
| 91 | otlpTracesEnabled: false, | ||
| 92 | otlpMetricsEnabled: false, | ||
| 93 | }, | ||
| 94 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 95 | }; | ||
| 96 | |||
| 97 | const serverApi = { | ||
| 98 | getConfig: vi.fn<() => Promise<ServerConfig>>(), | ||
| 99 | subscribeConfig: vi.fn((listener: (event: ServerConfigStreamEvent) => void) => | ||
| 100 | registerListener(configListeners, listener), | ||
| 101 | ), | ||
| 102 | subscribeLifecycle: vi.fn((listener: (event: ServerLifecycleStreamEvent) => void) => | ||
| 103 | registerListener(lifecycleListeners, listener), | ||
| 104 | ), | ||
| 105 | }; | ||
| 106 | |||
| 107 | function emitLifecycleEvent(event: ServerLifecycleStreamEvent) { | ||
| 108 | for (const listener of lifecycleListeners) { | ||
| 109 | listener(event); | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | function emitServerConfigEvent(event: ServerConfigStreamEvent) { | ||
| 114 | for (const listener of configListeners) { | ||
| 115 | listener(event); | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | ||
| 120 | const startedAt = Date.now(); | ||
| 121 | for (;;) { | ||
| 122 | try { | ||
| 123 | assertion(); | ||
| 124 | return; | ||
| 125 | } catch (error) { | ||
| 126 | if (Date.now() - startedAt >= timeoutMs) { | ||
| 127 | throw error; | ||
| 128 | } | ||
| 129 | await new Promise((resolve) => setTimeout(resolve, 10)); | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | beforeEach(() => { | ||
| 135 | vi.clearAllMocks(); | ||
| 136 | lifecycleListeners.clear(); | ||
| 137 | configListeners.clear(); | ||
| 138 | resetServerStateForTests(); | ||
| 139 | }); | ||
| 140 | |||
| 141 | afterEach(() => { | ||
| 142 | resetServerStateForTests(); | ||
| 143 | }); | ||
| 144 | |||
| 145 | describe("serverState", () => { | ||
| 146 | it("uses default keybindings before a server config snapshot is available", () => { | ||
| 147 | expect(getServerConfig()).toBeNull(); | ||
| 148 | expect(getServerKeybindings()).toEqual(DEFAULT_RESOLVED_KEYBINDINGS); | ||
| 149 | }); | ||
| 150 | |||
| 151 | it("bootstraps the server config snapshot and replays it to late subscribers", async () => { | ||
| 152 | serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); | ||
| 153 | |||
| 154 | const configListener = vi.fn(); | ||
| 155 | const stop = startServerStateSync(serverApi); | ||
| 156 | const unsubscribe = onServerConfigUpdated(configListener); | ||
| 157 | |||
| 158 | await waitFor(() => { | ||
| 159 | expect(getServerConfig()).toEqual(baseServerConfig); | ||
| 160 | }); | ||
| 161 | |||
| 162 | expect(serverApi.subscribeConfig).toHaveBeenCalledOnce(); | ||
| 163 | expect(serverApi.subscribeLifecycle).toHaveBeenCalledOnce(); | ||
| 164 | expect(serverApi.getConfig).toHaveBeenCalledOnce(); | ||
| 165 | expect(configListener).toHaveBeenCalledWith( | ||
| 166 | { | ||
| 167 | issues: [], | ||
| 168 | providers: defaultProviders, | ||
| 169 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 170 | }, | ||
| 171 | "snapshot", | ||
| 172 | ); | ||
| 173 | |||
| 174 | const lateListener = vi.fn(); | ||
| 175 | const unsubscribeLate = onServerConfigUpdated(lateListener); | ||
| 176 | expect(lateListener).toHaveBeenCalledWith( | ||
| 177 | { | ||
| 178 | issues: [], | ||
| 179 | providers: defaultProviders, | ||
| 180 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 181 | }, | ||
| 182 | "snapshot", | ||
| 183 | ); | ||
| 184 | |||
| 185 | unsubscribeLate(); | ||
| 186 | unsubscribe(); | ||
| 187 | stop(); | ||
| 188 | }); | ||
| 189 | |||
| 190 | it("keeps the streamed snapshot when it arrives before the fallback fetch resolves", async () => { | ||
| 191 | const deferred = createDeferredPromise<ServerConfig>(); | ||
| 192 | serverApi.getConfig.mockReturnValueOnce(deferred.promise); | ||
| 193 | const stop = startServerStateSync(serverApi); | ||
| 194 | |||
| 195 | const streamedConfig: ServerConfig = { | ||
| 196 | ...baseServerConfig, | ||
| 197 | cwd: "/tmp/from-stream", | ||
| 198 | }; | ||
| 199 | |||
| 200 | emitServerConfigEvent({ | ||
| 201 | version: 1, | ||
| 202 | type: "snapshot", | ||
| 203 | config: streamedConfig, | ||
| 204 | }); | ||
| 205 | |||
| 206 | await waitFor(() => { | ||
| 207 | expect(getServerConfig()).toEqual(streamedConfig); | ||
| 208 | }); | ||
| 209 | |||
| 210 | deferred.resolve(baseServerConfig); | ||
| 211 | await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| 212 | |||
| 213 | expect(getServerConfig()).toEqual(streamedConfig); | ||
| 214 | stop(); | ||
| 215 | }); | ||
| 216 | |||
| 217 | it("replays welcome events to late subscribers", async () => { | ||
| 218 | serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); | ||
| 219 | const stop = startServerStateSync(serverApi); | ||
| 220 | |||
| 221 | const listener = vi.fn(); | ||
| 222 | const unsubscribe = onWelcome(listener); | ||
| 223 | |||
| 224 | emitLifecycleEvent({ | ||
| 225 | version: 1, | ||
| 226 | sequence: 1, | ||
| 227 | type: "welcome", | ||
| 228 | payload: { | ||
| 229 | environment: baseEnvironment, | ||
| 230 | cwd: "/tmp/workspace", | ||
| 231 | projectName: "t3-code", | ||
| 232 | bootstrapProjectId: ProjectId.make("project-1"), | ||
| 233 | bootstrapThreadId: ThreadId.make("thread-1"), | ||
| 234 | }, | ||
| 235 | }); | ||
| 236 | |||
| 237 | expect(listener).toHaveBeenCalledWith({ | ||
| 238 | environment: baseEnvironment, | ||
| 239 | cwd: "/tmp/workspace", | ||
| 240 | projectName: "t3-code", | ||
| 241 | bootstrapProjectId: ProjectId.make("project-1"), | ||
| 242 | bootstrapThreadId: ThreadId.make("thread-1"), | ||
| 243 | }); | ||
| 244 | |||
| 245 | const lateListener = vi.fn(); | ||
| 246 | const unsubscribeLate = onWelcome(lateListener); | ||
| 247 | expect(lateListener).toHaveBeenCalledWith({ | ||
| 248 | environment: baseEnvironment, | ||
| 249 | cwd: "/tmp/workspace", | ||
| 250 | projectName: "t3-code", | ||
| 251 | bootstrapProjectId: ProjectId.make("project-1"), | ||
| 252 | bootstrapThreadId: ThreadId.make("thread-1"), | ||
| 253 | }); | ||
| 254 | |||
| 255 | unsubscribeLate(); | ||
| 256 | unsubscribe(); | ||
| 257 | stop(); | ||
| 258 | }); | ||
| 259 | |||
| 260 | it("merges provider, settings, and keybinding updates into the cached config", async () => { | ||
| 261 | serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); | ||
| 262 | const configListener = vi.fn(); | ||
| 263 | const providersListener = vi.fn(); | ||
| 264 | const stop = startServerStateSync(serverApi); | ||
| 265 | const unsubscribeConfig = onServerConfigUpdated(configListener); | ||
| 266 | const unsubscribeProviders = onProvidersUpdated(providersListener); | ||
| 267 | |||
| 268 | await waitFor(() => { | ||
| 269 | expect(getServerConfig()).toEqual(baseServerConfig); | ||
| 270 | }); | ||
| 271 | |||
| 272 | const nextProviders: ReadonlyArray<ServerProvider> = [ | ||
| 273 | { | ||
| 274 | ...defaultProviders[0]!, | ||
| 275 | status: "warning", | ||
| 276 | checkedAt: "2026-01-02T00:00:00.000Z", | ||
| 277 | message: "rate limited", | ||
| 278 | }, | ||
| 279 | ]; | ||
| 280 | |||
| 281 | const nextKeybindings = [ | ||
| 282 | { | ||
| 283 | command: "commandPalette.toggle", | ||
| 284 | shortcut: { | ||
| 285 | key: "p", | ||
| 286 | metaKey: false, | ||
| 287 | ctrlKey: false, | ||
| 288 | shiftKey: false, | ||
| 289 | altKey: false, | ||
| 290 | modKey: true, | ||
| 291 | }, | ||
| 292 | }, | ||
| 293 | ] as const; | ||
| 294 | |||
| 295 | emitServerConfigEvent({ | ||
| 296 | version: 1, | ||
| 297 | type: "keybindingsUpdated", | ||
| 298 | payload: { | ||
| 299 | keybindings: nextKeybindings, | ||
| 300 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | ||
| 301 | }, | ||
| 302 | }); | ||
| 303 | emitServerConfigEvent({ | ||
| 304 | version: 1, | ||
| 305 | type: "providerStatuses", | ||
| 306 | payload: { | ||
| 307 | providers: nextProviders, | ||
| 308 | }, | ||
| 309 | }); | ||
| 310 | emitServerConfigEvent({ | ||
| 311 | version: 1, | ||
| 312 | type: "settingsUpdated", | ||
| 313 | payload: { | ||
| 314 | settings: { | ||
| 315 | ...DEFAULT_SERVER_SETTINGS, | ||
| 316 | enableAssistantStreaming: true, | ||
| 317 | }, | ||
| 318 | }, | ||
| 319 | }); | ||
| 320 | |||
| 321 | await waitFor(() => { | ||
| 322 | expect(getServerConfig()).toEqual({ | ||
| 323 | ...baseServerConfig, | ||
| 324 | keybindings: nextKeybindings, | ||
| 325 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | ||
| 326 | providers: nextProviders, | ||
| 327 | settings: { | ||
| 328 | ...DEFAULT_SERVER_SETTINGS, | ||
| 329 | enableAssistantStreaming: true, | ||
| 330 | }, | ||
| 331 | }); | ||
| 332 | }); | ||
| 333 | |||
| 334 | expect(providersListener).toHaveBeenLastCalledWith({ providers: nextProviders }); | ||
| 335 | expect(configListener).toHaveBeenNthCalledWith( | ||
| 336 | 2, | ||
| 337 | { | ||
| 338 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | ||
| 339 | providers: defaultProviders, | ||
| 340 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 341 | }, | ||
| 342 | "keybindingsUpdated", | ||
| 343 | ); | ||
| 344 | expect(configListener).toHaveBeenNthCalledWith( | ||
| 345 | 3, | ||
| 346 | { | ||
| 347 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | ||
| 348 | providers: nextProviders, | ||
| 349 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 350 | }, | ||
| 351 | "providerStatuses", | ||
| 352 | ); | ||
| 353 | expect(configListener).toHaveBeenLastCalledWith( | ||
| 354 | { | ||
| 355 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | ||
| 356 | providers: nextProviders, | ||
| 357 | settings: { | ||
| 358 | ...DEFAULT_SERVER_SETTINGS, | ||
| 359 | enableAssistantStreaming: true, | ||
| 360 | }, | ||
| 361 | }, | ||
| 362 | "settingsUpdated", | ||
| 363 | ); | ||
| 364 | |||
| 365 | unsubscribeProviders(); | ||
| 366 | unsubscribeConfig(); | ||
| 367 | stop(); | ||
| 368 | }); | ||
| 369 | }); | ||
apps/web/src/rpc/serverState.ts deleted-305| ... | @@ -1,305 +0,0 @@ | ||
| 1 | import { useAtomSubscribe, useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | DEFAULT_SERVER_SETTINGS, | ||
| 4 | type EditorId, | ||
| 5 | type ServerConfig, | ||
| 6 | type ServerConfigStreamEvent, | ||
| 7 | type ServerConfigUpdatedPayload, | ||
| 8 | type ServerLifecycleWelcomePayload, | ||
| 9 | type ServerProvider, | ||
| 10 | type ServerProviderUpdatedPayload, | ||
| 11 | type ServerSettings, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 14 | import { Atom } from "effect/unstable/reactivity"; | ||
| 15 | import { useCallback, useRef } from "react"; | ||
| 16 | |||
| 17 | import type { WsRpcClient } from "@t3tools/client-runtime"; | ||
| 18 | import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; | ||
| 19 | |||
| 20 | export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; | ||
| 21 | |||
| 22 | export interface ServerConfigUpdatedNotification { | ||
| 23 | readonly id: number; | ||
| 24 | readonly payload: ServerConfigUpdatedPayload; | ||
| 25 | readonly source: ServerConfigUpdateSource; | ||
| 26 | } | ||
| 27 | |||
| 28 | type ServerStateClient = Pick< | ||
| 29 | WsRpcClient["server"], | ||
| 30 | "getConfig" | "subscribeConfig" | "subscribeLifecycle" | ||
| 31 | >; | ||
| 32 | |||
| 33 | function makeStateAtom<A>(label: string, initialValue: A) { | ||
| 34 | return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); | ||
| 35 | } | ||
| 36 | |||
| 37 | function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload { | ||
| 38 | return { | ||
| 39 | issues: config.issues, | ||
| 40 | providers: config.providers, | ||
| 41 | settings: config.settings, | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; | ||
| 46 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | ||
| 47 | |||
| 48 | const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> => | ||
| 49 | config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; | ||
| 50 | const selectKeybindings = (config: ServerConfig | null) => | ||
| 51 | config?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; | ||
| 52 | const selectKeybindingsConfigPath = (config: ServerConfig | null) => | ||
| 53 | config?.keybindingsConfigPath ?? null; | ||
| 54 | const selectObservability = (config: ServerConfig | null) => config?.observability ?? null; | ||
| 55 | const selectProviders = (config: ServerConfig | null) => | ||
| 56 | config?.providers ?? EMPTY_SERVER_PROVIDERS; | ||
| 57 | const selectSettings = (config: ServerConfig | null): ServerSettings => | ||
| 58 | config?.settings ?? DEFAULT_SERVER_SETTINGS; | ||
| 59 | |||
| 60 | export const welcomeAtom = makeStateAtom<ServerLifecycleWelcomePayload | null>( | ||
| 61 | "server-welcome", | ||
| 62 | null, | ||
| 63 | ); | ||
| 64 | export const serverConfigAtom = makeStateAtom<ServerConfig | null>("server-config", null); | ||
| 65 | export const serverConfigUpdatedAtom = makeStateAtom<ServerConfigUpdatedNotification | null>( | ||
| 66 | "server-config-updated", | ||
| 67 | null, | ||
| 68 | ); | ||
| 69 | export const providersUpdatedAtom = makeStateAtom<ServerProviderUpdatedPayload | null>( | ||
| 70 | "server-providers-updated", | ||
| 71 | null, | ||
| 72 | ); | ||
| 73 | |||
| 74 | export function getServerConfig(): ServerConfig | null { | ||
| 75 | return appAtomRegistry.get(serverConfigAtom); | ||
| 76 | } | ||
| 77 | |||
| 78 | export function getServerKeybindings(): ServerConfig["keybindings"] { | ||
| 79 | return selectKeybindings(getServerConfig()); | ||
| 80 | } | ||
| 81 | |||
| 82 | export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null { | ||
| 83 | return appAtomRegistry.get(serverConfigUpdatedAtom); | ||
| 84 | } | ||
| 85 | |||
| 86 | export function setServerConfigSnapshot(config: ServerConfig): void { | ||
| 87 | resolveServerConfig(config); | ||
| 88 | emitProvidersUpdated({ providers: config.providers }); | ||
| 89 | emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); | ||
| 90 | } | ||
| 91 | |||
| 92 | export function applyServerConfigEvent(event: ServerConfigStreamEvent): void { | ||
| 93 | switch (event.type) { | ||
| 94 | case "snapshot": { | ||
| 95 | setServerConfigSnapshot(event.config); | ||
| 96 | return; | ||
| 97 | } | ||
| 98 | case "keybindingsUpdated": { | ||
| 99 | const latestServerConfig = getServerConfig(); | ||
| 100 | if (!latestServerConfig) { | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | const nextConfig = { | ||
| 104 | ...latestServerConfig, | ||
| 105 | keybindings: event.payload.keybindings, | ||
| 106 | issues: event.payload.issues, | ||
| 107 | } satisfies ServerConfig; | ||
| 108 | resolveServerConfig(nextConfig); | ||
| 109 | emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); | ||
| 110 | return; | ||
| 111 | } | ||
| 112 | case "providerStatuses": { | ||
| 113 | applyProvidersUpdated(event.payload); | ||
| 114 | return; | ||
| 115 | } | ||
| 116 | case "settingsUpdated": { | ||
| 117 | applySettingsUpdated(event.payload.settings); | ||
| 118 | return; | ||
| 119 | } | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | export function applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void { | ||
| 124 | const latestServerConfig = getServerConfig(); | ||
| 125 | emitProvidersUpdated(payload); | ||
| 126 | |||
| 127 | if (!latestServerConfig) { | ||
| 128 | return; | ||
| 129 | } | ||
| 130 | |||
| 131 | const nextConfig = { | ||
| 132 | ...latestServerConfig, | ||
| 133 | providers: payload.providers, | ||
| 134 | } satisfies ServerConfig; | ||
| 135 | resolveServerConfig(nextConfig); | ||
| 136 | emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses"); | ||
| 137 | } | ||
| 138 | |||
| 139 | export function applySettingsUpdated(settings: ServerSettings): void { | ||
| 140 | const latestServerConfig = getServerConfig(); | ||
| 141 | if (!latestServerConfig) { | ||
| 142 | return; | ||
| 143 | } | ||
| 144 | |||
| 145 | const nextConfig = { | ||
| 146 | ...latestServerConfig, | ||
| 147 | settings, | ||
| 148 | } satisfies ServerConfig; | ||
| 149 | resolveServerConfig(nextConfig); | ||
| 150 | emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated"); | ||
| 151 | } | ||
| 152 | |||
| 153 | export function emitWelcome(payload: ServerLifecycleWelcomePayload): void { | ||
| 154 | appAtomRegistry.set(welcomeAtom, payload); | ||
| 155 | } | ||
| 156 | |||
| 157 | export function onWelcome(listener: (payload: ServerLifecycleWelcomePayload) => void): () => void { | ||
| 158 | return subscribeLatest(welcomeAtom, listener); | ||
| 159 | } | ||
| 160 | |||
| 161 | export function onServerConfigUpdated( | ||
| 162 | listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, | ||
| 163 | ): () => void { | ||
| 164 | return subscribeLatest(serverConfigUpdatedAtom, (notification) => { | ||
| 165 | listener(notification.payload, notification.source); | ||
| 166 | }); | ||
| 167 | } | ||
| 168 | |||
| 169 | export function onProvidersUpdated( | ||
| 170 | listener: (payload: ServerProviderUpdatedPayload) => void, | ||
| 171 | ): () => void { | ||
| 172 | return subscribeLatest(providersUpdatedAtom, listener); | ||
| 173 | } | ||
| 174 | |||
| 175 | export function startServerStateSync(client: ServerStateClient): () => void { | ||
| 176 | let disposed = false; | ||
| 177 | const cleanups = [ | ||
| 178 | client.subscribeLifecycle((event) => { | ||
| 179 | if (event.type === "welcome") { | ||
| 180 | emitWelcome(event.payload); | ||
| 181 | } | ||
| 182 | }), | ||
| 183 | client.subscribeConfig((event) => { | ||
| 184 | applyServerConfigEvent(event); | ||
| 185 | }), | ||
| 186 | ]; | ||
| 187 | |||
| 188 | if (getServerConfig() === null) { | ||
| 189 | void client | ||
| 190 | .getConfig() | ||
| 191 | .then((config) => { | ||
| 192 | if (disposed || getServerConfig() !== null) { | ||
| 193 | return; | ||
| 194 | } | ||
| 195 | setServerConfigSnapshot(config); | ||
| 196 | }) | ||
| 197 | .catch(() => undefined); | ||
| 198 | } | ||
| 199 | |||
| 200 | return () => { | ||
| 201 | disposed = true; | ||
| 202 | for (const cleanup of cleanups) { | ||
| 203 | cleanup(); | ||
| 204 | } | ||
| 205 | }; | ||
| 206 | } | ||
| 207 | |||
| 208 | export function resetServerStateForTests() { | ||
| 209 | resetAppAtomRegistryForTests(); | ||
| 210 | nextServerConfigUpdatedNotificationId = 1; | ||
| 211 | } | ||
| 212 | |||
| 213 | let nextServerConfigUpdatedNotificationId = 1; | ||
| 214 | |||
| 215 | function resolveServerConfig(config: ServerConfig): void { | ||
| 216 | appAtomRegistry.set(serverConfigAtom, config); | ||
| 217 | } | ||
| 218 | |||
| 219 | function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void { | ||
| 220 | appAtomRegistry.set(providersUpdatedAtom, payload); | ||
| 221 | } | ||
| 222 | |||
| 223 | function emitServerConfigUpdated( | ||
| 224 | payload: ServerConfigUpdatedPayload, | ||
| 225 | source: ServerConfigUpdateSource, | ||
| 226 | ): void { | ||
| 227 | appAtomRegistry.set(serverConfigUpdatedAtom, { | ||
| 228 | id: nextServerConfigUpdatedNotificationId++, | ||
| 229 | payload, | ||
| 230 | source, | ||
| 231 | }); | ||
| 232 | } | ||
| 233 | |||
| 234 | function subscribeLatest<A>( | ||
| 235 | atom: Atom.Atom<A | null>, | ||
| 236 | listener: (value: NonNullable<A>) => void, | ||
| 237 | ): () => void { | ||
| 238 | return appAtomRegistry.subscribe( | ||
| 239 | atom, | ||
| 240 | (value) => { | ||
| 241 | if (value === null) { | ||
| 242 | return; | ||
| 243 | } | ||
| 244 | listener(value as NonNullable<A>); | ||
| 245 | }, | ||
| 246 | { immediate: true }, | ||
| 247 | ); | ||
| 248 | } | ||
| 249 | |||
| 250 | function useLatestAtomSubscription<A>( | ||
| 251 | atom: Atom.Atom<A | null>, | ||
| 252 | listener: (value: NonNullable<A>) => void, | ||
| 253 | ): void { | ||
| 254 | const listenerRef = useRef(listener); | ||
| 255 | listenerRef.current = listener; | ||
| 256 | |||
| 257 | const stableListener = useCallback((value: A | null) => { | ||
| 258 | if (value === null) { | ||
| 259 | return; | ||
| 260 | } | ||
| 261 | listenerRef.current(value as NonNullable<A>); | ||
| 262 | }, []); | ||
| 263 | |||
| 264 | useAtomSubscribe(atom, stableListener, { immediate: true }); | ||
| 265 | } | ||
| 266 | |||
| 267 | export function useServerConfig(): ServerConfig | null { | ||
| 268 | return useAtomValue(serverConfigAtom); | ||
| 269 | } | ||
| 270 | |||
| 271 | export function useServerSettings(): ServerSettings { | ||
| 272 | return useAtomValue(serverConfigAtom, selectSettings); | ||
| 273 | } | ||
| 274 | |||
| 275 | export function useServerProviders(): ReadonlyArray<ServerProvider> { | ||
| 276 | return useAtomValue(serverConfigAtom, selectProviders); | ||
| 277 | } | ||
| 278 | |||
| 279 | export function useServerKeybindings(): ServerConfig["keybindings"] { | ||
| 280 | return useAtomValue(serverConfigAtom, selectKeybindings); | ||
| 281 | } | ||
| 282 | |||
| 283 | export function useServerAvailableEditors(): ReadonlyArray<EditorId> { | ||
| 284 | return useAtomValue(serverConfigAtom, selectAvailableEditors); | ||
| 285 | } | ||
| 286 | |||
| 287 | export function useServerKeybindingsConfigPath(): string | null { | ||
| 288 | return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); | ||
| 289 | } | ||
| 290 | |||
| 291 | export function useServerObservability(): ServerConfig["observability"] | null { | ||
| 292 | return useAtomValue(serverConfigAtom, selectObservability); | ||
| 293 | } | ||
| 294 | |||
| 295 | export function useServerWelcomeSubscription( | ||
| 296 | listener: (payload: ServerLifecycleWelcomePayload) => void, | ||
| 297 | ): void { | ||
| 298 | useLatestAtomSubscription(welcomeAtom, listener); | ||
| 299 | } | ||
| 300 | |||
| 301 | export function useServerConfigUpdatedSubscription( | ||
| 302 | listener: (notification: ServerConfigUpdatedNotification) => void, | ||
| 303 | ): void { | ||
| 304 | useLatestAtomSubscription(serverConfigUpdatedAtom, listener); | ||
| 305 | } | ||
apps/web/src/rpc/transportError.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | export { | 1 | export { |
| 2 | isTransportConnectionErrorMessage, | 2 | isTransportConnectionErrorMessage, |
| 3 | sanitizeThreadErrorMessage, | 3 | sanitizeThreadErrorMessage, |
| 4 | } from "@t3tools/client-runtime"; | 4 | } from "@t3tools/client-runtime/errors"; |
apps/web/src/rpc/wsConnectionState.test.ts deleted-107| ... | @@ -1,107 +0,0 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | getWsConnectionStatus, | ||
| 5 | getWsReconnectDelayMsForRetry, | ||
| 6 | getWsConnectionUiState, | ||
| 7 | recordWsConnectionAttempt, | ||
| 8 | recordWsConnectionClosed, | ||
| 9 | recordWsConnectionErrored, | ||
| 10 | recordWsConnectionOpened, | ||
| 11 | resetWsConnectionStateForTests, | ||
| 12 | setBrowserOnlineStatus, | ||
| 13 | WS_RECONNECT_MAX_ATTEMPTS, | ||
| 14 | } from "./wsConnectionState"; | ||
| 15 | |||
| 16 | describe("wsConnectionState", () => { | ||
| 17 | beforeEach(() => { | ||
| 18 | vi.useFakeTimers(); | ||
| 19 | vi.setSystemTime(new Date("2026-04-03T20:30:00.000Z")); | ||
| 20 | resetWsConnectionStateForTests(); | ||
| 21 | }); | ||
| 22 | |||
| 23 | afterEach(() => { | ||
| 24 | vi.useRealTimers(); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("treats a disconnected browser as offline once the websocket drops", () => { | ||
| 28 | recordWsConnectionAttempt("ws://localhost:3020/ws"); | ||
| 29 | recordWsConnectionOpened(); | ||
| 30 | recordWsConnectionClosed({ code: 1006, reason: "offline" }); | ||
| 31 | setBrowserOnlineStatus(false); | ||
| 32 | |||
| 33 | expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("offline"); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("stays in the initial connecting state until the first disconnect", () => { | ||
| 37 | recordWsConnectionAttempt("ws://localhost:3020/ws"); | ||
| 38 | |||
| 39 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 40 | attemptCount: 1, | ||
| 41 | hasConnected: false, | ||
| 42 | phase: "connecting", | ||
| 43 | }); | ||
| 44 | expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("connecting"); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("schedules the next retry after a failed websocket attempt", () => { | ||
| 48 | recordWsConnectionAttempt("ws://localhost:3020/ws", { | ||
| 49 | connectionLabel: "Remote Mac", | ||
| 50 | }); | ||
| 51 | recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); | ||
| 52 | |||
| 53 | const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0); | ||
| 54 | if (firstRetryDelayMs === null) { | ||
| 55 | throw new Error("Expected an initial retry delay."); | ||
| 56 | } | ||
| 57 | |||
| 58 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 59 | connectionLabel: "Remote Mac", | ||
| 60 | nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(), | ||
| 61 | reconnectAttemptCount: 1, | ||
| 62 | reconnectPhase: "waiting", | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("adds a version mismatch hint to websocket errors when metadata includes one", () => { | ||
| 67 | recordWsConnectionAttempt("ws://localhost:3020/ws", { | ||
| 68 | connectionLabel: "Remote Mac", | ||
| 69 | }); | ||
| 70 | recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.", { | ||
| 71 | versionMismatchHint: "Version mismatch. Try syncing the client and server.", | ||
| 72 | }); | ||
| 73 | |||
| 74 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 75 | lastError: | ||
| 76 | "Unable to connect to the T3 server WebSocket. Hint: Version mismatch. Try syncing the client and server.", | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("adds a version mismatch hint to websocket close reasons when metadata includes one", () => { | ||
| 81 | recordWsConnectionAttempt("ws://localhost:3020/ws"); | ||
| 82 | recordWsConnectionOpened(); | ||
| 83 | recordWsConnectionClosed( | ||
| 84 | { code: 1006, reason: "socket closed" }, | ||
| 85 | { | ||
| 86 | versionMismatchHint: "Version mismatch. Try syncing the client and server.", | ||
| 87 | }, | ||
| 88 | ); | ||
| 89 | |||
| 90 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 91 | closeReason: "socket closed Hint: Version mismatch. Try syncing the client and server.", | ||
| 92 | }); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("marks the reconnect cycle as exhausted after the final attempt fails", () => { | ||
| 96 | for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) { | ||
| 97 | recordWsConnectionAttempt("ws://localhost:3020/ws"); | ||
| 98 | recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); | ||
| 99 | } | ||
| 100 | |||
| 101 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 102 | nextRetryAt: null, | ||
| 103 | reconnectAttemptCount: WS_RECONNECT_MAX_ATTEMPTS, | ||
| 104 | reconnectPhase: "exhausted", | ||
| 105 | }); | ||
| 106 | }); | ||
| 107 | }); | ||
apps/web/src/rpc/wsConnectionState.ts deleted-238| ... | @@ -1,238 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { DEFAULT_RECONNECT_BACKOFF, getReconnectDelayMs } from "@t3tools/client-runtime"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { appAtomRegistry } from "./atomRegistry"; | ||
| 6 | |||
| 7 | export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting"; | ||
| 8 | export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting"; | ||
| 9 | |||
| 10 | export const WS_RECONNECT_INITIAL_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.initialDelayMs; | ||
| 11 | export const WS_RECONNECT_BACKOFF_FACTOR = DEFAULT_RECONNECT_BACKOFF.backoffFactor; | ||
| 12 | export const WS_RECONNECT_MAX_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.maxDelayMs; | ||
| 13 | export const WS_RECONNECT_MAX_RETRIES = DEFAULT_RECONNECT_BACKOFF.maxRetries!; | ||
| 14 | export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1; | ||
| 15 | |||
| 16 | export interface WsConnectionStatus { | ||
| 17 | readonly attemptCount: number; | ||
| 18 | readonly closeCode: number | null; | ||
| 19 | readonly closeReason: string | null; | ||
| 20 | readonly connectionLabel: string | null; | ||
| 21 | readonly connectedAt: string | null; | ||
| 22 | readonly disconnectedAt: string | null; | ||
| 23 | readonly hasConnected: boolean; | ||
| 24 | readonly lastError: string | null; | ||
| 25 | readonly lastErrorAt: string | null; | ||
| 26 | readonly nextRetryAt: string | null; | ||
| 27 | readonly online: boolean; | ||
| 28 | readonly phase: "idle" | "connecting" | "connected" | "disconnected"; | ||
| 29 | readonly reconnectAttemptCount: number; | ||
| 30 | readonly reconnectMaxAttempts: number; | ||
| 31 | readonly reconnectPhase: WsReconnectPhase; | ||
| 32 | readonly socketUrl: string | null; | ||
| 33 | } | ||
| 34 | |||
| 35 | const INITIAL_WS_CONNECTION_STATUS = Object.freeze<WsConnectionStatus>({ | ||
| 36 | attemptCount: 0, | ||
| 37 | closeCode: null, | ||
| 38 | closeReason: null, | ||
| 39 | connectionLabel: null, | ||
| 40 | connectedAt: null, | ||
| 41 | disconnectedAt: null, | ||
| 42 | hasConnected: false, | ||
| 43 | lastError: null, | ||
| 44 | lastErrorAt: null, | ||
| 45 | nextRetryAt: null, | ||
| 46 | online: typeof navigator === "undefined" ? true : navigator.onLine !== false, | ||
| 47 | phase: "idle", | ||
| 48 | reconnectAttemptCount: 0, | ||
| 49 | reconnectMaxAttempts: WS_RECONNECT_MAX_ATTEMPTS, | ||
| 50 | reconnectPhase: "idle", | ||
| 51 | socketUrl: null, | ||
| 52 | }); | ||
| 53 | |||
| 54 | export const wsConnectionStatusAtom = Atom.make(INITIAL_WS_CONNECTION_STATUS).pipe( | ||
| 55 | Atom.keepAlive, | ||
| 56 | Atom.withLabel("ws-connection-status"), | ||
| 57 | ); | ||
| 58 | |||
| 59 | function isoNow() { | ||
| 60 | return new Date().toISOString(); | ||
| 61 | } | ||
| 62 | |||
| 63 | function updateWsConnectionStatus( | ||
| 64 | updater: (current: WsConnectionStatus) => WsConnectionStatus, | ||
| 65 | ): WsConnectionStatus { | ||
| 66 | const nextStatus = updater(getWsConnectionStatus()); | ||
| 67 | appAtomRegistry.set(wsConnectionStatusAtom, nextStatus); | ||
| 68 | return nextStatus; | ||
| 69 | } | ||
| 70 | |||
| 71 | export interface WsConnectionMetadata { | ||
| 72 | readonly connectionLabel?: string | null; | ||
| 73 | readonly versionMismatchHint?: string | null; | ||
| 74 | } | ||
| 75 | |||
| 76 | function normalizeConnectionLabel(label: string | null | undefined): string | null { | ||
| 77 | const normalized = label?.trim(); | ||
| 78 | return normalized ? normalized : null; | ||
| 79 | } | ||
| 80 | |||
| 81 | export function getWsConnectionStatus(): WsConnectionStatus { | ||
| 82 | return appAtomRegistry.get(wsConnectionStatusAtom); | ||
| 83 | } | ||
| 84 | |||
| 85 | export function getWsConnectionUiState(status: WsConnectionStatus): WsConnectionUiState { | ||
| 86 | if (status.phase === "connected") { | ||
| 87 | return "connected"; | ||
| 88 | } | ||
| 89 | |||
| 90 | if (!status.online && (status.disconnectedAt !== null || status.phase === "disconnected")) { | ||
| 91 | return "offline"; | ||
| 92 | } | ||
| 93 | |||
| 94 | if (!status.hasConnected) { | ||
| 95 | return status.phase === "disconnected" ? "error" : "connecting"; | ||
| 96 | } | ||
| 97 | |||
| 98 | return "reconnecting"; | ||
| 99 | } | ||
| 100 | |||
| 101 | export function recordWsConnectionAttempt( | ||
| 102 | socketUrl: string, | ||
| 103 | metadata?: WsConnectionMetadata, | ||
| 104 | ): WsConnectionStatus { | ||
| 105 | const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); | ||
| 106 | return updateWsConnectionStatus((current) => ({ | ||
| 107 | ...current, | ||
| 108 | attemptCount: current.attemptCount + 1, | ||
| 109 | connectionLabel: connectionLabel ?? current.connectionLabel, | ||
| 110 | nextRetryAt: null, | ||
| 111 | phase: "connecting", | ||
| 112 | reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1, | ||
| 113 | reconnectPhase: "attempting", | ||
| 114 | socketUrl, | ||
| 115 | })); | ||
| 116 | } | ||
| 117 | |||
| 118 | export function recordWsConnectionOpened(metadata?: WsConnectionMetadata): WsConnectionStatus { | ||
| 119 | const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); | ||
| 120 | return updateWsConnectionStatus((current) => ({ | ||
| 121 | ...current, | ||
| 122 | closeCode: null, | ||
| 123 | closeReason: null, | ||
| 124 | connectionLabel: connectionLabel ?? current.connectionLabel, | ||
| 125 | connectedAt: isoNow(), | ||
| 126 | disconnectedAt: null, | ||
| 127 | hasConnected: true, | ||
| 128 | nextRetryAt: null, | ||
| 129 | phase: "connected", | ||
| 130 | reconnectAttemptCount: 0, | ||
| 131 | reconnectPhase: "idle", | ||
| 132 | })); | ||
| 133 | } | ||
| 134 | |||
| 135 | function appendHint(message: string | null | undefined, hint: string | null | undefined) { | ||
| 136 | const normalizedMessage = message?.trim(); | ||
| 137 | const normalizedHint = hint?.trim(); | ||
| 138 | if (!normalizedMessage) { | ||
| 139 | return normalizedHint ? `Hint: ${normalizedHint}` : null; | ||
| 140 | } | ||
| 141 | return normalizedHint ? `${normalizedMessage} Hint: ${normalizedHint}` : normalizedMessage; | ||
| 142 | } | ||
| 143 | |||
| 144 | export function recordWsConnectionErrored( | ||
| 145 | message?: string | null, | ||
| 146 | metadata?: WsConnectionMetadata, | ||
| 147 | ): WsConnectionStatus { | ||
| 148 | return updateWsConnectionStatus((current) => | ||
| 149 | applyDisconnectState(current, { | ||
| 150 | lastError: | ||
| 151 | appendHint(message, metadata?.versionMismatchHint) ?? | ||
| 152 | appendHint(current.lastError, metadata?.versionMismatchHint), | ||
| 153 | lastErrorAt: isoNow(), | ||
| 154 | }), | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | |||
| 158 | export function recordWsConnectionClosed( | ||
| 159 | details?: { | ||
| 160 | readonly code?: number; | ||
| 161 | readonly reason?: string; | ||
| 162 | }, | ||
| 163 | metadata?: WsConnectionMetadata, | ||
| 164 | ): WsConnectionStatus { | ||
| 165 | const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); | ||
| 166 | return updateWsConnectionStatus((current) => | ||
| 167 | applyDisconnectState( | ||
| 168 | current, | ||
| 169 | { | ||
| 170 | closeCode: details?.code ?? current.closeCode, | ||
| 171 | closeReason: | ||
| 172 | appendHint(details?.reason, metadata?.versionMismatchHint) ?? | ||
| 173 | appendHint(current.closeReason, metadata?.versionMismatchHint), | ||
| 174 | }, | ||
| 175 | connectionLabel === null ? undefined : { connectionLabel }, | ||
| 176 | ), | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | |||
| 180 | export function setBrowserOnlineStatus(online: boolean): WsConnectionStatus { | ||
| 181 | return updateWsConnectionStatus((current) => ({ | ||
| 182 | ...current, | ||
| 183 | online, | ||
| 184 | })); | ||
| 185 | } | ||
| 186 | |||
| 187 | export function resetWsReconnectBackoff(): WsConnectionStatus { | ||
| 188 | return updateWsConnectionStatus((current) => ({ | ||
| 189 | ...current, | ||
| 190 | nextRetryAt: null, | ||
| 191 | reconnectAttemptCount: 0, | ||
| 192 | reconnectPhase: "idle", | ||
| 193 | })); | ||
| 194 | } | ||
| 195 | |||
| 196 | export function resetWsConnectionStateForTests(): void { | ||
| 197 | appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS); | ||
| 198 | } | ||
| 199 | |||
| 200 | export function useWsConnectionStatus(): WsConnectionStatus { | ||
| 201 | return useAtomValue(wsConnectionStatusAtom); | ||
| 202 | } | ||
| 203 | |||
| 204 | export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null { | ||
| 205 | return getReconnectDelayMs(retryIndex); | ||
| 206 | } | ||
| 207 | |||
| 208 | function applyDisconnectState( | ||
| 209 | current: WsConnectionStatus, | ||
| 210 | updates: Partial< | ||
| 211 | Pick<WsConnectionStatus, "closeCode" | "closeReason" | "lastError" | "lastErrorAt"> | ||
| 212 | >, | ||
| 213 | metadata?: WsConnectionMetadata, | ||
| 214 | ): WsConnectionStatus { | ||
| 215 | const disconnectedAt = current.disconnectedAt ?? isoNow(); | ||
| 216 | const nextRetryDelayMs = | ||
| 217 | current.nextRetryAt !== null || current.reconnectPhase === "exhausted" | ||
| 218 | ? null | ||
| 219 | : getWsReconnectDelayMsForRetry(Math.max(0, current.reconnectAttemptCount - 1)); | ||
| 220 | |||
| 221 | return { | ||
| 222 | ...current, | ||
| 223 | ...updates, | ||
| 224 | connectionLabel: normalizeConnectionLabel(metadata?.connectionLabel) ?? current.connectionLabel, | ||
| 225 | disconnectedAt, | ||
| 226 | nextRetryAt: | ||
| 227 | nextRetryDelayMs === null | ||
| 228 | ? current.nextRetryAt | ||
| 229 | : new Date(Date.now() + nextRetryDelayMs).toISOString(), | ||
| 230 | phase: "disconnected", | ||
| 231 | reconnectPhase: | ||
| 232 | current.reconnectPhase === "waiting" || current.reconnectPhase === "exhausted" | ||
| 233 | ? current.reconnectPhase | ||
| 234 | : nextRetryDelayMs === null | ||
| 235 | ? "exhausted" | ||
| 236 | : "waiting", | ||
| 237 | }; | ||
| 238 | } | ||
apps/web/src/rpc/wsTransport.test.ts deleted-411| ... | @@ -1,411 +0,0 @@ | ||
| 1 | import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | __resetClientTracingForTests, | ||
| 7 | configureClientTracing, | ||
| 8 | } from "../observability/clientTracing"; | ||
| 9 | import { | ||
| 10 | getSlowRpcAckRequests, | ||
| 11 | resetRequestLatencyStateForTests, | ||
| 12 | setSlowRpcAckThresholdMsForTests, | ||
| 13 | } from "../rpc/requestLatencyState"; | ||
| 14 | import { | ||
| 15 | getWsConnectionStatus, | ||
| 16 | getWsConnectionUiState, | ||
| 17 | resetWsConnectionStateForTests, | ||
| 18 | } from "../rpc/wsConnectionState"; | ||
| 19 | import { WsTransport } from "./wsTransport"; | ||
| 20 | |||
| 21 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | ||
| 22 | |||
| 23 | type WsEventType = "open" | "message" | "close" | "error"; | ||
| 24 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | ||
| 25 | type WsListener = (event?: WsEvent) => void; | ||
| 26 | |||
| 27 | const sockets: MockWebSocket[] = []; | ||
| 28 | |||
| 29 | class MockWebSocket { | ||
| 30 | static readonly CONNECTING = 0; | ||
| 31 | static readonly OPEN = 1; | ||
| 32 | static readonly CLOSING = 2; | ||
| 33 | static readonly CLOSED = 3; | ||
| 34 | |||
| 35 | readyState = MockWebSocket.CONNECTING; | ||
| 36 | readonly sent: string[] = []; | ||
| 37 | readonly url: string; | ||
| 38 | private readonly listeners = new Map<WsEventType, Set<WsListener>>(); | ||
| 39 | |||
| 40 | constructor(url: string) { | ||
| 41 | this.url = url; | ||
| 42 | sockets.push(this); | ||
| 43 | } | ||
| 44 | |||
| 45 | addEventListener(type: WsEventType, listener: WsListener) { | ||
| 46 | const listeners = this.listeners.get(type) ?? new Set<WsListener>(); | ||
| 47 | listeners.add(listener); | ||
| 48 | this.listeners.set(type, listeners); | ||
| 49 | } | ||
| 50 | |||
| 51 | removeEventListener(type: WsEventType, listener: WsListener) { | ||
| 52 | this.listeners.get(type)?.delete(listener); | ||
| 53 | } | ||
| 54 | |||
| 55 | send(data: string) { | ||
| 56 | this.sent.push(data); | ||
| 57 | } | ||
| 58 | |||
| 59 | close(code = 1000, reason = "") { | ||
| 60 | this.readyState = MockWebSocket.CLOSED; | ||
| 61 | this.emit("close", { code, reason, type: "close" }); | ||
| 62 | } | ||
| 63 | |||
| 64 | open() { | ||
| 65 | this.readyState = MockWebSocket.OPEN; | ||
| 66 | this.emit("open", { type: "open" }); | ||
| 67 | } | ||
| 68 | |||
| 69 | serverMessage(data: unknown) { | ||
| 70 | this.emit("message", { data, type: "message" }); | ||
| 71 | } | ||
| 72 | |||
| 73 | error() { | ||
| 74 | this.emit("error", { type: "error" }); | ||
| 75 | } | ||
| 76 | |||
| 77 | private emit(type: WsEventType, event?: WsEvent) { | ||
| 78 | const listeners = this.listeners.get(type); | ||
| 79 | if (!listeners) return; | ||
| 80 | for (const listener of listeners) { | ||
| 81 | listener(event); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | const originalWebSocket = globalThis.WebSocket; | ||
| 87 | const originalFetch = globalThis.fetch; | ||
| 88 | const transports: WsTransport[] = []; | ||
| 89 | |||
| 90 | function getSocket(): MockWebSocket { | ||
| 91 | const socket = sockets.at(-1); | ||
| 92 | if (!socket) { | ||
| 93 | throw new Error("Expected a websocket instance"); | ||
| 94 | } | ||
| 95 | return socket; | ||
| 96 | } | ||
| 97 | |||
| 98 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | ||
| 99 | const startedAt = Date.now(); | ||
| 100 | for (;;) { | ||
| 101 | try { | ||
| 102 | assertion(); | ||
| 103 | return; | ||
| 104 | } catch (error) { | ||
| 105 | if (Date.now() - startedAt >= timeoutMs) { | ||
| 106 | throw error; | ||
| 107 | } | ||
| 108 | await new Promise((resolve) => setTimeout(resolve, 10)); | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | function createTransport(...args: ConstructorParameters<typeof WsTransport>): WsTransport { | ||
| 114 | const transport = new WsTransport(...args); | ||
| 115 | transports.push(transport); | ||
| 116 | return transport; | ||
| 117 | } | ||
| 118 | |||
| 119 | beforeEach(() => { | ||
| 120 | vi.useRealTimers(); | ||
| 121 | sockets.length = 0; | ||
| 122 | transports.length = 0; | ||
| 123 | resetRequestLatencyStateForTests(); | ||
| 124 | resetWsConnectionStateForTests(); | ||
| 125 | |||
| 126 | Object.defineProperty(globalThis, "window", { | ||
| 127 | configurable: true, | ||
| 128 | value: { | ||
| 129 | location: { | ||
| 130 | origin: "http://localhost:3020", | ||
| 131 | hostname: "localhost", | ||
| 132 | port: "3020", | ||
| 133 | protocol: "http:", | ||
| 134 | }, | ||
| 135 | desktopBridge: undefined, | ||
| 136 | }, | ||
| 137 | }); | ||
| 138 | Object.defineProperty(globalThis, "navigator", { | ||
| 139 | configurable: true, | ||
| 140 | value: { onLine: true }, | ||
| 141 | }); | ||
| 142 | |||
| 143 | globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; | ||
| 144 | }); | ||
| 145 | |||
| 146 | afterEach(async () => { | ||
| 147 | await Promise.allSettled(transports.map((transport) => transport.dispose())); | ||
| 148 | transports.length = 0; | ||
| 149 | globalThis.WebSocket = originalWebSocket; | ||
| 150 | globalThis.fetch = originalFetch; | ||
| 151 | resetRequestLatencyStateForTests(); | ||
| 152 | resetWsConnectionStateForTests(); | ||
| 153 | await __resetClientTracingForTests(); | ||
| 154 | vi.restoreAllMocks(); | ||
| 155 | }); | ||
| 156 | |||
| 157 | describe("WsTransport (web instrumentation)", () => { | ||
| 158 | it("tracks initial connection failures for the app error state", async () => { | ||
| 159 | const transport = createTransport("ws://localhost:3020"); | ||
| 160 | |||
| 161 | await waitFor(() => { | ||
| 162 | expect(sockets).toHaveLength(1); | ||
| 163 | }); | ||
| 164 | |||
| 165 | const socket = getSocket(); | ||
| 166 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 167 | attemptCount: 1, | ||
| 168 | phase: "connecting", | ||
| 169 | socketUrl: "ws://localhost:3020/ws", | ||
| 170 | }); | ||
| 171 | |||
| 172 | socket.error(); | ||
| 173 | socket.close(1006, "server unavailable"); | ||
| 174 | |||
| 175 | await waitFor(() => { | ||
| 176 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 177 | closeCode: 1006, | ||
| 178 | closeReason: "server unavailable", | ||
| 179 | hasConnected: false, | ||
| 180 | lastError: "Unable to connect to the T3 server WebSocket.", | ||
| 181 | phase: "disconnected", | ||
| 182 | }); | ||
| 183 | }); | ||
| 184 | expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("error"); | ||
| 185 | |||
| 186 | await transport.dispose(); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("surfaces reconnecting state after a live socket disconnects", async () => { | ||
| 190 | const transport = createTransport("ws://localhost:3020"); | ||
| 191 | |||
| 192 | await waitFor(() => { | ||
| 193 | expect(sockets).toHaveLength(1); | ||
| 194 | }); | ||
| 195 | |||
| 196 | const socket = getSocket(); | ||
| 197 | socket.open(); | ||
| 198 | |||
| 199 | await waitFor(() => { | ||
| 200 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 201 | hasConnected: true, | ||
| 202 | phase: "connected", | ||
| 203 | }); | ||
| 204 | }); | ||
| 205 | |||
| 206 | socket.close(1013, "try again later"); | ||
| 207 | |||
| 208 | await waitFor(() => { | ||
| 209 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 210 | closeReason: "try again later", | ||
| 211 | hasConnected: true, | ||
| 212 | }); | ||
| 213 | }); | ||
| 214 | expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("reconnecting"); | ||
| 215 | |||
| 216 | await transport.dispose(); | ||
| 217 | }); | ||
| 218 | |||
| 219 | it("composes custom lifecycle handlers with default websocket state tracking", async () => { | ||
| 220 | const onOpen = vi.fn(); | ||
| 221 | const onClose = vi.fn(); | ||
| 222 | const transport = createTransport("ws://localhost:3020", { | ||
| 223 | onOpen, | ||
| 224 | onClose, | ||
| 225 | }); | ||
| 226 | |||
| 227 | await waitFor(() => { | ||
| 228 | expect(sockets).toHaveLength(1); | ||
| 229 | }); | ||
| 230 | |||
| 231 | const socket = getSocket(); | ||
| 232 | socket.open(); | ||
| 233 | |||
| 234 | await waitFor(() => { | ||
| 235 | expect(onOpen).toHaveBeenCalledOnce(); | ||
| 236 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 237 | hasConnected: true, | ||
| 238 | phase: "connected", | ||
| 239 | }); | ||
| 240 | }); | ||
| 241 | |||
| 242 | socket.close(1012, "service restart"); | ||
| 243 | |||
| 244 | await waitFor(() => { | ||
| 245 | expect(onClose).toHaveBeenCalledWith( | ||
| 246 | { | ||
| 247 | code: 1012, | ||
| 248 | reason: "service restart", | ||
| 249 | }, | ||
| 250 | { | ||
| 251 | intentional: false, | ||
| 252 | }, | ||
| 253 | ); | ||
| 254 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 255 | attemptCount: 2, | ||
| 256 | closeReason: "service restart", | ||
| 257 | phase: "connecting", | ||
| 258 | }); | ||
| 259 | }, 2_000); | ||
| 260 | |||
| 261 | await transport.dispose(); | ||
| 262 | }); | ||
| 263 | |||
| 264 | it("marks unary requests as slow until the first server ack arrives", async () => { | ||
| 265 | const slowAckThresholdMs = 25; | ||
| 266 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); | ||
| 267 | const transport = createTransport("ws://localhost:3020"); | ||
| 268 | |||
| 269 | const requestPromise = transport.request((client) => | ||
| 270 | client[WS_METHODS.serverUpsertKeybinding]({ | ||
| 271 | command: "terminal.toggle", | ||
| 272 | key: "ctrl+k", | ||
| 273 | }), | ||
| 274 | ); | ||
| 275 | |||
| 276 | await waitFor(() => { | ||
| 277 | expect(sockets).toHaveLength(1); | ||
| 278 | }); | ||
| 279 | |||
| 280 | const socket = getSocket(); | ||
| 281 | socket.open(); | ||
| 282 | |||
| 283 | await waitFor(() => { | ||
| 284 | expect(socket.sent).toHaveLength(1); | ||
| 285 | }); | ||
| 286 | |||
| 287 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 288 | await waitFor(() => { | ||
| 289 | expect(getSlowRpcAckRequests()).toMatchObject([ | ||
| 290 | { | ||
| 291 | requestId: requestMessage.id, | ||
| 292 | tag: WS_METHODS.serverUpsertKeybinding, | ||
| 293 | }, | ||
| 294 | ]); | ||
| 295 | }, 1_000); | ||
| 296 | |||
| 297 | socket.serverMessage( | ||
| 298 | JSON.stringify({ | ||
| 299 | _tag: "Exit", | ||
| 300 | requestId: requestMessage.id, | ||
| 301 | exit: { | ||
| 302 | _tag: "Success", | ||
| 303 | value: { | ||
| 304 | keybindings: [], | ||
| 305 | issues: [], | ||
| 306 | }, | ||
| 307 | }, | ||
| 308 | }), | ||
| 309 | ); | ||
| 310 | |||
| 311 | await expect(requestPromise).resolves.toEqual({ | ||
| 312 | keybindings: [], | ||
| 313 | issues: [], | ||
| 314 | }); | ||
| 315 | expect(getSlowRpcAckRequests()).toEqual([]); | ||
| 316 | |||
| 317 | await transport.dispose(); | ||
| 318 | }, 5_000); | ||
| 319 | |||
| 320 | it("clears slow unary request tracking when the transport reconnects", async () => { | ||
| 321 | const slowAckThresholdMs = 25; | ||
| 322 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); | ||
| 323 | const transport = createTransport("ws://localhost:3020"); | ||
| 324 | |||
| 325 | const requestPromise = transport.request((client) => | ||
| 326 | client[WS_METHODS.serverUpsertKeybinding]({ | ||
| 327 | command: "terminal.toggle", | ||
| 328 | key: "ctrl+k", | ||
| 329 | }), | ||
| 330 | ); | ||
| 331 | |||
| 332 | await waitFor(() => { | ||
| 333 | expect(sockets).toHaveLength(1); | ||
| 334 | }); | ||
| 335 | |||
| 336 | const firstSocket = getSocket(); | ||
| 337 | firstSocket.open(); | ||
| 338 | |||
| 339 | await waitFor(() => { | ||
| 340 | expect(firstSocket.sent).toHaveLength(1); | ||
| 341 | }); | ||
| 342 | |||
| 343 | const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; | ||
| 344 | |||
| 345 | await waitFor(() => { | ||
| 346 | expect(getSlowRpcAckRequests()).toMatchObject([ | ||
| 347 | { | ||
| 348 | requestId: firstRequest.id, | ||
| 349 | tag: WS_METHODS.serverUpsertKeybinding, | ||
| 350 | }, | ||
| 351 | ]); | ||
| 352 | }, 1_000); | ||
| 353 | |||
| 354 | void requestPromise.catch(() => undefined); | ||
| 355 | |||
| 356 | await transport.reconnect(); | ||
| 357 | |||
| 358 | expect(getSlowRpcAckRequests()).toEqual([]); | ||
| 359 | |||
| 360 | await waitFor(() => { | ||
| 361 | expect(sockets).toHaveLength(2); | ||
| 362 | }); | ||
| 363 | |||
| 364 | const secondSocket = getSocket(); | ||
| 365 | secondSocket.open(); | ||
| 366 | |||
| 367 | await transport.dispose(); | ||
| 368 | }, 5_000); | ||
| 369 | |||
| 370 | it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { | ||
| 371 | await configureClientTracing({ | ||
| 372 | exportIntervalMs: 10, | ||
| 373 | }); | ||
| 374 | |||
| 375 | const transport = createTransport("ws://localhost:3020"); | ||
| 376 | const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); | ||
| 377 | |||
| 378 | await waitFor(() => { | ||
| 379 | expect(sockets).toHaveLength(1); | ||
| 380 | }); | ||
| 381 | |||
| 382 | const socket = getSocket(); | ||
| 383 | socket.open(); | ||
| 384 | |||
| 385 | await waitFor(() => { | ||
| 386 | expect(socket.sent).toHaveLength(1); | ||
| 387 | }); | ||
| 388 | |||
| 389 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | ||
| 390 | id: string; | ||
| 391 | spanId?: string; | ||
| 392 | traceId?: string; | ||
| 393 | }; | ||
| 394 | expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); | ||
| 395 | expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); | ||
| 396 | |||
| 397 | socket.serverMessage( | ||
| 398 | JSON.stringify({ | ||
| 399 | _tag: "Exit", | ||
| 400 | requestId: requestMessage.id, | ||
| 401 | exit: { | ||
| 402 | _tag: "Success", | ||
| 403 | value: encodeServerSettings(DEFAULT_SERVER_SETTINGS), | ||
| 404 | }, | ||
| 405 | }), | ||
| 406 | ); | ||
| 407 | |||
| 408 | await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); | ||
| 409 | await transport.dispose(); | ||
| 410 | }); | ||
| 411 | }); | ||
apps/web/src/rpc/wsTransport.ts deleted-63| ... | @@ -1,63 +0,0 @@ | ||
| 1 | import { | ||
| 2 | WsTransport as BaseWsTransport, | ||
| 3 | type WsProtocolLifecycleHandlers, | ||
| 4 | type WsRpcProtocolSocketUrlProvider, | ||
| 5 | type WsTransportOptions, | ||
| 6 | } from "@t3tools/client-runtime"; | ||
| 7 | import { createWsRpcProtocolLayer as createSharedWsRpcProtocolLayer } from "@t3tools/client-runtime"; | ||
| 8 | |||
| 9 | import { ClientTracingLive } from "../observability/clientTracing"; | ||
| 10 | import { | ||
| 11 | acknowledgeRpcRequest, | ||
| 12 | clearAllTrackedRpcRequests, | ||
| 13 | trackRpcRequestSent, | ||
| 14 | } from "./requestLatencyState"; | ||
| 15 | import { | ||
| 16 | recordWsConnectionAttempt, | ||
| 17 | recordWsConnectionClosed, | ||
| 18 | recordWsConnectionErrored, | ||
| 19 | recordWsConnectionOpened, | ||
| 20 | } from "./wsConnectionState"; | ||
| 21 | |||
| 22 | function createWsRpcProtocolLayer( | ||
| 23 | url: WsRpcProtocolSocketUrlProvider, | ||
| 24 | handlers?: WsProtocolLifecycleHandlers, | ||
| 25 | ) { | ||
| 26 | return createSharedWsRpcProtocolLayer(url, handlers, { | ||
| 27 | telemetryLifecycle: { | ||
| 28 | onAttempt: recordWsConnectionAttempt, | ||
| 29 | onOpen: recordWsConnectionOpened, | ||
| 30 | onError: (message) => { | ||
| 31 | clearAllTrackedRpcRequests(); | ||
| 32 | recordWsConnectionErrored(message); | ||
| 33 | }, | ||
| 34 | onClose: (details, context) => { | ||
| 35 | clearAllTrackedRpcRequests(); | ||
| 36 | if (context.intentional) { | ||
| 37 | return; | ||
| 38 | } | ||
| 39 | recordWsConnectionClosed(details); | ||
| 40 | }, | ||
| 41 | }, | ||
| 42 | requestTelemetry: { | ||
| 43 | onRequestSent: trackRpcRequestSent, | ||
| 44 | onRequestAcknowledged: acknowledgeRpcRequest, | ||
| 45 | onClearTrackedRequests: clearAllTrackedRpcRequests, | ||
| 46 | }, | ||
| 47 | }); | ||
| 48 | } | ||
| 49 | |||
| 50 | const webWsTransportOptions = { | ||
| 51 | tracingLayer: ClientTracingLive, | ||
| 52 | createProtocolLayer: createWsRpcProtocolLayer, | ||
| 53 | onBeforeReconnect: () => clearAllTrackedRpcRequests(), | ||
| 54 | } satisfies WsTransportOptions; | ||
| 55 | |||
| 56 | export class WsTransport extends BaseWsTransport { | ||
| 57 | constructor( | ||
| 58 | url: WsRpcProtocolSocketUrlProvider, | ||
| 59 | lifecycleHandlers?: WsProtocolLifecycleHandlers, | ||
| 60 | ) { | ||
| 61 | super(url, lifecycleHandlers, webWsTransportOptions); | ||
| 62 | } | ||
| 63 | } | ||
apps/web/src/session-logic.test.ts+10-8| ... | @@ -1501,6 +1501,8 @@ describe("deriveTimelineEntries", () => { | ... | @@ -1501,6 +1501,8 @@ describe("deriveTimelineEntries", () => { |
| 1501 | role: "assistant", | 1501 | role: "assistant", |
| 1502 | text: "hello", | 1502 | text: "hello", |
| 1503 | createdAt: "2026-02-23T00:00:01.000Z", | 1503 | createdAt: "2026-02-23T00:00:01.000Z", |
| 1504 | turnId: null, | ||
| 1505 | updatedAt: "2026-02-23T00:00:01.000Z", | ||
| 1504 | streaming: false, | 1506 | streaming: false, |
| 1505 | }, | 1507 | }, |
| 1506 | ], | 1508 | ], |
| ... | @@ -1586,7 +1588,7 @@ describe("isLatestTurnSettled", () => { | ... | @@ -1586,7 +1588,7 @@ describe("isLatestTurnSettled", () => { |
| 1586 | it("returns false while the same turn is still active in a running session", () => { | 1588 | it("returns false while the same turn is still active in a running session", () => { |
| 1587 | expect( | 1589 | expect( |
| 1588 | isLatestTurnSettled(latestTurn, { | 1590 | isLatestTurnSettled(latestTurn, { |
| 1589 | orchestrationStatus: "running", | 1591 | status: "running", |
| 1590 | activeTurnId: TurnId.make("turn-1"), | 1592 | activeTurnId: TurnId.make("turn-1"), |
| 1591 | }), | 1593 | }), |
| 1592 | ).toBe(false); | 1594 | ).toBe(false); |
| ... | @@ -1595,7 +1597,7 @@ describe("isLatestTurnSettled", () => { | ... | @@ -1595,7 +1597,7 @@ describe("isLatestTurnSettled", () => { |
| 1595 | it("returns false while any turn is running to avoid stale latest-turn banners", () => { | 1597 | it("returns false while any turn is running to avoid stale latest-turn banners", () => { |
| 1596 | expect( | 1598 | expect( |
| 1597 | isLatestTurnSettled(latestTurn, { | 1599 | isLatestTurnSettled(latestTurn, { |
| 1598 | orchestrationStatus: "running", | 1600 | status: "running", |
| 1599 | activeTurnId: TurnId.make("turn-2"), | 1601 | activeTurnId: TurnId.make("turn-2"), |
| 1600 | }), | 1602 | }), |
| 1601 | ).toBe(false); | 1603 | ).toBe(false); |
| ... | @@ -1604,8 +1606,8 @@ describe("isLatestTurnSettled", () => { | ... | @@ -1604,8 +1606,8 @@ describe("isLatestTurnSettled", () => { |
| 1604 | it("returns true once the session is no longer running that turn", () => { | 1606 | it("returns true once the session is no longer running that turn", () => { |
| 1605 | expect( | 1607 | expect( |
| 1606 | isLatestTurnSettled(latestTurn, { | 1608 | isLatestTurnSettled(latestTurn, { |
| 1607 | orchestrationStatus: "ready", | 1609 | status: "ready", |
| 1608 | activeTurnId: undefined, | 1610 | activeTurnId: null, |
| 1609 | }), | 1611 | }), |
| 1610 | ).toBe(true); | 1612 | ).toBe(true); |
| 1611 | }); | 1613 | }); |
| ... | @@ -1636,7 +1638,7 @@ describe("deriveActiveWorkStartedAt", () => { | ... | @@ -1636,7 +1638,7 @@ describe("deriveActiveWorkStartedAt", () => { |
| 1636 | deriveActiveWorkStartedAt( | 1638 | deriveActiveWorkStartedAt( |
| 1637 | latestTurn, | 1639 | latestTurn, |
| 1638 | { | 1640 | { |
| 1639 | orchestrationStatus: "running", | 1641 | status: "running", |
| 1640 | activeTurnId: TurnId.make("turn-1"), | 1642 | activeTurnId: TurnId.make("turn-1"), |
| 1641 | }, | 1643 | }, |
| 1642 | "2026-02-27T21:11:00.000Z", | 1644 | "2026-02-27T21:11:00.000Z", |
| ... | @@ -1649,7 +1651,7 @@ describe("deriveActiveWorkStartedAt", () => { | ... | @@ -1649,7 +1651,7 @@ describe("deriveActiveWorkStartedAt", () => { |
| 1649 | deriveActiveWorkStartedAt( | 1651 | deriveActiveWorkStartedAt( |
| 1650 | latestTurn, | 1652 | latestTurn, |
| 1651 | { | 1653 | { |
| 1652 | orchestrationStatus: "running", | 1654 | status: "running", |
| 1653 | activeTurnId: TurnId.make("turn-2"), | 1655 | activeTurnId: TurnId.make("turn-2"), |
| 1654 | }, | 1656 | }, |
| 1655 | "2026-02-27T21:11:00.000Z", | 1657 | "2026-02-27T21:11:00.000Z", |
| ... | @@ -1662,8 +1664,8 @@ describe("deriveActiveWorkStartedAt", () => { | ... | @@ -1662,8 +1664,8 @@ describe("deriveActiveWorkStartedAt", () => { |
| 1662 | deriveActiveWorkStartedAt( | 1664 | deriveActiveWorkStartedAt( |
| 1663 | latestTurn, | 1665 | latestTurn, |
| 1664 | { | 1666 | { |
| 1665 | orchestrationStatus: "ready", | 1667 | status: "ready", |
| 1666 | activeTurnId: undefined, | 1668 | activeTurnId: null, |
| 1667 | }, | 1669 | }, |
| 1668 | "2026-02-27T21:11:00.000Z", | 1670 | "2026-02-27T21:11:00.000Z", |
| 1669 | ), | 1671 | ), |
apps/web/src/session-logic.ts+16-10| ... | @@ -289,7 +289,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str | ... | @@ -289,7 +289,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str |
| 289 | } | 289 | } |
| 290 | 290 | ||
| 291 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; | 291 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; |
| 292 | type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">; | 292 | type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">; |
| 293 | 293 | ||
| 294 | export function isLatestTurnSettled( | 294 | export function isLatestTurnSettled( |
| 295 | latestTurn: LatestTurnTiming | null, | 295 | latestTurn: LatestTurnTiming | null, |
| ... | @@ -298,7 +298,7 @@ export function isLatestTurnSettled( | ... | @@ -298,7 +298,7 @@ export function isLatestTurnSettled( |
| 298 | if (!latestTurn?.startedAt) return false; | 298 | if (!latestTurn?.startedAt) return false; |
| 299 | if (!latestTurn.completedAt) return false; | 299 | if (!latestTurn.completedAt) return false; |
| 300 | if (!session) return true; | 300 | if (!session) return true; |
| 301 | if (session.orchestrationStatus === "running") return false; | 301 | if (session.status === "running") return false; |
| 302 | return true; | 302 | return true; |
| 303 | } | 303 | } |
| 304 | 304 | ||
| ... | @@ -307,8 +307,7 @@ export function deriveActiveWorkStartedAt( | ... | @@ -307,8 +307,7 @@ export function deriveActiveWorkStartedAt( |
| 307 | session: SessionActivityState | null, | 307 | session: SessionActivityState | null, |
| 308 | sendStartedAt: string | null, | 308 | sendStartedAt: string | null, |
| 309 | ): string | null { | 309 | ): string | null { |
| 310 | const runningTurnId = | 310 | const runningTurnId = session?.status === "running" ? session.activeTurnId : null; |
| 311 | session?.orchestrationStatus === "running" ? (session.activeTurnId ?? null) : null; | ||
| 312 | if (runningTurnId !== null) { | 311 | if (runningTurnId !== null) { |
| 313 | if (latestTurn?.turnId === runningTurnId) { | 312 | if (latestTurn?.turnId === runningTurnId) { |
| 314 | return latestTurn.startedAt ?? sendStartedAt; | 313 | return latestTurn.startedAt ?? sendStartedAt; |
| ... | @@ -1339,9 +1338,9 @@ function compareActivityLifecycleRank(kind: string): number { | ... | @@ -1339,9 +1338,9 @@ function compareActivityLifecycleRank(kind: string): number { |
| 1339 | } | 1338 | } |
| 1340 | 1339 | ||
| 1341 | export function deriveTimelineEntries( | 1340 | export function deriveTimelineEntries( |
| 1342 | messages: ChatMessage[], | 1341 | messages: ReadonlyArray<ChatMessage>, |
| 1343 | proposedPlans: ProposedPlan[], | 1342 | proposedPlans: ReadonlyArray<ProposedPlan>, |
| 1344 | workEntries: WorkLogEntry[], | 1343 | workEntries: ReadonlyArray<WorkLogEntry>, |
| 1345 | ): TimelineEntry[] { | 1344 | ): TimelineEntry[] { |
| 1346 | const messageRows: TimelineEntry[] = messages.map((message) => ({ | 1345 | const messageRows: TimelineEntry[] = messages.map((message) => ({ |
| 1347 | id: message.id, | 1346 | id: message.id, |
| ... | @@ -1367,7 +1366,7 @@ export function deriveTimelineEntries( | ... | @@ -1367,7 +1366,7 @@ export function deriveTimelineEntries( |
| 1367 | } | 1366 | } |
| 1368 | 1367 | ||
| 1369 | export function inferCheckpointTurnCountByTurnId( | 1368 | export function inferCheckpointTurnCountByTurnId( |
| 1370 | summaries: TurnDiffSummary[], | 1369 | summaries: ReadonlyArray<TurnDiffSummary>, |
| 1371 | ): Record<TurnId, number> { | 1370 | ): Record<TurnId, number> { |
| 1372 | const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); | 1371 | const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); |
| 1373 | const result: Record<TurnId, number> = {}; | 1372 | const result: Record<TurnId, number> = {}; |
| ... | @@ -1380,8 +1379,15 @@ export function inferCheckpointTurnCountByTurnId( | ... | @@ -1380,8 +1379,15 @@ export function inferCheckpointTurnCountByTurnId( |
| 1380 | } | 1379 | } |
| 1381 | 1380 | ||
| 1382 | export function derivePhase(session: ThreadSession | null): SessionPhase { | 1381 | export function derivePhase(session: ThreadSession | null): SessionPhase { |
| 1383 | if (!session || session.status === "closed") return "disconnected"; | 1382 | if ( |
| 1384 | if (session.status === "connecting") return "connecting"; | 1383 | !session || |
| 1384 | session.status === "stopped" || | ||
| 1385 | session.status === "interrupted" || | ||
| 1386 | session.status === "error" | ||
| 1387 | ) { | ||
| 1388 | return "disconnected"; | ||
| 1389 | } | ||
| 1390 | if (session.status === "starting") return "connecting"; | ||
| 1385 | if (session.status === "running") return "running"; | 1391 | if (session.status === "running") return "running"; |
| 1386 | return "ready"; | 1392 | return "ready"; |
| 1387 | } | 1393 | } |
apps/web/src/shortcutModifierState.test.ts+29-62| ... | @@ -2,12 +2,17 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -2,12 +2,17 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | areShortcutModifierStatesEqual, | 4 | areShortcutModifierStatesEqual, |
| 5 | clearShortcutModifierState, | 5 | shortcutModifierStateAfterKeyboardEvent, |
| 6 | readShortcutModifierState, | 6 | type ShortcutModifierState, |
| 7 | setShortcutModifierState, | ||
| 8 | syncShortcutModifierStateFromKeyboardEvent, | ||
| 9 | } from "./shortcutModifierState"; | 7 | } from "./shortcutModifierState"; |
| 10 | 8 | ||
| 9 | const emptyState = (): ShortcutModifierState => ({ | ||
| 10 | metaKey: false, | ||
| 11 | ctrlKey: false, | ||
| 12 | altKey: false, | ||
| 13 | shiftKey: false, | ||
| 14 | }); | ||
| 15 | |||
| 11 | function keyboardEventLike(type: "keydown" | "keyup", init: Partial<KeyboardEvent>): KeyboardEvent { | 16 | function keyboardEventLike(type: "keydown" | "keyup", init: Partial<KeyboardEvent>): KeyboardEvent { |
| 12 | return { | 17 | return { |
| 13 | type, | 18 | type, |
| ... | @@ -36,107 +41,69 @@ describe("shortcutModifierState", () => { | ... | @@ -36,107 +41,69 @@ describe("shortcutModifierState", () => { |
| 36 | ).toBe(false); | 41 | ).toBe(false); |
| 37 | }); | 42 | }); |
| 38 | 43 | ||
| 39 | it("preserves the current store object when modifier values do not change", () => { | 44 | it("preserves the current object when modifier values do not change", () => { |
| 40 | clearShortcutModifierState(); | 45 | const initialState = emptyState(); |
| 41 | 46 | const nextState = shortcutModifierStateAfterKeyboardEvent( | |
| 42 | const initialState = readShortcutModifierState(); | 47 | initialState, |
| 43 | setShortcutModifierState({ | 48 | keyboardEventLike("keyup", { key: "Shift" }), |
| 44 | metaKey: false, | 49 | ); |
| 45 | ctrlKey: false, | 50 | expect(nextState).toBe(initialState); |
| 46 | altKey: false, | ||
| 47 | shiftKey: false, | ||
| 48 | }); | ||
| 49 | |||
| 50 | expect(readShortcutModifierState()).toBe(initialState); | ||
| 51 | |||
| 52 | setShortcutModifierState({ | ||
| 53 | metaKey: false, | ||
| 54 | ctrlKey: true, | ||
| 55 | altKey: false, | ||
| 56 | shiftKey: false, | ||
| 57 | }); | ||
| 58 | const updatedState = readShortcutModifierState(); | ||
| 59 | expect(updatedState).not.toBe(initialState); | ||
| 60 | expect(updatedState).toEqual({ | ||
| 61 | metaKey: false, | ||
| 62 | ctrlKey: true, | ||
| 63 | altKey: false, | ||
| 64 | shiftKey: false, | ||
| 65 | }); | ||
| 66 | |||
| 67 | setShortcutModifierState({ | ||
| 68 | metaKey: false, | ||
| 69 | ctrlKey: true, | ||
| 70 | altKey: false, | ||
| 71 | shiftKey: false, | ||
| 72 | }); | ||
| 73 | expect(readShortcutModifierState()).toBe(updatedState); | ||
| 74 | |||
| 75 | clearShortcutModifierState(); | ||
| 76 | const clearedState = readShortcutModifierState(); | ||
| 77 | expect(clearedState).toEqual({ | ||
| 78 | metaKey: false, | ||
| 79 | ctrlKey: false, | ||
| 80 | altKey: false, | ||
| 81 | shiftKey: false, | ||
| 82 | }); | ||
| 83 | expect(clearedState).not.toBe(updatedState); | ||
| 84 | |||
| 85 | clearShortcutModifierState(); | ||
| 86 | expect(readShortcutModifierState()).toBe(clearedState); | ||
| 87 | }); | 51 | }); |
| 88 | 52 | ||
| 89 | it("tracks bare modifier keydown and keyup events explicitly", () => { | 53 | it("tracks bare modifier keydown and keyup events explicitly", () => { |
| 90 | clearShortcutModifierState(); | 54 | let state = emptyState(); |
| 91 | 55 | state = shortcutModifierStateAfterKeyboardEvent( | |
| 92 | syncShortcutModifierStateFromKeyboardEvent( | 56 | state, |
| 93 | keyboardEventLike("keydown", { | 57 | keyboardEventLike("keydown", { |
| 94 | key: "Meta", | 58 | key: "Meta", |
| 95 | metaKey: false, | 59 | metaKey: false, |
| 96 | }), | 60 | }), |
| 97 | ); | 61 | ); |
| 98 | expect(readShortcutModifierState()).toEqual({ | 62 | expect(state).toEqual({ |
| 99 | metaKey: true, | 63 | metaKey: true, |
| 100 | ctrlKey: false, | 64 | ctrlKey: false, |
| 101 | altKey: false, | 65 | altKey: false, |
| 102 | shiftKey: false, | 66 | shiftKey: false, |
| 103 | }); | 67 | }); |
| 104 | 68 | ||
| 105 | syncShortcutModifierStateFromKeyboardEvent( | 69 | state = shortcutModifierStateAfterKeyboardEvent( |
| 70 | state, | ||
| 106 | keyboardEventLike("keydown", { | 71 | keyboardEventLike("keydown", { |
| 107 | key: "Shift", | 72 | key: "Shift", |
| 108 | metaKey: true, | 73 | metaKey: true, |
| 109 | shiftKey: false, | 74 | shiftKey: false, |
| 110 | }), | 75 | }), |
| 111 | ); | 76 | ); |
| 112 | expect(readShortcutModifierState()).toEqual({ | 77 | expect(state).toEqual({ |
| 113 | metaKey: true, | 78 | metaKey: true, |
| 114 | ctrlKey: false, | 79 | ctrlKey: false, |
| 115 | altKey: false, | 80 | altKey: false, |
| 116 | shiftKey: true, | 81 | shiftKey: true, |
| 117 | }); | 82 | }); |
| 118 | 83 | ||
| 119 | syncShortcutModifierStateFromKeyboardEvent( | 84 | state = shortcutModifierStateAfterKeyboardEvent( |
| 85 | state, | ||
| 120 | keyboardEventLike("keyup", { | 86 | keyboardEventLike("keyup", { |
| 121 | key: "Meta", | 87 | key: "Meta", |
| 122 | metaKey: true, | 88 | metaKey: true, |
| 123 | shiftKey: true, | 89 | shiftKey: true, |
| 124 | }), | 90 | }), |
| 125 | ); | 91 | ); |
| 126 | expect(readShortcutModifierState()).toEqual({ | 92 | expect(state).toEqual({ |
| 127 | metaKey: false, | 93 | metaKey: false, |
| 128 | ctrlKey: false, | 94 | ctrlKey: false, |
| 129 | altKey: false, | 95 | altKey: false, |
| 130 | shiftKey: true, | 96 | shiftKey: true, |
| 131 | }); | 97 | }); |
| 132 | 98 | ||
| 133 | syncShortcutModifierStateFromKeyboardEvent( | 99 | state = shortcutModifierStateAfterKeyboardEvent( |
| 100 | state, | ||
| 134 | keyboardEventLike("keyup", { | 101 | keyboardEventLike("keyup", { |
| 135 | key: "Shift", | 102 | key: "Shift", |
| 136 | shiftKey: true, | 103 | shiftKey: true, |
| 137 | }), | 104 | }), |
| 138 | ); | 105 | ); |
| 139 | expect(readShortcutModifierState()).toEqual({ | 106 | expect(state).toEqual({ |
| 140 | metaKey: false, | 107 | metaKey: false, |
| 141 | ctrlKey: false, | 108 | ctrlKey: false, |
| 142 | altKey: false, | 109 | altKey: false, |
apps/web/src/shortcutModifierState.ts+41-41| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { create } from "zustand"; | 1 | import { useEffect, useState } from "react"; |
| 2 | 2 | ||
| 3 | export interface ShortcutModifierState { | 3 | export interface ShortcutModifierState { |
| 4 | metaKey: boolean; | 4 | metaKey: boolean; |
| ... | @@ -26,24 +26,32 @@ export function areShortcutModifierStatesEqual( | ... | @@ -26,24 +26,32 @@ export function areShortcutModifierStatesEqual( |
| 26 | ); | 26 | ); |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | const useShortcutModifierStateStore = create<{ | ||
| 30 | state: ShortcutModifierState; | ||
| 31 | setState: (state: ShortcutModifierState) => void; | ||
| 32 | clear: () => void; | ||
| 33 | }>((set) => ({ | ||
| 34 | state: EMPTY_SHORTCUT_MODIFIER_STATE, | ||
| 35 | setState: (state) => | ||
| 36 | set((current) => (areShortcutModifierStatesEqual(current.state, state) ? current : { state })), | ||
| 37 | clear: () => | ||
| 38 | set((current) => | ||
| 39 | areShortcutModifierStatesEqual(current.state, EMPTY_SHORTCUT_MODIFIER_STATE) | ||
| 40 | ? current | ||
| 41 | : { state: EMPTY_SHORTCUT_MODIFIER_STATE }, | ||
| 42 | ), | ||
| 43 | })); | ||
| 44 | |||
| 45 | export function useShortcutModifierState(): ShortcutModifierState { | 29 | export function useShortcutModifierState(): ShortcutModifierState { |
| 46 | return useShortcutModifierStateStore((store) => store.state); | 30 | const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE); |
| 31 | |||
| 32 | useEffect(() => { | ||
| 33 | const onKeyboardEvent = (event: KeyboardEvent) => { | ||
| 34 | setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event)); | ||
| 35 | }; | ||
| 36 | const onWindowBlur = () => { | ||
| 37 | setState((current) => | ||
| 38 | areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE) | ||
| 39 | ? current | ||
| 40 | : EMPTY_SHORTCUT_MODIFIER_STATE, | ||
| 41 | ); | ||
| 42 | }; | ||
| 43 | |||
| 44 | window.addEventListener("keydown", onKeyboardEvent, true); | ||
| 45 | window.addEventListener("keyup", onKeyboardEvent, true); | ||
| 46 | window.addEventListener("blur", onWindowBlur); | ||
| 47 | return () => { | ||
| 48 | window.removeEventListener("keydown", onKeyboardEvent, true); | ||
| 49 | window.removeEventListener("keyup", onKeyboardEvent, true); | ||
| 50 | window.removeEventListener("blur", onWindowBlur); | ||
| 51 | }; | ||
| 52 | }, []); | ||
| 53 | |||
| 54 | return state; | ||
| 47 | } | 55 | } |
| 48 | 56 | ||
| 49 | function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { | 57 | function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { |
| ... | @@ -64,33 +72,25 @@ function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { | ... | @@ -64,33 +72,25 @@ function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { |
| 64 | } | 72 | } |
| 65 | } | 73 | } |
| 66 | 74 | ||
| 67 | export function syncShortcutModifierStateFromKeyboardEvent(event: KeyboardEvent): void { | 75 | export function shortcutModifierStateAfterKeyboardEvent( |
| 76 | currentState: ShortcutModifierState, | ||
| 77 | event: KeyboardEvent, | ||
| 78 | ): ShortcutModifierState { | ||
| 68 | const normalizedModifierKey = normalizeModifierKey(event.key); | 79 | const normalizedModifierKey = normalizeModifierKey(event.key); |
| 80 | let nextState: ShortcutModifierState; | ||
| 69 | if (normalizedModifierKey) { | 81 | if (normalizedModifierKey) { |
| 70 | const currentState = useShortcutModifierStateStore.getState().state; | 82 | nextState = { |
| 71 | useShortcutModifierStateStore.getState().setState({ | ||
| 72 | ...currentState, | 83 | ...currentState, |
| 73 | [normalizedModifierKey]: event.type === "keydown", | 84 | [normalizedModifierKey]: event.type === "keydown", |
| 74 | }); | 85 | }; |
| 75 | return; | 86 | } else { |
| 87 | nextState = { | ||
| 88 | metaKey: event.metaKey, | ||
| 89 | ctrlKey: event.ctrlKey, | ||
| 90 | altKey: event.altKey, | ||
| 91 | shiftKey: event.shiftKey, | ||
| 92 | }; | ||
| 76 | } | 93 | } |
| 77 | 94 | ||
| 78 | useShortcutModifierStateStore.getState().setState({ | 95 | return areShortcutModifierStatesEqual(currentState, nextState) ? currentState : nextState; |
| 79 | metaKey: event.metaKey, | ||
| 80 | ctrlKey: event.ctrlKey, | ||
| 81 | altKey: event.altKey, | ||
| 82 | shiftKey: event.shiftKey, | ||
| 83 | }); | ||
| 84 | } | ||
| 85 | |||
| 86 | export function setShortcutModifierState(state: ShortcutModifierState): void { | ||
| 87 | useShortcutModifierStateStore.getState().setState(state); | ||
| 88 | } | ||
| 89 | |||
| 90 | export function clearShortcutModifierState(): void { | ||
| 91 | useShortcutModifierStateStore.getState().clear(); | ||
| 92 | } | ||
| 93 | |||
| 94 | export function readShortcutModifierState(): ShortcutModifierState { | ||
| 95 | return useShortcutModifierStateStore.getState().state; | ||
| 96 | } | 96 | } |
apps/web/src/sidebarProjectGrouping.ts+2-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; |
| 3 | import { | 3 | import { |
| 4 | deriveLogicalProjectKeyFromSettings, | 4 | deriveLogicalProjectKeyFromSettings, |
| ... | @@ -104,7 +104,7 @@ export function buildSidebarProjectSnapshots(input: { | ... | @@ -104,7 +104,7 @@ export function buildSidebarProjectSnapshots(input: { |
| 104 | representative, | 104 | representative, |
| 105 | members, | 105 | members, |
| 106 | }) | 106 | }) |
| 107 | : representative.name, | 107 | : representative.title, |
| 108 | groupedProjectCount: members.length, | 108 | groupedProjectCount: members.length, |
| 109 | environmentPresence: | 109 | environmentPresence: |
| 110 | hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", | 110 | hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", |
apps/web/src/state/assets.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); | ||
apps/web/src/state/auth.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); | ||
apps/web/src/state/desktopNetworkAccess.test.ts created+50| ... | @@ -0,0 +1,50 @@ | ||
| 1 | import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts"; | ||
| 2 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { createDesktopNetworkAccessStateAtom } from "./desktopNetworkAccess"; | ||
| 7 | |||
| 8 | const serverExposureState: DesktopServerExposureState = { | ||
| 9 | advertisedHost: "192.168.1.10", | ||
| 10 | endpointUrl: "http://192.168.1.10:37737", | ||
| 11 | mode: "network-accessible", | ||
| 12 | tailscaleServeEnabled: false, | ||
| 13 | tailscaleServePort: 443, | ||
| 14 | }; | ||
| 15 | |||
| 16 | const advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint> = []; | ||
| 17 | |||
| 18 | describe("desktopNetworkAccessState", () => { | ||
| 19 | it("retains the loaded snapshot when the settings screen remounts", async () => { | ||
| 20 | const getServerExposureState = vi.fn(async () => serverExposureState); | ||
| 21 | const getAdvertisedEndpoints = vi.fn(async () => advertisedEndpoints); | ||
| 22 | const atom = createDesktopNetworkAccessStateAtom(() => ({ | ||
| 23 | getAdvertisedEndpoints, | ||
| 24 | getServerExposureState, | ||
| 25 | })); | ||
| 26 | const registry = AtomRegistry.make(); | ||
| 27 | |||
| 28 | const unmount = registry.mount(atom); | ||
| 29 | await vi.waitFor(() => { | ||
| 30 | expect(AsyncResult.value(registry.get(atom))).toEqual( | ||
| 31 | expect.objectContaining({ _tag: "Some" }), | ||
| 32 | ); | ||
| 33 | }); | ||
| 34 | unmount(); | ||
| 35 | |||
| 36 | const remount = registry.mount(atom); | ||
| 37 | const result = registry.get(atom); | ||
| 38 | expect(AsyncResult.value(result)).toEqual( | ||
| 39 | expect.objectContaining({ | ||
| 40 | _tag: "Some", | ||
| 41 | value: { advertisedEndpoints, serverExposureState }, | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | expect(getServerExposureState).toHaveBeenCalledTimes(1); | ||
| 45 | expect(getAdvertisedEndpoints).toHaveBeenCalledTimes(1); | ||
| 46 | |||
| 47 | remount(); | ||
| 48 | registry.dispose(); | ||
| 49 | }); | ||
| 50 | }); | ||
apps/web/src/state/desktopNetworkAccess.ts created+79| ... | @@ -0,0 +1,79 @@ | ||
| 1 | import type { | ||
| 2 | AdvertisedEndpoint, | ||
| 3 | DesktopBridge, | ||
| 4 | DesktopServerExposureState, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import { Atom } from "effect/unstable/reactivity"; | ||
| 9 | |||
| 10 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | ||
| 11 | |||
| 12 | const DESKTOP_NETWORK_ACCESS_STALE_TIME_MS = 30_000; | ||
| 13 | |||
| 14 | type DesktopNetworkAccessBridge = Pick< | ||
| 15 | DesktopBridge, | ||
| 16 | "getAdvertisedEndpoints" | "getServerExposureState" | ||
| 17 | >; | ||
| 18 | |||
| 19 | export interface DesktopNetworkAccessSnapshot { | ||
| 20 | readonly advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint>; | ||
| 21 | readonly serverExposureState: DesktopServerExposureState; | ||
| 22 | } | ||
| 23 | |||
| 24 | class DesktopNetworkAccessError extends Schema.TaggedErrorClass<DesktopNetworkAccessError>()( | ||
| 25 | "DesktopNetworkAccessError", | ||
| 26 | { | ||
| 27 | message: Schema.String, | ||
| 28 | cause: Schema.optional(Schema.Defect()), | ||
| 29 | }, | ||
| 30 | ) {} | ||
| 31 | |||
| 32 | function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined { | ||
| 33 | return typeof window === "undefined" ? undefined : window.desktopBridge; | ||
| 34 | } | ||
| 35 | |||
| 36 | export function createDesktopNetworkAccessStateAtom( | ||
| 37 | getBridge: () => DesktopNetworkAccessBridge | undefined, | ||
| 38 | ) { | ||
| 39 | const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () { | ||
| 40 | const bridge = getBridge(); | ||
| 41 | if (!bridge) { | ||
| 42 | return yield* new DesktopNetworkAccessError({ | ||
| 43 | message: "Desktop network access is unavailable.", | ||
| 44 | }); | ||
| 45 | } | ||
| 46 | return yield* Effect.tryPromise({ | ||
| 47 | try: async (): Promise<DesktopNetworkAccessSnapshot> => { | ||
| 48 | const [serverExposureState, advertisedEndpoints] = await Promise.all([ | ||
| 49 | bridge.getServerExposureState(), | ||
| 50 | bridge.getAdvertisedEndpoints(), | ||
| 51 | ]); | ||
| 52 | return { advertisedEndpoints, serverExposureState }; | ||
| 53 | }, | ||
| 54 | catch: (cause) => | ||
| 55 | new DesktopNetworkAccessError({ | ||
| 56 | message: | ||
| 57 | cause instanceof Error ? cause.message : "Failed to load desktop network access.", | ||
| 58 | cause, | ||
| 59 | }), | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | return Atom.make(loadDesktopNetworkAccess()).pipe( | ||
| 64 | Atom.swr({ | ||
| 65 | staleTime: DESKTOP_NETWORK_ACCESS_STALE_TIME_MS, | ||
| 66 | revalidateOnMount: true, | ||
| 67 | }), | ||
| 68 | Atom.keepAlive, | ||
| 69 | Atom.withLabel("desktop:network-access"), | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | |||
| 73 | export const desktopNetworkAccessStateAtom = createDesktopNetworkAccessStateAtom( | ||
| 74 | getDesktopNetworkAccessBridge, | ||
| 75 | ); | ||
| 76 | |||
| 77 | export function refreshDesktopNetworkAccessState(): void { | ||
| 78 | appAtomRegistry.refresh(desktopNetworkAccessStateAtom); | ||
| 79 | } | ||
apps/web/src/state/desktopSshHosts.test.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { createDesktopSshHostsStateAtom } from "./desktopSshHosts"; | ||
| 7 | |||
| 8 | const hosts: ReadonlyArray<DesktopDiscoveredSshHost> = [ | ||
| 9 | { | ||
| 10 | alias: "devbox", | ||
| 11 | hostname: "devbox.local", | ||
| 12 | port: null, | ||
| 13 | source: "ssh-config", | ||
| 14 | username: null, | ||
| 15 | }, | ||
| 16 | ]; | ||
| 17 | |||
| 18 | describe("desktopSshHostsState", () => { | ||
| 19 | it("retains discovered hosts when the settings screen remounts", async () => { | ||
| 20 | const discoverSshHosts = vi.fn(async () => hosts); | ||
| 21 | const atom = createDesktopSshHostsStateAtom(() => ({ discoverSshHosts })); | ||
| 22 | const registry = AtomRegistry.make(); | ||
| 23 | |||
| 24 | const unmount = registry.mount(atom); | ||
| 25 | await vi.waitFor(() => { | ||
| 26 | expect(AsyncResult.value(registry.get(atom))).toEqual( | ||
| 27 | expect.objectContaining({ _tag: "Some", value: hosts }), | ||
| 28 | ); | ||
| 29 | }); | ||
| 30 | unmount(); | ||
| 31 | |||
| 32 | const remount = registry.mount(atom); | ||
| 33 | expect(AsyncResult.value(registry.get(atom))).toEqual( | ||
| 34 | expect.objectContaining({ _tag: "Some", value: hosts }), | ||
| 35 | ); | ||
| 36 | expect(discoverSshHosts).toHaveBeenCalledTimes(1); | ||
| 37 | |||
| 38 | remount(); | ||
| 39 | registry.dispose(); | ||
| 40 | }); | ||
| 41 | }); | ||
apps/web/src/state/desktopSshHosts.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import type { DesktopBridge, DesktopDiscoveredSshHost } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import { Atom } from "effect/unstable/reactivity"; | ||
| 5 | |||
| 6 | type DesktopSshDiscoveryBridge = Pick<DesktopBridge, "discoverSshHosts">; | ||
| 7 | |||
| 8 | class DesktopSshDiscoveryError extends Schema.TaggedErrorClass<DesktopSshDiscoveryError>()( | ||
| 9 | "DesktopSshDiscoveryError", | ||
| 10 | { | ||
| 11 | message: Schema.String, | ||
| 12 | cause: Schema.optional(Schema.Defect()), | ||
| 13 | }, | ||
| 14 | ) {} | ||
| 15 | |||
| 16 | function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined { | ||
| 17 | return typeof window === "undefined" ? undefined : window.desktopBridge; | ||
| 18 | } | ||
| 19 | |||
| 20 | export function createDesktopSshHostsStateAtom( | ||
| 21 | getBridge: () => DesktopSshDiscoveryBridge | undefined, | ||
| 22 | ) { | ||
| 23 | const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () { | ||
| 24 | const bridge = getBridge(); | ||
| 25 | if (!bridge) { | ||
| 26 | return yield* new DesktopSshDiscoveryError({ | ||
| 27 | message: "Desktop SSH host discovery is unavailable.", | ||
| 28 | }); | ||
| 29 | } | ||
| 30 | return yield* Effect.tryPromise({ | ||
| 31 | try: (): Promise<ReadonlyArray<DesktopDiscoveredSshHost>> => bridge.discoverSshHosts(), | ||
| 32 | catch: (cause) => | ||
| 33 | new DesktopSshDiscoveryError({ | ||
| 34 | message: cause instanceof Error ? cause.message : "Failed to discover SSH hosts.", | ||
| 35 | cause, | ||
| 36 | }), | ||
| 37 | }); | ||
| 38 | }); | ||
| 39 | |||
| 40 | return Atom.make(discoverDesktopSshHosts()).pipe( | ||
| 41 | Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), | ||
| 42 | Atom.keepAlive, | ||
| 43 | Atom.withLabel("desktop:ssh-hosts"), | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | export const desktopSshHostsStateAtom = createDesktopSshHostsStateAtom( | ||
| 48 | getDesktopSshDiscoveryBridge, | ||
| 49 | ); | ||
apps/web/src/state/desktopUpdate.test.ts created+111| ... | @@ -0,0 +1,111 @@ | ||
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | ||
| 2 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { createDesktopUpdateStateAtom } from "./desktopUpdate"; | ||
| 7 | |||
| 8 | const baseState: DesktopUpdateState = { | ||
| 9 | enabled: true, | ||
| 10 | status: "idle", | ||
| 11 | channel: "latest", | ||
| 12 | currentVersion: "1.0.0", | ||
| 13 | hostArch: "x64", | ||
| 14 | appArch: "x64", | ||
| 15 | runningUnderArm64Translation: false, | ||
| 16 | availableVersion: null, | ||
| 17 | downloadedVersion: null, | ||
| 18 | downloadPercent: null, | ||
| 19 | checkedAt: null, | ||
| 20 | message: null, | ||
| 21 | errorContext: null, | ||
| 22 | canRetry: false, | ||
| 23 | }; | ||
| 24 | |||
| 25 | describe("desktopUpdateStateAtom", () => { | ||
| 26 | it("loads once, retains state, and follows desktop update events", async () => { | ||
| 27 | let listener: ((state: DesktopUpdateState) => void) | undefined; | ||
| 28 | const unsubscribe = vi.fn(); | ||
| 29 | const getUpdateState = vi.fn(async () => baseState); | ||
| 30 | const onUpdateState = vi.fn((nextListener: (state: DesktopUpdateState) => void) => { | ||
| 31 | listener = nextListener; | ||
| 32 | return unsubscribe; | ||
| 33 | }); | ||
| 34 | const atom = createDesktopUpdateStateAtom(() => ({ getUpdateState, onUpdateState })); | ||
| 35 | const registry = AtomRegistry.make(); | ||
| 36 | |||
| 37 | const unmount = registry.mount(atom); | ||
| 38 | await vi.waitFor(() => { | ||
| 39 | expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState); | ||
| 40 | }); | ||
| 41 | |||
| 42 | const downloadedState: DesktopUpdateState = { | ||
| 43 | ...baseState, | ||
| 44 | status: "downloaded", | ||
| 45 | availableVersion: "1.1.0", | ||
| 46 | downloadedVersion: "1.1.0", | ||
| 47 | }; | ||
| 48 | listener?.(downloadedState); | ||
| 49 | |||
| 50 | await vi.waitFor(() => { | ||
| 51 | expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(downloadedState); | ||
| 52 | }); | ||
| 53 | unmount(); | ||
| 54 | |||
| 55 | const remount = registry.mount(atom); | ||
| 56 | expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(downloadedState); | ||
| 57 | expect(getUpdateState).toHaveBeenCalledTimes(1); | ||
| 58 | expect(onUpdateState).toHaveBeenCalledTimes(1); | ||
| 59 | |||
| 60 | remount(); | ||
| 61 | registry.dispose(); | ||
| 62 | expect(unsubscribe).toHaveBeenCalledTimes(1); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("does not let a slower initial read overwrite a newer update event", async () => { | ||
| 66 | let resolveInitial: ((state: DesktopUpdateState) => void) | undefined; | ||
| 67 | let listener: ((state: DesktopUpdateState) => void) | undefined; | ||
| 68 | const atom = createDesktopUpdateStateAtom(() => ({ | ||
| 69 | getUpdateState: () => | ||
| 70 | new Promise<DesktopUpdateState>((resolve) => { | ||
| 71 | resolveInitial = resolve; | ||
| 72 | }), | ||
| 73 | onUpdateState: (nextListener) => { | ||
| 74 | listener = nextListener; | ||
| 75 | return () => undefined; | ||
| 76 | }, | ||
| 77 | })); | ||
| 78 | const registry = AtomRegistry.make(); | ||
| 79 | registry.mount(atom); | ||
| 80 | |||
| 81 | await vi.waitFor(() => expect(listener).toBeDefined()); | ||
| 82 | const newerState: DesktopUpdateState = { ...baseState, status: "checking" }; | ||
| 83 | listener?.(newerState); | ||
| 84 | resolveInitial?.(baseState); | ||
| 85 | |||
| 86 | await vi.waitFor(() => { | ||
| 87 | expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(newerState); | ||
| 88 | }); | ||
| 89 | registry.dispose(); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("keeps listening when the initial desktop state read fails", async () => { | ||
| 93 | let listener: ((state: DesktopUpdateState) => void) | undefined; | ||
| 94 | const atom = createDesktopUpdateStateAtom(() => ({ | ||
| 95 | getUpdateState: async () => Promise.reject(new Error("IPC unavailable")), | ||
| 96 | onUpdateState: (nextListener) => { | ||
| 97 | listener = nextListener; | ||
| 98 | return () => undefined; | ||
| 99 | }, | ||
| 100 | })); | ||
| 101 | const registry = AtomRegistry.make(); | ||
| 102 | registry.mount(atom); | ||
| 103 | |||
| 104 | await vi.waitFor(() => expect(listener).toBeDefined()); | ||
| 105 | listener?.(baseState); | ||
| 106 | await vi.waitFor(() => { | ||
| 107 | expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState); | ||
| 108 | }); | ||
| 109 | registry.dispose(); | ||
| 110 | }); | ||
| 111 | }); | ||
apps/web/src/state/desktopUpdate.ts created+57| ... | @@ -0,0 +1,57 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Queue from "effect/Queue"; | ||
| 5 | import * as Stream from "effect/Stream"; | ||
| 6 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 7 | import { Atom } from "effect/unstable/reactivity"; | ||
| 8 | |||
| 9 | type DesktopUpdateBridge = Pick<DesktopBridge, "getUpdateState" | "onUpdateState">; | ||
| 10 | |||
| 11 | function getDesktopUpdateBridge(): DesktopUpdateBridge | undefined { | ||
| 12 | return typeof window === "undefined" ? undefined : window.desktopBridge; | ||
| 13 | } | ||
| 14 | |||
| 15 | export function createDesktopUpdateStateAtom(getBridge: () => DesktopUpdateBridge | undefined) { | ||
| 16 | const updates = Stream.callback<DesktopUpdateState | null>((queue) => | ||
| 17 | Effect.gen(function* () { | ||
| 18 | const bridge = getBridge(); | ||
| 19 | if (!bridge) { | ||
| 20 | Queue.offerUnsafe(queue, null); | ||
| 21 | return yield* Effect.never; | ||
| 22 | } | ||
| 23 | |||
| 24 | let receivedUpdate = false; | ||
| 25 | yield* Effect.acquireRelease( | ||
| 26 | Effect.sync(() => | ||
| 27 | bridge.onUpdateState((state) => { | ||
| 28 | receivedUpdate = true; | ||
| 29 | Queue.offerUnsafe(queue, state); | ||
| 30 | }), | ||
| 31 | ), | ||
| 32 | (unsubscribe) => Effect.sync(unsubscribe), | ||
| 33 | ); | ||
| 34 | |||
| 35 | const initialState = yield* Effect.tryPromise(() => bridge.getUpdateState()).pipe( | ||
| 36 | Effect.retry({ times: 2 }), | ||
| 37 | Effect.orElseSucceed(() => null), | ||
| 38 | ); | ||
| 39 | if (!receivedUpdate && initialState !== null) { | ||
| 40 | Queue.offerUnsafe(queue, initialState); | ||
| 41 | } | ||
| 42 | |||
| 43 | return yield* Effect.never; | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | |||
| 47 | return Atom.make(updates, { initialValue: null }).pipe( | ||
| 48 | Atom.keepAlive, | ||
| 49 | Atom.withLabel("desktop:update-state"), | ||
| 50 | ); | ||
| 51 | } | ||
| 52 | |||
| 53 | const desktopUpdateStateAtom = createDesktopUpdateStateAtom(getDesktopUpdateBridge); | ||
| 54 | |||
| 55 | export function useDesktopUpdateState(): DesktopUpdateState | null { | ||
| 56 | return AsyncResult.getOrElse(useAtomValue(desktopUpdateStateAtom), () => null); | ||
| 57 | } | ||
apps/web/src/state/entities.ts created+197| ... | @@ -0,0 +1,197 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { | ||
| 3 | EnvironmentProject, | ||
| 4 | EnvironmentThread, | ||
| 5 | EnvironmentThreadShell, | ||
| 6 | } from "@t3tools/client-runtime/state/shell"; | ||
| 7 | import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads"; | ||
| 8 | import type { | ||
| 9 | OrchestrationMessage, | ||
| 10 | OrchestrationProposedPlan, | ||
| 11 | OrchestrationSession, | ||
| 12 | OrchestrationThreadActivity, | ||
| 13 | ScopedProjectRef, | ||
| 14 | ScopedThreadRef, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 17 | import { Atom } from "effect/unstable/reactivity"; | ||
| 18 | import { useMemo } from "react"; | ||
| 19 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 20 | import { environmentProjects } from "./projects"; | ||
| 21 | import { environmentThreadDetails, environmentThreadShells } from "./threads"; | ||
| 22 | |||
| 23 | const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]); | ||
| 24 | const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); | ||
| 25 | const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]); | ||
| 26 | const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]); | ||
| 27 | const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]); | ||
| 28 | |||
| 29 | const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe( | ||
| 30 | Atom.withLabel("web-project:empty"), | ||
| 31 | ); | ||
| 32 | const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe( | ||
| 33 | Atom.withLabel("web-project-refs:empty"), | ||
| 34 | ); | ||
| 35 | const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( | ||
| 36 | Atom.withLabel("web-thread-refs:empty"), | ||
| 37 | ); | ||
| 38 | const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).pipe( | ||
| 39 | Atom.withLabel("web-thread-shell:empty"), | ||
| 40 | ); | ||
| 41 | const EMPTY_THREAD_DETAIL_ATOM = Atom.make<EnvironmentThread | null>(null).pipe( | ||
| 42 | Atom.withLabel("web-thread-detail:empty"), | ||
| 43 | ); | ||
| 44 | const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( | ||
| 45 | Atom.withLabel("web-thread-messages:empty"), | ||
| 46 | ); | ||
| 47 | const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe( | ||
| 48 | Atom.withLabel("web-thread-activities:empty"), | ||
| 49 | ); | ||
| 50 | const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( | ||
| 51 | Atom.withLabel("web-thread-proposed-plans:empty"), | ||
| 52 | ); | ||
| 53 | const EMPTY_SESSION_ATOM = Atom.make<OrchestrationSession | null>(null).pipe( | ||
| 54 | Atom.withLabel("web-thread-session:empty"), | ||
| 55 | ); | ||
| 56 | |||
| 57 | export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe( | ||
| 58 | Atom.keepAlive, | ||
| 59 | Atom.withLabel("web-active-environment-id"), | ||
| 60 | ); | ||
| 61 | |||
| 62 | export function useActiveEnvironmentId(): EnvironmentId | null { | ||
| 63 | return useAtomValue(activeEnvironmentIdAtom); | ||
| 64 | } | ||
| 65 | |||
| 66 | export function readActiveEnvironmentId(): EnvironmentId | null { | ||
| 67 | return appAtomRegistry.get(activeEnvironmentIdAtom); | ||
| 68 | } | ||
| 69 | |||
| 70 | export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void { | ||
| 71 | appAtomRegistry.set(activeEnvironmentIdAtom, environmentId); | ||
| 72 | } | ||
| 73 | |||
| 74 | export function useProjectRefs(): ReadonlyArray<ScopedProjectRef> { | ||
| 75 | return useAtomValue(environmentProjects.projectRefsAtom); | ||
| 76 | } | ||
| 77 | |||
| 78 | export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> { | ||
| 79 | return useAtomValue(environmentThreadShells.threadRefsAtom); | ||
| 80 | } | ||
| 81 | |||
| 82 | export function useEnvironmentProjectRefs( | ||
| 83 | environmentId: EnvironmentId | null, | ||
| 84 | ): ReadonlyArray<ScopedProjectRef> { | ||
| 85 | return useAtomValue( | ||
| 86 | environmentId === null | ||
| 87 | ? EMPTY_PROJECT_REFS_ATOM | ||
| 88 | : environmentProjects.environmentProjectRefsAtom(environmentId), | ||
| 89 | ); | ||
| 90 | } | ||
| 91 | |||
| 92 | export function useEnvironmentThreadRefs( | ||
| 93 | environmentId: EnvironmentId | null, | ||
| 94 | ): ReadonlyArray<ScopedThreadRef> { | ||
| 95 | return useAtomValue( | ||
| 96 | environmentId === null | ||
| 97 | ? EMPTY_THREAD_REFS_ATOM | ||
| 98 | : environmentThreadShells.environmentThreadRefsAtom(environmentId), | ||
| 99 | ); | ||
| 100 | } | ||
| 101 | |||
| 102 | export function useProjects(): ReadonlyArray<EnvironmentProject> { | ||
| 103 | return useAtomValue(environmentProjects.projectsAtom); | ||
| 104 | } | ||
| 105 | |||
| 106 | export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> { | ||
| 107 | return useAtomValue(environmentThreadShells.threadShellsAtom); | ||
| 108 | } | ||
| 109 | |||
| 110 | export function useThreadShellsForProjectRefs( | ||
| 111 | refs: ReadonlyArray<ScopedProjectRef>, | ||
| 112 | ): ReadonlyArray<EnvironmentThreadShell> { | ||
| 113 | return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); | ||
| 114 | } | ||
| 115 | |||
| 116 | export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { | ||
| 117 | return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); | ||
| 118 | } | ||
| 119 | |||
| 120 | export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null { | ||
| 121 | return useAtomValue( | ||
| 122 | ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref), | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | |||
| 126 | export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread | null { | ||
| 127 | return useAtomValue( | ||
| 128 | ref === null ? EMPTY_THREAD_DETAIL_ATOM : environmentThreadDetails.detailAtom(ref), | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | |||
| 132 | /** Detail collections composed with shell-authoritative thread/workspace metadata. */ | ||
| 133 | export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { | ||
| 134 | const shell = useThreadShell(ref); | ||
| 135 | const detail = useThreadDetail(ref); | ||
| 136 | return useMemo(() => mergeEnvironmentThread(detail, shell), [detail, shell]); | ||
| 137 | } | ||
| 138 | |||
| 139 | export function useThreadMessages( | ||
| 140 | ref: ScopedThreadRef | null, | ||
| 141 | ): ReadonlyArray<OrchestrationMessage> { | ||
| 142 | return useAtomValue( | ||
| 143 | ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref), | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | |||
| 147 | export function useThreadActivities( | ||
| 148 | ref: ScopedThreadRef | null, | ||
| 149 | ): ReadonlyArray<OrchestrationThreadActivity> { | ||
| 150 | return useAtomValue( | ||
| 151 | ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref), | ||
| 152 | ); | ||
| 153 | } | ||
| 154 | |||
| 155 | export function useThreadProposedPlans( | ||
| 156 | ref: ScopedThreadRef | null, | ||
| 157 | ): ReadonlyArray<OrchestrationProposedPlan> { | ||
| 158 | return useAtomValue( | ||
| 159 | ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref), | ||
| 160 | ); | ||
| 161 | } | ||
| 162 | |||
| 163 | export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null { | ||
| 164 | return useAtomValue( | ||
| 165 | ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref), | ||
| 166 | ); | ||
| 167 | } | ||
| 168 | |||
| 169 | export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { | ||
| 170 | return appAtomRegistry.get(environmentProjects.projectAtom(ref)); | ||
| 171 | } | ||
| 172 | |||
| 173 | export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null { | ||
| 174 | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); | ||
| 175 | } | ||
| 176 | |||
| 177 | export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { | ||
| 178 | return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); | ||
| 179 | } | ||
| 180 | |||
| 181 | export function readEnvironmentThreadRefs( | ||
| 182 | environmentId: EnvironmentId, | ||
| 183 | ): ReadonlyArray<ScopedThreadRef> { | ||
| 184 | return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId)); | ||
| 185 | } | ||
| 186 | |||
| 187 | export function readThreadRefs(): ReadonlyArray<ScopedThreadRef> { | ||
| 188 | return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); | ||
| 189 | } | ||
| 190 | |||
| 191 | export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { | ||
| 192 | return ( | ||
| 193 | appAtomRegistry | ||
| 194 | .get(environmentThreadShells.threadRefsAtom) | ||
| 195 | .find((ref) => ref.threadId === threadId) ?? null | ||
| 196 | ); | ||
| 197 | } | ||
apps/web/src/state/environments.ts created+99| ... | @@ -0,0 +1,99 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | connectionCatalogDisplayUrl, | ||
| 4 | type EnvironmentPresentation as BaseEnvironmentPresentation, | ||
| 5 | } from "@t3tools/client-runtime/connection"; | ||
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import { Atom } from "effect/unstable/reactivity"; | ||
| 9 | import { useMemo } from "react"; | ||
| 10 | |||
| 11 | import { environmentCatalog } from "../connection/catalog"; | ||
| 12 | import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; | ||
| 13 | import { useEnvironmentQuery } from "./query"; | ||
| 14 | import { relayEnvironmentDiscovery } from "./relay"; | ||
| 15 | import { usePreparedConnection } from "./session"; | ||
| 16 | |||
| 17 | export interface EnvironmentPresentation extends BaseEnvironmentPresentation { | ||
| 18 | readonly environmentId: EnvironmentId; | ||
| 19 | readonly label: string; | ||
| 20 | readonly displayUrl: string | null; | ||
| 21 | readonly relayManaged: boolean; | ||
| 22 | } | ||
| 23 | |||
| 24 | export const primaryEnvironmentIdAtom = Atom.make((get) => { | ||
| 25 | for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { | ||
| 26 | if (entry.target._tag === "PrimaryConnectionTarget") { | ||
| 27 | return environmentId; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | return null; | ||
| 31 | }).pipe(Atom.withLabel("web-primary-environment-id")); | ||
| 32 | |||
| 33 | function projectEnvironmentPresentation( | ||
| 34 | environmentId: EnvironmentId, | ||
| 35 | presentation: BaseEnvironmentPresentation, | ||
| 36 | ): EnvironmentPresentation { | ||
| 37 | return { | ||
| 38 | ...presentation, | ||
| 39 | environmentId, | ||
| 40 | label: presentation.entry.target.label, | ||
| 41 | displayUrl: connectionCatalogDisplayUrl(presentation.entry), | ||
| 42 | relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | export function useEnvironments() { | ||
| 47 | const catalog = useAtomValue(environmentCatalog.catalogValueAtom); | ||
| 48 | const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); | ||
| 49 | const presentationById = useAtomValue(environmentPresentations.presentationsAtom); | ||
| 50 | |||
| 51 | const environments = useMemo( | ||
| 52 | () => | ||
| 53 | [...presentationById.entries()].map(([environmentId, presentation]) => | ||
| 54 | projectEnvironmentPresentation(environmentId, presentation), | ||
| 55 | ), | ||
| 56 | [presentationById], | ||
| 57 | ); | ||
| 58 | |||
| 59 | return { | ||
| 60 | isReady: catalog.isReady, | ||
| 61 | networkStatus, | ||
| 62 | environments, | ||
| 63 | presentationById, | ||
| 64 | }; | ||
| 65 | } | ||
| 66 | |||
| 67 | export function usePrimaryEnvironmentId(): EnvironmentId | null { | ||
| 68 | return useAtomValue(primaryEnvironmentIdAtom); | ||
| 69 | } | ||
| 70 | |||
| 71 | export function useEnvironment( | ||
| 72 | environmentId: EnvironmentId | null, | ||
| 73 | ): EnvironmentPresentation | null { | ||
| 74 | const { presentation } = useEnvironmentPresentation(environmentId); | ||
| 75 | return useMemo( | ||
| 76 | () => | ||
| 77 | environmentId === null || presentation === null | ||
| 78 | ? null | ||
| 79 | : projectEnvironmentPresentation(environmentId, presentation), | ||
| 80 | [environmentId, presentation], | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | |||
| 84 | export function usePrimaryEnvironment(): EnvironmentPresentation | null { | ||
| 85 | return useEnvironment(usePrimaryEnvironmentId()); | ||
| 86 | } | ||
| 87 | |||
| 88 | export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): string | null { | ||
| 89 | const prepared = usePreparedConnection(environmentId); | ||
| 90 | return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null; | ||
| 91 | } | ||
| 92 | |||
| 93 | export function useRelayEnvironmentDiscovery() { | ||
| 94 | return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); | ||
| 95 | } | ||
| 96 | |||
| 97 | export function useEnvironmentConnectionState(environmentId: EnvironmentId) { | ||
| 98 | return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); | ||
| 99 | } | ||
apps/web/src/state/filesystem.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); | ||
apps/web/src/state/git.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); | ||
apps/web/src/state/orchestration.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); | ||
apps/web/src/state/presentation.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; | ||
| 3 | import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; | ||
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 5 | import { Atom } from "effect/unstable/reactivity"; | ||
| 6 | |||
| 7 | import { environmentCatalog } from "../connection/catalog"; | ||
| 8 | import { environmentSession } from "./session"; | ||
| 9 | |||
| 10 | export const environmentPresentations = createEnvironmentPresentationAtoms({ | ||
| 11 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 12 | stateAtom: environmentCatalog.stateAtom, | ||
| 13 | configValueAtom: environmentSession.configValueAtom, | ||
| 14 | }); | ||
| 15 | |||
| 16 | const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make<EnvironmentPresentation | null>(null).pipe( | ||
| 17 | Atom.withLabel("web-environment-presentation:empty"), | ||
| 18 | ); | ||
| 19 | |||
| 20 | export function useEnvironmentPresentation(environmentId: EnvironmentId | null) { | ||
| 21 | const catalog = useAtomValue(environmentCatalog.catalogValueAtom); | ||
| 22 | const presentation = useAtomValue( | ||
| 23 | environmentId === null | ||
| 24 | ? EMPTY_ENVIRONMENT_PRESENTATION_ATOM | ||
| 25 | : environmentPresentations.presentationAtom(environmentId), | ||
| 26 | ); | ||
| 27 | return { | ||
| 28 | isReady: catalog.isReady, | ||
| 29 | presentation, | ||
| 30 | }; | ||
| 31 | } | ||
apps/web/src/state/preview.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime); | ||
apps/web/src/state/projects.ts created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; | ||
| 2 | import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; | ||
| 3 | |||
| 4 | import { environmentCatalog } from "../connection/catalog"; | ||
| 5 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 6 | import { environmentSnapshotAtom } from "./shell"; | ||
| 7 | |||
| 8 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); | ||
| 9 | export const environmentProjects = createEnvironmentProjectAtoms({ | ||
| 10 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 11 | snapshotAtom: environmentSnapshotAtom, | ||
| 12 | }); | ||
apps/web/src/state/queries.ts created+257| ... | @@ -0,0 +1,257 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type CheckpointDiffTarget, | ||
| 4 | type ComposerPathSearchTarget, | ||
| 5 | } from "@t3tools/client-runtime/state/threads"; | ||
| 6 | import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; | ||
| 7 | import type { | ||
| 8 | EnvironmentId, | ||
| 9 | OrchestrationThread, | ||
| 10 | ThreadId, | ||
| 11 | VcsListRefsResult, | ||
| 12 | VcsRef, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import * as Cause from "effect/Cause"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 17 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 18 | |||
| 19 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 20 | import { orchestrationEnvironment } from "./orchestration"; | ||
| 21 | import { projectEnvironment } from "./projects"; | ||
| 22 | import { useEnvironmentQuery } from "./query"; | ||
| 23 | import { useEnvironmentThread } from "./threads"; | ||
| 24 | import { vcsEnvironment } from "./vcs"; | ||
| 25 | |||
| 26 | const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; | ||
| 27 | const COMPOSER_PATH_SEARCH_LIMIT = 80; | ||
| 28 | const VCS_REF_LIST_LIMIT = 100; | ||
| 29 | const EMPTY_REFS: ReadonlyArray<VcsRef> = []; | ||
| 30 | const INITIAL_BRANCH_CURSORS = [undefined] as const; | ||
| 31 | |||
| 32 | export interface ThreadDetailView { | ||
| 33 | readonly data: OrchestrationThread | null; | ||
| 34 | readonly error: string | null; | ||
| 35 | readonly isPending: boolean; | ||
| 36 | readonly isDeleted: boolean; | ||
| 37 | } | ||
| 38 | |||
| 39 | function useDebouncedValue<A>(value: A, delayMs: number): A { | ||
| 40 | const [debounced, setDebounced] = useState(value); | ||
| 41 | |||
| 42 | useEffect(() => { | ||
| 43 | const timer = window.setTimeout(() => { | ||
| 44 | setDebounced(value); | ||
| 45 | }, delayMs); | ||
| 46 | return () => { | ||
| 47 | window.clearTimeout(timer); | ||
| 48 | }; | ||
| 49 | }, [delayMs, value]); | ||
| 50 | |||
| 51 | return debounced; | ||
| 52 | } | ||
| 53 | |||
| 54 | export function useThreadDetail( | ||
| 55 | environmentId: EnvironmentId | null, | ||
| 56 | threadId: ThreadId | null, | ||
| 57 | ): ThreadDetailView { | ||
| 58 | const state = useEnvironmentThread(environmentId, threadId); | ||
| 59 | return { | ||
| 60 | data: Option.getOrNull(state.data), | ||
| 61 | error: Option.getOrNull(state.error), | ||
| 62 | isPending: state.status === "synchronizing", | ||
| 63 | isDeleted: state.status === "deleted", | ||
| 64 | }; | ||
| 65 | } | ||
| 66 | |||
| 67 | export function useBranches(target: VcsRefTarget) { | ||
| 68 | const query = target.query?.trim() ?? ""; | ||
| 69 | return useEnvironmentQuery( | ||
| 70 | target.environmentId !== null && target.cwd !== null | ||
| 71 | ? vcsEnvironment.listRefs({ | ||
| 72 | environmentId: target.environmentId, | ||
| 73 | input: { | ||
| 74 | cwd: target.cwd, | ||
| 75 | ...(query.length > 0 ? { query } : {}), | ||
| 76 | limit: VCS_REF_LIST_LIMIT, | ||
| 77 | }, | ||
| 78 | }) | ||
| 79 | : null, | ||
| 80 | ); | ||
| 81 | } | ||
| 82 | |||
| 83 | export function usePaginatedBranches(target: VcsRefTarget) { | ||
| 84 | const query = target.query?.trim() ?? ""; | ||
| 85 | const targetKey = | ||
| 86 | target.environmentId !== null && target.cwd !== null | ||
| 87 | ? JSON.stringify([target.environmentId, target.cwd, query]) | ||
| 88 | : null; | ||
| 89 | const [pagination, setPagination] = useState<{ | ||
| 90 | readonly targetKey: string | null; | ||
| 91 | readonly cursors: ReadonlyArray<number | undefined>; | ||
| 92 | }>({ | ||
| 93 | targetKey, | ||
| 94 | cursors: INITIAL_BRANCH_CURSORS, | ||
| 95 | }); | ||
| 96 | const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; | ||
| 97 | const pageAtoms = useMemo( | ||
| 98 | () => | ||
| 99 | target.environmentId !== null && target.cwd !== null | ||
| 100 | ? cursors.map((cursor) => | ||
| 101 | vcsEnvironment.listRefs({ | ||
| 102 | environmentId: target.environmentId!, | ||
| 103 | input: { | ||
| 104 | cwd: target.cwd!, | ||
| 105 | ...(query.length > 0 ? { query } : {}), | ||
| 106 | ...(cursor === undefined ? {} : { cursor }), | ||
| 107 | limit: VCS_REF_LIST_LIMIT, | ||
| 108 | }, | ||
| 109 | }), | ||
| 110 | ) | ||
| 111 | : [], | ||
| 112 | [cursors, query, target.cwd, target.environmentId], | ||
| 113 | ); | ||
| 114 | const pagesAtom = useMemo( | ||
| 115 | () => | ||
| 116 | Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( | ||
| 117 | Atom.withLabel(`web:vcs-ref-pages:${targetKey ?? "empty"}`), | ||
| 118 | ), | ||
| 119 | [pageAtoms, targetKey], | ||
| 120 | ); | ||
| 121 | const results = useAtomValue(pagesAtom); | ||
| 122 | const values = results.flatMap((result) => { | ||
| 123 | const value = Option.getOrNull(AsyncResult.value(result)); | ||
| 124 | return value === null ? [] : [value]; | ||
| 125 | }); | ||
| 126 | const refs = new Map<string, VcsRef>(); | ||
| 127 | for (const value of values) { | ||
| 128 | for (const ref of value.refs) { | ||
| 129 | refs.set(ref.name, ref); | ||
| 130 | } | ||
| 131 | } | ||
| 132 | const first = values[0] ?? null; | ||
| 133 | const last = values.at(-1) ?? null; | ||
| 134 | const data: VcsListRefsResult | null = | ||
| 135 | first === null || last === null | ||
| 136 | ? null | ||
| 137 | : { | ||
| 138 | refs: [...refs.values()], | ||
| 139 | isRepo: first.isRepo, | ||
| 140 | hasPrimaryRemote: first.hasPrimaryRemote, | ||
| 141 | nextCursor: last.nextCursor, | ||
| 142 | totalCount: Math.max(...values.map((value) => value.totalCount)), | ||
| 143 | }; | ||
| 144 | const failed = results.find((result) => result._tag === "Failure"); | ||
| 145 | const error = | ||
| 146 | failed?._tag === "Failure" | ||
| 147 | ? (() => { | ||
| 148 | const cause = Cause.squash(failed.cause); | ||
| 149 | return cause instanceof Error && cause.message.trim().length > 0 | ||
| 150 | ? cause.message | ||
| 151 | : "Failed to load refs."; | ||
| 152 | })() | ||
| 153 | : null; | ||
| 154 | const refresh = useCallback(() => { | ||
| 155 | const firstPage = pageAtoms[0]; | ||
| 156 | setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); | ||
| 157 | if (firstPage !== undefined) { | ||
| 158 | appAtomRegistry.refresh(firstPage); | ||
| 159 | } | ||
| 160 | }, [pageAtoms, targetKey]); | ||
| 161 | const loadNext = useCallback(() => { | ||
| 162 | if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) { | ||
| 163 | return; | ||
| 164 | } | ||
| 165 | setPagination((current) => { | ||
| 166 | const currentCursors = | ||
| 167 | current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS; | ||
| 168 | return currentCursors.includes(data.nextCursor!) | ||
| 169 | ? { targetKey, cursors: currentCursors } | ||
| 170 | : { targetKey, cursors: [...currentCursors, data.nextCursor!] }; | ||
| 171 | }); | ||
| 172 | }, [data?.nextCursor, targetKey]); | ||
| 173 | |||
| 174 | return { | ||
| 175 | data, | ||
| 176 | refs: data?.refs ?? EMPTY_REFS, | ||
| 177 | error, | ||
| 178 | isPending: results.some((result) => result.waiting), | ||
| 179 | refresh, | ||
| 180 | loadNext, | ||
| 181 | }; | ||
| 182 | } | ||
| 183 | |||
| 184 | export function useComposerPathSearch(target: ComposerPathSearchTarget) { | ||
| 185 | const normalizedTarget = useMemo( | ||
| 186 | () => ({ | ||
| 187 | environmentId: target.environmentId, | ||
| 188 | cwd: target.cwd, | ||
| 189 | query: target.query?.trim() ?? "", | ||
| 190 | }), | ||
| 191 | [target.cwd, target.environmentId, target.query], | ||
| 192 | ); | ||
| 193 | const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); | ||
| 194 | const result = useEnvironmentQuery( | ||
| 195 | debouncedTarget.environmentId !== null && | ||
| 196 | debouncedTarget.cwd !== null && | ||
| 197 | debouncedTarget.query.length > 0 | ||
| 198 | ? projectEnvironment.searchEntries({ | ||
| 199 | environmentId: debouncedTarget.environmentId, | ||
| 200 | input: { | ||
| 201 | cwd: debouncedTarget.cwd, | ||
| 202 | query: debouncedTarget.query, | ||
| 203 | limit: COMPOSER_PATH_SEARCH_LIMIT, | ||
| 204 | }, | ||
| 205 | }) | ||
| 206 | : null, | ||
| 207 | ); | ||
| 208 | |||
| 209 | return { | ||
| 210 | entries: result.data?.entries ?? [], | ||
| 211 | error: result.error, | ||
| 212 | isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, | ||
| 213 | refresh: result.refresh, | ||
| 214 | }; | ||
| 215 | } | ||
| 216 | |||
| 217 | export function useCheckpointDiff( | ||
| 218 | target: CheckpointDiffTarget, | ||
| 219 | options?: { readonly enabled?: boolean }, | ||
| 220 | ) { | ||
| 221 | const enabled = | ||
| 222 | options?.enabled !== false && | ||
| 223 | target.environmentId !== null && | ||
| 224 | target.threadId !== null && | ||
| 225 | target.fromTurnCount !== null && | ||
| 226 | target.toTurnCount !== null; | ||
| 227 | const fullThreadTarget = | ||
| 228 | enabled && target.fromTurnCount === 0 | ||
| 229 | ? { | ||
| 230 | environmentId: target.environmentId!, | ||
| 231 | input: { | ||
| 232 | threadId: target.threadId!, | ||
| 233 | toTurnCount: target.toTurnCount!, | ||
| 234 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 235 | }, | ||
| 236 | } | ||
| 237 | : null; | ||
| 238 | const turnTarget = | ||
| 239 | enabled && target.fromTurnCount !== 0 | ||
| 240 | ? { | ||
| 241 | environmentId: target.environmentId!, | ||
| 242 | input: { | ||
| 243 | threadId: target.threadId!, | ||
| 244 | fromTurnCount: target.fromTurnCount!, | ||
| 245 | toTurnCount: target.toTurnCount!, | ||
| 246 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 247 | }, | ||
| 248 | } | ||
| 249 | : null; | ||
| 250 | const fullThread = useEnvironmentQuery( | ||
| 251 | fullThreadTarget === null ? null : orchestrationEnvironment.fullThreadDiff(fullThreadTarget), | ||
| 252 | ); | ||
| 253 | const turn = useEnvironmentQuery( | ||
| 254 | turnTarget === null ? null : orchestrationEnvironment.turnDiff(turnTarget), | ||
| 255 | ); | ||
| 256 | return fullThreadTarget === null ? turn : fullThread; | ||
| 257 | } | ||
apps/web/src/state/query.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 5 | |||
| 6 | const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | ||
| 7 | Atom.withLabel("web-environment-query:empty"), | ||
| 8 | ); | ||
| 9 | |||
| 10 | export interface EnvironmentQueryView<A> { | ||
| 11 | readonly data: A | null; | ||
| 12 | readonly error: string | null; | ||
| 13 | readonly isPending: boolean; | ||
| 14 | readonly refresh: () => void; | ||
| 15 | } | ||
| 16 | |||
| 17 | function formatError(cause: Cause.Cause<unknown>): string { | ||
| 18 | const error = Cause.squash(cause); | ||
| 19 | return error instanceof Error && error.message.trim().length > 0 | ||
| 20 | ? error.message | ||
| 21 | : "The environment request failed."; | ||
| 22 | } | ||
| 23 | |||
| 24 | export function useEnvironmentQuery<A, E>( | ||
| 25 | atom: Atom.Atom<AsyncResult.AsyncResult<A, E>> | null, | ||
| 26 | ): EnvironmentQueryView<A> { | ||
| 27 | const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; | ||
| 28 | const result = useAtomValue(selectedAtom); | ||
| 29 | const refresh = useAtomRefresh(selectedAtom); | ||
| 30 | return { | ||
| 31 | data: Option.getOrNull(AsyncResult.value(result)), | ||
| 32 | error: result._tag === "Failure" ? formatError(result.cause) : null, | ||
| 33 | isPending: atom !== null && result.waiting, | ||
| 34 | refresh, | ||
| 35 | }; | ||
| 36 | } | ||
apps/web/src/state/relay.ts created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const relayEnvironmentDiscovery = | ||
| 6 | createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); | ||
apps/web/src/state/review.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); | ||
apps/web/src/state/server.ts created+100| ... | @@ -0,0 +1,100 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | type EditorId, | ||
| 4 | type ServerConfig, | ||
| 5 | type ServerConfigStreamEvent, | ||
| 6 | type ServerLifecycleWelcomePayload, | ||
| 7 | type ServerProvider, | ||
| 8 | type ServerSettings, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; | ||
| 11 | import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; | ||
| 12 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 15 | |||
| 16 | import { environmentCatalog } from "../connection/catalog"; | ||
| 17 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 18 | import { primaryEnvironmentIdAtom } from "./environments"; | ||
| 19 | import { environmentSession } from "./session"; | ||
| 20 | |||
| 21 | export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { | ||
| 22 | initialConfigValueAtom: environmentSession.configValueAtom, | ||
| 23 | }); | ||
| 24 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ | ||
| 25 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 26 | configValueAtom: serverEnvironment.configValueAtom, | ||
| 27 | }); | ||
| 28 | |||
| 29 | interface PrimaryServerState { | ||
| 30 | readonly config: ServerConfig | null; | ||
| 31 | readonly latestEvent: ServerConfigStreamEvent | null; | ||
| 32 | readonly welcome: ServerLifecycleWelcomePayload | null; | ||
| 33 | } | ||
| 34 | |||
| 35 | const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; | ||
| 36 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | ||
| 37 | const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { | ||
| 38 | config: null, | ||
| 39 | latestEvent: null, | ||
| 40 | welcome: null, | ||
| 41 | }; | ||
| 42 | |||
| 43 | export const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { | ||
| 44 | const environmentId = get(primaryEnvironmentIdAtom); | ||
| 45 | if (environmentId === null) { | ||
| 46 | return EMPTY_PRIMARY_SERVER_STATE; | ||
| 47 | } | ||
| 48 | |||
| 49 | const target = { environmentId, input: {} }; | ||
| 50 | const configProjection = Option.getOrNull( | ||
| 51 | AsyncResult.value(get(serverEnvironment.configProjection(target))), | ||
| 52 | ); | ||
| 53 | const welcome = Option.getOrNull(AsyncResult.value(get(serverEnvironment.welcome(target)))); | ||
| 54 | |||
| 55 | return { | ||
| 56 | config: get(serverEnvironment.configValueAtom(environmentId)), | ||
| 57 | latestEvent: configProjection?.latestEvent ?? null, | ||
| 58 | welcome, | ||
| 59 | }; | ||
| 60 | }).pipe(Atom.withLabel("web-primary-server-state")); | ||
| 61 | |||
| 62 | export const primaryServerConfigAtom = Atom.make( | ||
| 63 | (get): ServerConfig | null => get(primaryServerStateAtom).config, | ||
| 64 | ).pipe(Atom.withLabel("web-primary-server-config")); | ||
| 65 | |||
| 66 | export const primaryServerConfigEventAtom = Atom.make( | ||
| 67 | (get): ServerConfigStreamEvent | null => get(primaryServerStateAtom).latestEvent, | ||
| 68 | ).pipe(Atom.withLabel("web-primary-server-config-event")); | ||
| 69 | |||
| 70 | export const primaryServerWelcomeAtom = Atom.make( | ||
| 71 | (get): ServerLifecycleWelcomePayload | null => get(primaryServerStateAtom).welcome, | ||
| 72 | ).pipe(Atom.withLabel("web-primary-server-welcome")); | ||
| 73 | |||
| 74 | export const primaryServerSettingsAtom = Atom.make( | ||
| 75 | (get): ServerSettings => get(primaryServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS, | ||
| 76 | ).pipe(Atom.withLabel("web-primary-server-settings")); | ||
| 77 | |||
| 78 | export const primaryServerProvidersAtom = Atom.make( | ||
| 79 | (get): ReadonlyArray<ServerProvider> => | ||
| 80 | get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, | ||
| 81 | ).pipe(Atom.withLabel("web-primary-server-providers")); | ||
| 82 | |||
| 83 | export const primaryServerKeybindingsAtom = Atom.make( | ||
| 84 | (get): ServerConfig["keybindings"] => | ||
| 85 | get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 86 | ).pipe(Atom.withLabel("web-primary-server-keybindings")); | ||
| 87 | |||
| 88 | export const primaryServerAvailableEditorsAtom = Atom.make( | ||
| 89 | (get): ReadonlyArray<EditorId> => | ||
| 90 | get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS, | ||
| 91 | ).pipe(Atom.withLabel("web-primary-server-available-editors")); | ||
| 92 | |||
| 93 | export const primaryServerKeybindingsConfigPathAtom = Atom.make( | ||
| 94 | (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null, | ||
| 95 | ).pipe(Atom.withLabel("web-primary-server-keybindings-config-path")); | ||
| 96 | |||
| 97 | export const primaryServerObservabilityAtom = Atom.make( | ||
| 98 | (get): ServerConfig["observability"] | null => | ||
| 99 | get(primaryServerConfigAtom)?.observability ?? null, | ||
| 100 | ).pipe(Atom.withLabel("web-primary-server-observability")); | ||
apps/web/src/state/session.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { Atom } from "effect/unstable/reactivity"; | ||
| 6 | |||
| 7 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 8 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 9 | |||
| 10 | export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); | ||
| 11 | |||
| 12 | const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( | ||
| 13 | Atom.withLabel("web-prepared-connection:empty"), | ||
| 14 | ); | ||
| 15 | |||
| 16 | export function usePreparedConnection(environmentId: EnvironmentId | null) { | ||
| 17 | return useAtomValue( | ||
| 18 | environmentId === null | ||
| 19 | ? EMPTY_PREPARED_CONNECTION_ATOM | ||
| 20 | : environmentSession.preparedConnectionValueAtom(environmentId), | ||
| 21 | ); | ||
| 22 | } | ||
| 23 | |||
| 24 | export function readPreparedConnection(environmentId: EnvironmentId) { | ||
| 25 | return Option.getOrNull( | ||
| 26 | appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), | ||
| 27 | ); | ||
| 28 | } | ||
apps/web/src/state/shell.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | import { | ||
| 2 | createEnvironmentShellAtoms, | ||
| 3 | createEnvironmentShellSummaryAtom, | ||
| 4 | createEnvironmentSnapshotAtom, | ||
| 5 | createShellEnvironmentAtoms, | ||
| 6 | } from "@t3tools/client-runtime/state/shell"; | ||
| 7 | |||
| 8 | import { environmentCatalog } from "../connection/catalog"; | ||
| 9 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 10 | |||
| 11 | export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); | ||
| 12 | export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); | ||
| 13 | export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); | ||
| 14 | export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ | ||
| 15 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 16 | shellStateValueAtom: environmentShell.stateValueAtom, | ||
| 17 | }); | ||
apps/web/src/state/sourceControl.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime); | ||
apps/web/src/state/sourceControlActions.ts created+356| ... | @@ -0,0 +1,356 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { | ||
| 3 | AtomCommandFailure, | ||
| 4 | AtomCommandResult, | ||
| 5 | AtomCommandSuccess, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import { | ||
| 8 | VcsActionUnavailableError, | ||
| 9 | type VcsActionOperation, | ||
| 10 | } from "@t3tools/client-runtime/state/vcs"; | ||
| 11 | import type { | ||
| 12 | EnvironmentId, | ||
| 13 | GitActionProgressEvent, | ||
| 14 | GitResolvePullRequestResult, | ||
| 15 | GitStackedAction, | ||
| 16 | SourceControlCloneProtocol, | ||
| 17 | SourceControlRepositoryVisibility, | ||
| 18 | ThreadId, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | import * as Cause from "effect/Cause"; | ||
| 21 | import * as Option from "effect/Option"; | ||
| 22 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 23 | import { useCallback } from "react"; | ||
| 24 | |||
| 25 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 26 | import { gitEnvironment } from "./git"; | ||
| 27 | import { useEnvironmentQuery } from "./query"; | ||
| 28 | import { sourceControlEnvironment } from "./sourceControl"; | ||
| 29 | import { useAtomCommand } from "./use-atom-command"; | ||
| 30 | import { vcsActionManager, vcsEnvironment } from "./vcs"; | ||
| 31 | |||
| 32 | export type SourceControlActionKind = | ||
| 33 | | "init" | ||
| 34 | | "pull" | ||
| 35 | | "publishRepository" | ||
| 36 | | "runStackedAction" | ||
| 37 | | "preparePullRequestThread"; | ||
| 38 | |||
| 39 | export interface SourceControlActionScope { | ||
| 40 | readonly environmentId: EnvironmentId | null; | ||
| 41 | readonly cwd: string | null; | ||
| 42 | } | ||
| 43 | |||
| 44 | interface SourceControlActionState< | ||
| 45 | TArgs extends ReadonlyArray<unknown>, | ||
| 46 | R extends AtomCommandResult<unknown, unknown>, | ||
| 47 | > { | ||
| 48 | readonly isPending: boolean; | ||
| 49 | readonly error: unknown; | ||
| 50 | readonly run: ( | ||
| 51 | ...args: TArgs | ||
| 52 | ) => Promise< | ||
| 53 | AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R> | VcsActionUnavailableError> | ||
| 54 | >; | ||
| 55 | readonly resetError: () => void; | ||
| 56 | } | ||
| 57 | |||
| 58 | const ACTION_OPERATION = { | ||
| 59 | init: "init", | ||
| 60 | pull: "pull", | ||
| 61 | publishRepository: "publish_repository", | ||
| 62 | runStackedAction: "run_change_request", | ||
| 63 | preparePullRequestThread: "prepare_pull_request_thread", | ||
| 64 | } as const satisfies Record<SourceControlActionKind, VcsActionOperation>; | ||
| 65 | |||
| 66 | function useAction< | ||
| 67 | TArgs extends ReadonlyArray<unknown>, | ||
| 68 | R extends AtomCommandResult<unknown, unknown>, | ||
| 69 | >(input: { | ||
| 70 | readonly kind: SourceControlActionKind; | ||
| 71 | readonly label: string; | ||
| 72 | readonly scope: SourceControlActionScope; | ||
| 73 | readonly action: (...args: TArgs) => Promise<R>; | ||
| 74 | readonly onSuccess?: () => void; | ||
| 75 | readonly managedExternally?: boolean; | ||
| 76 | }): SourceControlActionState<TArgs, R> { | ||
| 77 | const operation = ACTION_OPERATION[input.kind]; | ||
| 78 | const state = useAtomValue(vcsActionManager.stateAtom(input.scope)); | ||
| 79 | const ownsState = state.operation === operation; | ||
| 80 | |||
| 81 | const resetError = useCallback(() => { | ||
| 82 | vcsActionManager.resetError(appAtomRegistry, input.scope, operation); | ||
| 83 | }, [input.scope, operation]); | ||
| 84 | |||
| 85 | const run = useCallback( | ||
| 86 | async (...args: TArgs) => { | ||
| 87 | const execute = async (): Promise< | ||
| 88 | AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>> | ||
| 89 | > => { | ||
| 90 | const result = await input.action(...args); | ||
| 91 | if (AsyncResult.isSuccess(result)) { | ||
| 92 | input.onSuccess?.(); | ||
| 93 | } | ||
| 94 | return result as AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>>; | ||
| 95 | }; | ||
| 96 | return input.managedExternally === true | ||
| 97 | ? execute() | ||
| 98 | : vcsActionManager.track( | ||
| 99 | appAtomRegistry, | ||
| 100 | input.scope, | ||
| 101 | { | ||
| 102 | operation, | ||
| 103 | label: input.label, | ||
| 104 | }, | ||
| 105 | execute, | ||
| 106 | ); | ||
| 107 | }, | ||
| 108 | [input.action, input.label, input.managedExternally, input.onSuccess, input.scope, operation], | ||
| 109 | ); | ||
| 110 | |||
| 111 | return { | ||
| 112 | error: ownsState ? state.error : null, | ||
| 113 | isPending: ownsState && state.isRunning, | ||
| 114 | resetError, | ||
| 115 | run, | ||
| 116 | }; | ||
| 117 | } | ||
| 118 | |||
| 119 | function resolveScope(scope: SourceControlActionScope) { | ||
| 120 | if (scope.environmentId === null || scope.cwd === null) { | ||
| 121 | return null; | ||
| 122 | } | ||
| 123 | return { | ||
| 124 | environmentId: scope.environmentId, | ||
| 125 | cwd: scope.cwd, | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | |||
| 129 | function unavailableResult(message: string) { | ||
| 130 | return AsyncResult.failure<never, VcsActionUnavailableError>( | ||
| 131 | Cause.fail(new VcsActionUnavailableError({ message })), | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | |||
| 135 | export function useSourceControlActionRunning( | ||
| 136 | scope: SourceControlActionScope, | ||
| 137 | kinds: ReadonlyArray<SourceControlActionKind>, | ||
| 138 | ): boolean { | ||
| 139 | const state = useAtomValue(vcsActionManager.stateAtom(scope)); | ||
| 140 | return ( | ||
| 141 | state.isRunning && | ||
| 142 | state.operation !== null && | ||
| 143 | kinds.some((kind) => ACTION_OPERATION[kind] === state.operation) | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | |||
| 147 | export function useVcsInitAction(scope: SourceControlActionScope) { | ||
| 148 | const init = useAtomCommand(vcsEnvironment.init, { reportFailure: false }); | ||
| 149 | const action = useCallback(async () => { | ||
| 150 | const target = resolveScope(scope); | ||
| 151 | if (target === null) { | ||
| 152 | return unavailableResult("Git init is unavailable."); | ||
| 153 | } | ||
| 154 | return init({ | ||
| 155 | environmentId: target.environmentId, | ||
| 156 | input: { cwd: target.cwd }, | ||
| 157 | }); | ||
| 158 | }, [init, scope]); | ||
| 159 | return useAction({ kind: "init", label: "Initializing repository", scope, action }); | ||
| 160 | } | ||
| 161 | |||
| 162 | export function useVcsPullAction(scope: SourceControlActionScope) { | ||
| 163 | const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); | ||
| 164 | const status = useEnvironmentQuery( | ||
| 165 | scope.environmentId !== null && scope.cwd !== null | ||
| 166 | ? vcsEnvironment.status({ | ||
| 167 | environmentId: scope.environmentId, | ||
| 168 | input: { cwd: scope.cwd }, | ||
| 169 | }) | ||
| 170 | : null, | ||
| 171 | ); | ||
| 172 | const action = useCallback(async () => { | ||
| 173 | const target = resolveScope(scope); | ||
| 174 | if (target === null) { | ||
| 175 | return unavailableResult("Git pull is unavailable."); | ||
| 176 | } | ||
| 177 | return pull({ | ||
| 178 | environmentId: target.environmentId, | ||
| 179 | input: { cwd: target.cwd }, | ||
| 180 | }); | ||
| 181 | }, [pull, scope]); | ||
| 182 | return useAction({ | ||
| 183 | kind: "pull", | ||
| 184 | label: "Pulling latest changes", | ||
| 185 | scope, | ||
| 186 | action, | ||
| 187 | onSuccess: status.refresh, | ||
| 188 | }); | ||
| 189 | } | ||
| 190 | |||
| 191 | export function useGitStackedAction(scope: SourceControlActionScope) { | ||
| 192 | const runStackedAction = useAtomCommand(vcsActionManager.runStackedAction(scope), { | ||
| 193 | reportFailure: false, | ||
| 194 | }); | ||
| 195 | const status = useEnvironmentQuery( | ||
| 196 | scope.environmentId !== null && scope.cwd !== null | ||
| 197 | ? vcsEnvironment.status({ | ||
| 198 | environmentId: scope.environmentId, | ||
| 199 | input: { cwd: scope.cwd }, | ||
| 200 | }) | ||
| 201 | : null, | ||
| 202 | ); | ||
| 203 | |||
| 204 | const action = useCallback( | ||
| 205 | async (input: { | ||
| 206 | actionId: string; | ||
| 207 | action: GitStackedAction; | ||
| 208 | commitMessage?: string; | ||
| 209 | featureBranch?: boolean; | ||
| 210 | filePaths?: string[]; | ||
| 211 | onProgress?: (event: GitActionProgressEvent) => void; | ||
| 212 | }) => { | ||
| 213 | if (resolveScope(scope) === null) { | ||
| 214 | return unavailableResult("Git action is unavailable."); | ||
| 215 | } | ||
| 216 | return runStackedAction({ | ||
| 217 | actionId: input.actionId, | ||
| 218 | action: input.action, | ||
| 219 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | ||
| 220 | ...(input.featureBranch ? { featureBranch: true } : {}), | ||
| 221 | ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}), | ||
| 222 | ...(input.onProgress ? { onProgress: input.onProgress } : {}), | ||
| 223 | }); | ||
| 224 | }, | ||
| 225 | [runStackedAction, scope], | ||
| 226 | ); | ||
| 227 | |||
| 228 | return useAction({ | ||
| 229 | kind: "runStackedAction", | ||
| 230 | label: "Running source control action", | ||
| 231 | scope, | ||
| 232 | action, | ||
| 233 | onSuccess: status.refresh, | ||
| 234 | managedExternally: true, | ||
| 235 | }); | ||
| 236 | } | ||
| 237 | |||
| 238 | export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { | ||
| 239 | const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { | ||
| 240 | reportFailure: false, | ||
| 241 | }); | ||
| 242 | const status = useEnvironmentQuery( | ||
| 243 | scope.environmentId !== null && scope.cwd !== null | ||
| 244 | ? vcsEnvironment.status({ | ||
| 245 | environmentId: scope.environmentId, | ||
| 246 | input: { cwd: scope.cwd }, | ||
| 247 | }) | ||
| 248 | : null, | ||
| 249 | ); | ||
| 250 | const action = useCallback( | ||
| 251 | async (input: { | ||
| 252 | provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; | ||
| 253 | repository: string; | ||
| 254 | visibility: SourceControlRepositoryVisibility; | ||
| 255 | remoteName: string; | ||
| 256 | protocol: SourceControlCloneProtocol; | ||
| 257 | }) => { | ||
| 258 | const target = resolveScope(scope); | ||
| 259 | if (target === null) { | ||
| 260 | return unavailableResult("Repository publishing is unavailable."); | ||
| 261 | } | ||
| 262 | return publishRepository({ | ||
| 263 | environmentId: target.environmentId, | ||
| 264 | input: { | ||
| 265 | cwd: target.cwd, | ||
| 266 | ...input, | ||
| 267 | }, | ||
| 268 | }); | ||
| 269 | }, | ||
| 270 | [publishRepository, scope], | ||
| 271 | ); | ||
| 272 | return useAction({ | ||
| 273 | kind: "publishRepository", | ||
| 274 | label: "Publishing repository", | ||
| 275 | scope, | ||
| 276 | action, | ||
| 277 | onSuccess: status.refresh, | ||
| 278 | }); | ||
| 279 | } | ||
| 280 | |||
| 281 | export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) { | ||
| 282 | const preparePullRequestThread = useAtomCommand(gitEnvironment.preparePullRequestThread, { | ||
| 283 | reportFailure: false, | ||
| 284 | }); | ||
| 285 | const action = useCallback( | ||
| 286 | async (input: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { | ||
| 287 | const target = resolveScope(scope); | ||
| 288 | if (target === null) { | ||
| 289 | return unavailableResult("Pull request thread preparation is unavailable."); | ||
| 290 | } | ||
| 291 | return preparePullRequestThread({ | ||
| 292 | environmentId: target.environmentId, | ||
| 293 | input: { | ||
| 294 | cwd: target.cwd, | ||
| 295 | reference: input.reference, | ||
| 296 | mode: input.mode, | ||
| 297 | ...(input.threadId ? { threadId: input.threadId } : {}), | ||
| 298 | }, | ||
| 299 | }); | ||
| 300 | }, | ||
| 301 | [preparePullRequestThread, scope], | ||
| 302 | ); | ||
| 303 | return useAction({ | ||
| 304 | kind: "preparePullRequestThread", | ||
| 305 | label: "Preparing pull request thread", | ||
| 306 | scope, | ||
| 307 | action, | ||
| 308 | }); | ||
| 309 | } | ||
| 310 | |||
| 311 | export interface PullRequestResolutionTarget { | ||
| 312 | readonly environmentId: EnvironmentId | null; | ||
| 313 | readonly cwd: string | null; | ||
| 314 | readonly reference: string | null; | ||
| 315 | } | ||
| 316 | |||
| 317 | export function readCachedPullRequestResolution( | ||
| 318 | target: PullRequestResolutionTarget, | ||
| 319 | ): GitResolvePullRequestResult | null { | ||
| 320 | if (target.environmentId === null || target.cwd === null || target.reference === null) { | ||
| 321 | return null; | ||
| 322 | } | ||
| 323 | return Option.getOrNull( | ||
| 324 | AsyncResult.value( | ||
| 325 | appAtomRegistry.get( | ||
| 326 | gitEnvironment.pullRequestResolution({ | ||
| 327 | environmentId: target.environmentId, | ||
| 328 | input: { cwd: target.cwd, reference: target.reference }, | ||
| 329 | }), | ||
| 330 | ), | ||
| 331 | ), | ||
| 332 | ); | ||
| 333 | } | ||
| 334 | |||
| 335 | export function usePullRequestResolutionState(target: PullRequestResolutionTarget) { | ||
| 336 | const query = useEnvironmentQuery( | ||
| 337 | target.environmentId !== null && target.cwd !== null && target.reference !== null | ||
| 338 | ? gitEnvironment.pullRequestResolution({ | ||
| 339 | environmentId: target.environmentId, | ||
| 340 | input: { | ||
| 341 | cwd: target.cwd, | ||
| 342 | reference: target.reference, | ||
| 343 | }, | ||
| 344 | }) | ||
| 345 | : null, | ||
| 346 | ); | ||
| 347 | const cached = readCachedPullRequestResolution(target); | ||
| 348 | |||
| 349 | return { | ||
| 350 | data: query.data ?? cached, | ||
| 351 | error: query.error, | ||
| 352 | isPending: query.isPending && cached === null, | ||
| 353 | isFetching: query.isPending, | ||
| 354 | refresh: query.refresh, | ||
| 355 | }; | ||
| 356 | } | ||
apps/web/src/state/terminal.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime); | ||
apps/web/src/state/terminalSessions.ts created+90| ... | @@ -0,0 +1,90 @@ | ||
| 1 | import { | ||
| 2 | combineTerminalSessionState, | ||
| 3 | EMPTY_TERMINAL_BUFFER_STATE, | ||
| 4 | EMPTY_TERMINAL_SESSION_STATE, | ||
| 5 | selectRunningSubprocessTerminalIds, | ||
| 6 | type KnownTerminalSession, | ||
| 7 | type TerminalSessionState, | ||
| 8 | } from "@t3tools/client-runtime/state/terminal"; | ||
| 9 | import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; | ||
| 10 | import { useMemo } from "react"; | ||
| 11 | |||
| 12 | import { useEnvironmentQuery } from "./query"; | ||
| 13 | import { terminalEnvironment } from "./terminal"; | ||
| 14 | |||
| 15 | export function useAttachedTerminalSession(input: { | ||
| 16 | readonly environmentId: EnvironmentId | null; | ||
| 17 | readonly terminal: TerminalAttachInput | null; | ||
| 18 | }): TerminalSessionState { | ||
| 19 | const attach = useEnvironmentQuery( | ||
| 20 | input.environmentId !== null && input.terminal !== null | ||
| 21 | ? terminalEnvironment.attach({ | ||
| 22 | environmentId: input.environmentId, | ||
| 23 | input: input.terminal, | ||
| 24 | }) | ||
| 25 | : null, | ||
| 26 | ); | ||
| 27 | const metadata = useEnvironmentQuery( | ||
| 28 | input.environmentId === null | ||
| 29 | ? null | ||
| 30 | : terminalEnvironment.metadata({ | ||
| 31 | environmentId: input.environmentId, | ||
| 32 | input: null, | ||
| 33 | }), | ||
| 34 | ); | ||
| 35 | |||
| 36 | return useMemo(() => { | ||
| 37 | if (input.environmentId === null || input.terminal === null) { | ||
| 38 | return EMPTY_TERMINAL_SESSION_STATE; | ||
| 39 | } | ||
| 40 | const summary = | ||
| 41 | metadata.data?.find( | ||
| 42 | (terminal) => | ||
| 43 | terminal.threadId === input.terminal?.threadId && | ||
| 44 | terminal.terminalId === input.terminal?.terminalId, | ||
| 45 | ) ?? null; | ||
| 46 | const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); | ||
| 47 | return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; | ||
| 48 | }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); | ||
| 49 | } | ||
| 50 | |||
| 51 | export function useKnownTerminalSessions(input: { | ||
| 52 | readonly environmentId: EnvironmentId | null; | ||
| 53 | readonly threadId: ThreadId | null; | ||
| 54 | }): ReadonlyArray<KnownTerminalSession> { | ||
| 55 | const metadata = useEnvironmentQuery( | ||
| 56 | input.environmentId === null | ||
| 57 | ? null | ||
| 58 | : terminalEnvironment.metadata({ | ||
| 59 | environmentId: input.environmentId, | ||
| 60 | input: null, | ||
| 61 | }), | ||
| 62 | ); | ||
| 63 | return useMemo(() => { | ||
| 64 | if (input.environmentId === null) { | ||
| 65 | return []; | ||
| 66 | } | ||
| 67 | return (metadata.data ?? []) | ||
| 68 | .filter((summary) => input.threadId === null || summary.threadId === input.threadId) | ||
| 69 | .map((summary) => ({ | ||
| 70 | target: { | ||
| 71 | environmentId: input.environmentId!, | ||
| 72 | threadId: ThreadId.make(summary.threadId), | ||
| 73 | terminalId: summary.terminalId, | ||
| 74 | }, | ||
| 75 | state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), | ||
| 76 | })) | ||
| 77 | .sort((left, right) => | ||
| 78 | left.target.terminalId.localeCompare(right.target.terminalId, undefined, { | ||
| 79 | numeric: true, | ||
| 80 | }), | ||
| 81 | ); | ||
| 82 | }, [input.environmentId, input.threadId, metadata.data]); | ||
| 83 | } | ||
| 84 | |||
| 85 | export function useThreadRunningTerminalIds(input: { | ||
| 86 | readonly environmentId: EnvironmentId | null; | ||
| 87 | readonly threadId: ThreadId | null; | ||
| 88 | }): ReadonlyArray<string> { | ||
| 89 | return selectRunningSubprocessTerminalIds(useKnownTerminalSessions(input)); | ||
| 90 | } | ||
apps/web/src/state/threads.ts created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | createEnvironmentThreadDetailAtoms, | ||
| 4 | createEnvironmentThreadShellAtoms, | ||
| 5 | createEnvironmentThreadStateAtoms, | ||
| 6 | EMPTY_ENVIRONMENT_THREAD_STATE, | ||
| 7 | type EnvironmentThreadState, | ||
| 8 | createThreadEnvironmentAtoms, | ||
| 9 | } from "@t3tools/client-runtime/state/threads"; | ||
| 10 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 13 | |||
| 14 | import { environmentCatalog } from "../connection/catalog"; | ||
| 15 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 16 | import { environmentSnapshotAtom } from "./shell"; | ||
| 17 | |||
| 18 | export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); | ||
| 19 | export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); | ||
| 20 | export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( | ||
| 21 | environmentThreads.stateAtom, | ||
| 22 | ); | ||
| 23 | export const environmentThreadShells = createEnvironmentThreadShellAtoms({ | ||
| 24 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 25 | snapshotAtom: environmentSnapshotAtom, | ||
| 26 | }); | ||
| 27 | |||
| 28 | const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( | ||
| 29 | Atom.withLabel("web-environment-thread:empty"), | ||
| 30 | ); | ||
| 31 | |||
| 32 | export function useEnvironmentThread( | ||
| 33 | environmentId: EnvironmentId | null, | ||
| 34 | threadId: ThreadId | null, | ||
| 35 | ): EnvironmentThreadState { | ||
| 36 | const result = useAtomValue( | ||
| 37 | environmentId !== null && threadId !== null | ||
| 38 | ? environmentThreads.stateAtom(environmentId, threadId) | ||
| 39 | : EMPTY_THREAD_STATE_ATOM, | ||
| 40 | ); | ||
| 41 | return Option.getOrElse( | ||
| 42 | AsyncResult.value(result), | ||
| 43 | () => EMPTY_ENVIRONMENT_THREAD_STATE, | ||
| 44 | ) as EnvironmentThreadState; | ||
| 45 | } | ||
apps/web/src/state/use-atom-command.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 1 | import { RegistryContext } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type AtomCommand, | ||
| 4 | type AtomCommandOptions, | ||
| 5 | type AtomCommandResult, | ||
| 6 | runAtomCommand, | ||
| 7 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 8 | import { useCallback, useContext } from "react"; | ||
| 9 | |||
| 10 | export function useAtomCommand<A, E, W>( | ||
| 11 | command: AtomCommand<W, A, E>, | ||
| 12 | options?: string | AtomCommandOptions, | ||
| 13 | ): (value: W) => Promise<AtomCommandResult<A, E>> { | ||
| 14 | const registry = useContext(RegistryContext); | ||
| 15 | const label = typeof options === "string" ? options : (options?.label ?? command.label); | ||
| 16 | const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); | ||
| 17 | const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); | ||
| 18 | |||
| 19 | return useCallback( | ||
| 20 | (value: W) => runAtomCommand(registry, command, value, { label, reportFailure, reportDefect }), | ||
| 21 | [command, label, registry, reportDefect, reportFailure], | ||
| 22 | ); | ||
| 23 | } | ||
apps/web/src/state/use-atom-query-runner.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import { RegistryContext } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | executeAtomQuery, | ||
| 4 | type AtomCommandOptions, | ||
| 5 | type AtomCommandResult, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import { AsyncResult, type Atom } from "effect/unstable/reactivity"; | ||
| 8 | import { useCallback, useContext } from "react"; | ||
| 9 | |||
| 10 | export function useAtomQueryRunner<T, A, E>( | ||
| 11 | family: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>, | ||
| 12 | options?: string | AtomCommandOptions, | ||
| 13 | ): (target: T) => Promise<AtomCommandResult<A, E>> { | ||
| 14 | const registry = useContext(RegistryContext); | ||
| 15 | const explicitLabel = typeof options === "string" ? options : options?.label; | ||
| 16 | const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); | ||
| 17 | const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); | ||
| 18 | |||
| 19 | return useCallback( | ||
| 20 | (target: T) => { | ||
| 21 | const atom = family(target); | ||
| 22 | return executeAtomQuery(registry, atom, { | ||
| 23 | label: explicitLabel ?? atom.label?.[0] ?? "atom query", | ||
| 24 | reportFailure, | ||
| 25 | reportDefect, | ||
| 26 | }); | ||
| 27 | }, | ||
| 28 | [explicitLabel, family, registry, reportDefect, reportFailure], | ||
| 29 | ); | ||
| 30 | } | ||
apps/web/src/state/vcs.ts created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | import { | ||
| 2 | createVcsActionManager, | ||
| 3 | createVcsEnvironmentAtoms, | ||
| 4 | } from "@t3tools/client-runtime/state/vcs"; | ||
| 5 | |||
| 6 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 7 | |||
| 8 | export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); | ||
| 9 | export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); | ||
apps/web/src/store.test.ts deleted-1083| ... | @@ -1,1083 +0,0 @@ | ||
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 2 | import { | ||
| 3 | CheckpointRef, | ||
| 4 | DEFAULT_MODEL, | ||
| 5 | EnvironmentId, | ||
| 6 | EventId, | ||
| 7 | MessageId, | ||
| 8 | ProjectId, | ||
| 9 | ProviderInstanceId, | ||
| 10 | ThreadId, | ||
| 11 | TurnId, | ||
| 12 | type OrchestrationEvent, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { describe, expect, it } from "vite-plus/test"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | applyOrchestrationEvent, | ||
| 18 | applyOrchestrationEvents, | ||
| 19 | removeEnvironmentState, | ||
| 20 | selectEnvironmentState, | ||
| 21 | selectProjectsAcrossEnvironments, | ||
| 22 | selectThreadByRef, | ||
| 23 | selectThreadExistsByRef, | ||
| 24 | setThreadBranch, | ||
| 25 | selectThreadsAcrossEnvironments, | ||
| 26 | type AppState, | ||
| 27 | type EnvironmentState, | ||
| 28 | } from "./store"; | ||
| 29 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; | ||
| 30 | |||
| 31 | const localEnvironmentId = EnvironmentId.make("environment-local"); | ||
| 32 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); | ||
| 33 | |||
| 34 | function withActiveEnvironmentState( | ||
| 35 | environmentState: EnvironmentState, | ||
| 36 | overrides: Partial<AppState & EnvironmentState> = {}, | ||
| 37 | ): AppState { | ||
| 38 | const { | ||
| 39 | activeEnvironmentId: overrideActiveEnvironmentId, | ||
| 40 | environmentStateById: overrideEnvironmentStateById, | ||
| 41 | ...environmentOverrides | ||
| 42 | } = overrides; | ||
| 43 | const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId; | ||
| 44 | const mergedEnvironmentState = { | ||
| 45 | ...environmentState, | ||
| 46 | ...environmentOverrides, | ||
| 47 | }; | ||
| 48 | const environmentStateById = | ||
| 49 | overrideEnvironmentStateById ?? | ||
| 50 | (activeEnvironmentId | ||
| 51 | ? { | ||
| 52 | [activeEnvironmentId]: mergedEnvironmentState, | ||
| 53 | } | ||
| 54 | : {}); | ||
| 55 | |||
| 56 | return { | ||
| 57 | activeEnvironmentId, | ||
| 58 | environmentStateById, | ||
| 59 | }; | ||
| 60 | } | ||
| 61 | |||
| 62 | function makeThread(overrides: Partial<Thread> = {}): Thread { | ||
| 63 | return { | ||
| 64 | id: ThreadId.make("thread-1"), | ||
| 65 | environmentId: localEnvironmentId, | ||
| 66 | codexThreadId: null, | ||
| 67 | projectId: ProjectId.make("project-1"), | ||
| 68 | title: "Thread", | ||
| 69 | modelSelection: { | ||
| 70 | instanceId: ProviderInstanceId.make("codex"), | ||
| 71 | model: "gpt-5-codex", | ||
| 72 | }, | ||
| 73 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 74 | interactionMode: DEFAULT_INTERACTION_MODE, | ||
| 75 | session: null, | ||
| 76 | messages: [], | ||
| 77 | turnDiffSummaries: [], | ||
| 78 | activities: [], | ||
| 79 | proposedPlans: [], | ||
| 80 | error: null, | ||
| 81 | createdAt: "2026-02-13T00:00:00.000Z", | ||
| 82 | archivedAt: null, | ||
| 83 | latestTurn: null, | ||
| 84 | branch: null, | ||
| 85 | worktreePath: null, | ||
| 86 | ...overrides, | ||
| 87 | }; | ||
| 88 | } | ||
| 89 | |||
| 90 | function makeState(thread: Thread): AppState { | ||
| 91 | const projectId = ProjectId.make("project-1"); | ||
| 92 | const project = { | ||
| 93 | id: projectId, | ||
| 94 | environmentId: thread.environmentId, | ||
| 95 | name: "Project", | ||
| 96 | cwd: "/tmp/project", | ||
| 97 | defaultModelSelection: { | ||
| 98 | instanceId: ProviderInstanceId.make("codex"), | ||
| 99 | model: "gpt-5-codex", | ||
| 100 | }, | ||
| 101 | createdAt: "2026-02-13T00:00:00.000Z", | ||
| 102 | updatedAt: "2026-02-13T00:00:00.000Z", | ||
| 103 | scripts: [], | ||
| 104 | }; | ||
| 105 | const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = { | ||
| 106 | [thread.projectId]: [thread.id], | ||
| 107 | }; | ||
| 108 | const environmentState = { | ||
| 109 | projectIds: [projectId], | ||
| 110 | projectById: { | ||
| 111 | [projectId]: project, | ||
| 112 | }, | ||
| 113 | threadIds: [thread.id], | ||
| 114 | threadIdsByProjectId, | ||
| 115 | threadShellById: { | ||
| 116 | [thread.id]: { | ||
| 117 | id: thread.id, | ||
| 118 | environmentId: thread.environmentId, | ||
| 119 | codexThreadId: thread.codexThreadId, | ||
| 120 | projectId: thread.projectId, | ||
| 121 | title: thread.title, | ||
| 122 | modelSelection: thread.modelSelection, | ||
| 123 | runtimeMode: thread.runtimeMode, | ||
| 124 | interactionMode: thread.interactionMode, | ||
| 125 | error: thread.error, | ||
| 126 | createdAt: thread.createdAt, | ||
| 127 | archivedAt: thread.archivedAt, | ||
| 128 | updatedAt: thread.updatedAt, | ||
| 129 | branch: thread.branch, | ||
| 130 | worktreePath: thread.worktreePath, | ||
| 131 | }, | ||
| 132 | }, | ||
| 133 | threadSessionById: { | ||
| 134 | [thread.id]: thread.session, | ||
| 135 | }, | ||
| 136 | threadTurnStateById: { | ||
| 137 | [thread.id]: { | ||
| 138 | latestTurn: thread.latestTurn, | ||
| 139 | ...(thread.pendingSourceProposedPlan | ||
| 140 | ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } | ||
| 141 | : {}), | ||
| 142 | }, | ||
| 143 | }, | ||
| 144 | messageIdsByThreadId: { | ||
| 145 | [thread.id]: thread.messages.map((message) => message.id), | ||
| 146 | }, | ||
| 147 | messageByThreadId: { | ||
| 148 | [thread.id]: Object.fromEntries( | ||
| 149 | thread.messages.map((message) => [message.id, message] as const), | ||
| 150 | ) as EnvironmentState["messageByThreadId"][ThreadId], | ||
| 151 | }, | ||
| 152 | activityIdsByThreadId: { | ||
| 153 | [thread.id]: thread.activities.map((activity) => activity.id), | ||
| 154 | }, | ||
| 155 | activityByThreadId: { | ||
| 156 | [thread.id]: Object.fromEntries( | ||
| 157 | thread.activities.map((activity) => [activity.id, activity] as const), | ||
| 158 | ) as EnvironmentState["activityByThreadId"][ThreadId], | ||
| 159 | }, | ||
| 160 | proposedPlanIdsByThreadId: { | ||
| 161 | [thread.id]: thread.proposedPlans.map((plan) => plan.id), | ||
| 162 | }, | ||
| 163 | proposedPlanByThreadId: { | ||
| 164 | [thread.id]: Object.fromEntries( | ||
| 165 | thread.proposedPlans.map((plan) => [plan.id, plan] as const), | ||
| 166 | ) as EnvironmentState["proposedPlanByThreadId"][ThreadId], | ||
| 167 | }, | ||
| 168 | turnDiffIdsByThreadId: { | ||
| 169 | [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), | ||
| 170 | }, | ||
| 171 | turnDiffSummaryByThreadId: { | ||
| 172 | [thread.id]: Object.fromEntries( | ||
| 173 | thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), | ||
| 174 | ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId], | ||
| 175 | }, | ||
| 176 | sidebarThreadSummaryById: {}, | ||
| 177 | bootstrapComplete: true, | ||
| 178 | }; | ||
| 179 | return withActiveEnvironmentState(environmentState, { | ||
| 180 | activeEnvironmentId: thread.environmentId, | ||
| 181 | }); | ||
| 182 | } | ||
| 183 | |||
| 184 | function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState { | ||
| 185 | const environmentState: EnvironmentState = { | ||
| 186 | projectIds: [], | ||
| 187 | projectById: {}, | ||
| 188 | threadIds: [], | ||
| 189 | threadIdsByProjectId: {}, | ||
| 190 | threadShellById: {}, | ||
| 191 | threadSessionById: {}, | ||
| 192 | threadTurnStateById: {}, | ||
| 193 | messageIdsByThreadId: {}, | ||
| 194 | messageByThreadId: {}, | ||
| 195 | activityIdsByThreadId: {}, | ||
| 196 | activityByThreadId: {}, | ||
| 197 | proposedPlanIdsByThreadId: {}, | ||
| 198 | proposedPlanByThreadId: {}, | ||
| 199 | turnDiffIdsByThreadId: {}, | ||
| 200 | turnDiffSummaryByThreadId: {}, | ||
| 201 | sidebarThreadSummaryById: {}, | ||
| 202 | bootstrapComplete: true, | ||
| 203 | }; | ||
| 204 | return withActiveEnvironmentState(environmentState, overrides); | ||
| 205 | } | ||
| 206 | |||
| 207 | function localEnvironmentStateOf(state: AppState): EnvironmentState { | ||
| 208 | return selectEnvironmentState(state, localEnvironmentId); | ||
| 209 | } | ||
| 210 | |||
| 211 | function environmentStateOf(state: AppState, environmentId: EnvironmentId): EnvironmentState { | ||
| 212 | return selectEnvironmentState(state, environmentId); | ||
| 213 | } | ||
| 214 | |||
| 215 | function projectsOf(state: AppState) { | ||
| 216 | return selectProjectsAcrossEnvironments(state); | ||
| 217 | } | ||
| 218 | |||
| 219 | function threadsOf(state: AppState) { | ||
| 220 | return selectThreadsAcrossEnvironments(state); | ||
| 221 | } | ||
| 222 | |||
| 223 | function makeEvent<T extends OrchestrationEvent["type"]>( | ||
| 224 | type: T, | ||
| 225 | payload: Extract<OrchestrationEvent, { type: T }>["payload"], | ||
| 226 | overrides: Partial<Extract<OrchestrationEvent, { type: T }>> = {}, | ||
| 227 | ): Extract<OrchestrationEvent, { type: T }> { | ||
| 228 | const sequence = overrides.sequence ?? 1; | ||
| 229 | return { | ||
| 230 | sequence, | ||
| 231 | eventId: EventId.make(`event-${sequence}`), | ||
| 232 | aggregateKind: "thread", | ||
| 233 | aggregateId: | ||
| 234 | "threadId" in payload | ||
| 235 | ? payload.threadId | ||
| 236 | : "projectId" in payload | ||
| 237 | ? payload.projectId | ||
| 238 | : ProjectId.make("project-1"), | ||
| 239 | occurredAt: "2026-02-27T00:00:00.000Z", | ||
| 240 | commandId: null, | ||
| 241 | causationEventId: null, | ||
| 242 | correlationId: null, | ||
| 243 | metadata: {}, | ||
| 244 | type, | ||
| 245 | payload, | ||
| 246 | ...overrides, | ||
| 247 | } as Extract<OrchestrationEvent, { type: T }>; | ||
| 248 | } | ||
| 249 | |||
| 250 | describe("environment state removal", () => { | ||
| 251 | it("drops local state for removed environments", () => { | ||
| 252 | const removedThread = makeThread({ | ||
| 253 | environmentId: remoteEnvironmentId, | ||
| 254 | id: ThreadId.make("thread-removed"), | ||
| 255 | }); | ||
| 256 | const keptThread = makeThread({ id: ThreadId.make("thread-kept") }); | ||
| 257 | const removedState = makeState(removedThread).environmentStateById[remoteEnvironmentId]!; | ||
| 258 | const keptState = makeState(keptThread).environmentStateById[localEnvironmentId]!; | ||
| 259 | const state: AppState = { | ||
| 260 | activeEnvironmentId: remoteEnvironmentId, | ||
| 261 | environmentStateById: { | ||
| 262 | [remoteEnvironmentId]: removedState, | ||
| 263 | [localEnvironmentId]: keptState, | ||
| 264 | }, | ||
| 265 | }; | ||
| 266 | |||
| 267 | const next = removeEnvironmentState(state, remoteEnvironmentId); | ||
| 268 | |||
| 269 | expect(next.activeEnvironmentId).toBeNull(); | ||
| 270 | expect(next.environmentStateById[remoteEnvironmentId]).toBeUndefined(); | ||
| 271 | expect(next.environmentStateById[localEnvironmentId]).toBe(keptState); | ||
| 272 | }); | ||
| 273 | |||
| 274 | it("preserves active environment when removing a different environment", () => { | ||
| 275 | const state = makeState(makeThread()); | ||
| 276 | |||
| 277 | const next = removeEnvironmentState(state, remoteEnvironmentId); | ||
| 278 | |||
| 279 | expect(next).toBe(state); | ||
| 280 | }); | ||
| 281 | }); | ||
| 282 | |||
| 283 | describe("thread selection memoization", () => { | ||
| 284 | it("returns stable thread references for repeated reads of the same state", () => { | ||
| 285 | const thread = makeThread({ | ||
| 286 | messages: [ | ||
| 287 | { | ||
| 288 | id: MessageId.make("message-1"), | ||
| 289 | role: "user", | ||
| 290 | text: "hello", | ||
| 291 | createdAt: "2026-02-13T00:01:00.000Z", | ||
| 292 | streaming: false, | ||
| 293 | }, | ||
| 294 | ], | ||
| 295 | activities: [ | ||
| 296 | { | ||
| 297 | id: EventId.make("activity-1"), | ||
| 298 | tone: "info", | ||
| 299 | kind: "step", | ||
| 300 | summary: "working", | ||
| 301 | payload: {}, | ||
| 302 | turnId: TurnId.make("turn-1"), | ||
| 303 | createdAt: "2026-02-13T00:01:30.000Z", | ||
| 304 | }, | ||
| 305 | ], | ||
| 306 | proposedPlans: [ | ||
| 307 | { | ||
| 308 | id: "plan-1", | ||
| 309 | turnId: null, | ||
| 310 | planMarkdown: "plan", | ||
| 311 | implementedAt: null, | ||
| 312 | implementationThreadId: null, | ||
| 313 | createdAt: "2026-02-13T00:02:00.000Z", | ||
| 314 | updatedAt: "2026-02-13T00:02:00.000Z", | ||
| 315 | }, | ||
| 316 | ], | ||
| 317 | turnDiffSummaries: [ | ||
| 318 | { | ||
| 319 | turnId: TurnId.make("turn-1"), | ||
| 320 | completedAt: "2026-02-13T00:03:00.000Z", | ||
| 321 | files: [], | ||
| 322 | }, | ||
| 323 | ], | ||
| 324 | }); | ||
| 325 | const state = makeState(thread); | ||
| 326 | const ref = scopeThreadRef(thread.environmentId, thread.id); | ||
| 327 | |||
| 328 | const first = selectThreadByRef(state, ref); | ||
| 329 | const second = selectThreadByRef(state, ref); | ||
| 330 | |||
| 331 | expect(first).toBeDefined(); | ||
| 332 | expect(second).toBe(first); | ||
| 333 | expect(second?.messages).toBe(first?.messages); | ||
| 334 | expect(second?.activities).toBe(first?.activities); | ||
| 335 | expect(second?.proposedPlans).toBe(first?.proposedPlans); | ||
| 336 | expect(second?.turnDiffSummaries).toBe(first?.turnDiffSummaries); | ||
| 337 | }); | ||
| 338 | |||
| 339 | it("reuses the derived thread when the app state wrapper changes but thread data does not", () => { | ||
| 340 | const thread = makeThread({ | ||
| 341 | messages: [ | ||
| 342 | { | ||
| 343 | id: MessageId.make("message-1"), | ||
| 344 | role: "assistant", | ||
| 345 | text: "done", | ||
| 346 | createdAt: "2026-02-13T00:01:00.000Z", | ||
| 347 | streaming: false, | ||
| 348 | }, | ||
| 349 | ], | ||
| 350 | }); | ||
| 351 | const state = makeState(thread); | ||
| 352 | const ref = scopeThreadRef(thread.environmentId, thread.id); | ||
| 353 | const wrappedState: AppState = { | ||
| 354 | ...state, | ||
| 355 | environmentStateById: { ...state.environmentStateById }, | ||
| 356 | }; | ||
| 357 | |||
| 358 | const first = selectThreadByRef(state, ref); | ||
| 359 | const second = selectThreadByRef(wrappedState, ref); | ||
| 360 | |||
| 361 | expect(second).toBe(first); | ||
| 362 | }); | ||
| 363 | |||
| 364 | it("updates the derived thread when the underlying thread data changes", () => { | ||
| 365 | const thread = makeThread(); | ||
| 366 | const ref = scopeThreadRef(thread.environmentId, thread.id); | ||
| 367 | const firstState = makeState(thread); | ||
| 368 | const secondState = makeState({ | ||
| 369 | ...thread, | ||
| 370 | messages: [ | ||
| 371 | { | ||
| 372 | id: MessageId.make("message-2"), | ||
| 373 | role: "user", | ||
| 374 | text: "new", | ||
| 375 | createdAt: "2026-02-13T00:04:00.000Z", | ||
| 376 | streaming: false, | ||
| 377 | }, | ||
| 378 | ], | ||
| 379 | }); | ||
| 380 | |||
| 381 | const first = selectThreadByRef(firstState, ref); | ||
| 382 | const second = selectThreadByRef(secondState, ref); | ||
| 383 | |||
| 384 | expect(second).not.toBe(first); | ||
| 385 | expect(second?.messages).toHaveLength(1); | ||
| 386 | expect(second?.messages[0]?.text).toBe("new"); | ||
| 387 | }); | ||
| 388 | |||
| 389 | it("checks thread existence without materializing the full thread", () => { | ||
| 390 | const thread = makeThread(); | ||
| 391 | const state = makeState(thread); | ||
| 392 | const ref = scopeThreadRef(thread.environmentId, thread.id); | ||
| 393 | |||
| 394 | expect(selectThreadExistsByRef(state, ref)).toBe(true); | ||
| 395 | expect( | ||
| 396 | selectThreadExistsByRef( | ||
| 397 | state, | ||
| 398 | scopeThreadRef(thread.environmentId, ThreadId.make("missing")), | ||
| 399 | ), | ||
| 400 | ).toBe(false); | ||
| 401 | expect(selectThreadExistsByRef(state, null)).toBe(false); | ||
| 402 | }); | ||
| 403 | }); | ||
| 404 | |||
| 405 | describe("setThreadBranch", () => { | ||
| 406 | it("updates only the scoped thread environment", () => { | ||
| 407 | const sharedThreadId = ThreadId.make("thread-shared"); | ||
| 408 | const localThread = makeThread({ | ||
| 409 | id: sharedThreadId, | ||
| 410 | environmentId: localEnvironmentId, | ||
| 411 | branch: "local-branch", | ||
| 412 | }); | ||
| 413 | const remoteThread = makeThread({ | ||
| 414 | id: sharedThreadId, | ||
| 415 | environmentId: remoteEnvironmentId, | ||
| 416 | branch: "remote-branch", | ||
| 417 | }); | ||
| 418 | const state: AppState = { | ||
| 419 | activeEnvironmentId: localEnvironmentId, | ||
| 420 | environmentStateById: { | ||
| 421 | [localEnvironmentId]: environmentStateOf(makeState(localThread), localEnvironmentId), | ||
| 422 | [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId), | ||
| 423 | }, | ||
| 424 | }; | ||
| 425 | |||
| 426 | const next = setThreadBranch( | ||
| 427 | state, | ||
| 428 | scopeThreadRef(remoteEnvironmentId, sharedThreadId), | ||
| 429 | "remote-next", | ||
| 430 | "/tmp/remote-worktree", | ||
| 431 | ); | ||
| 432 | |||
| 433 | expect( | ||
| 434 | environmentStateOf(next, localEnvironmentId).threadShellById[sharedThreadId]?.branch, | ||
| 435 | ).toBe("local-branch"); | ||
| 436 | expect( | ||
| 437 | environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.branch, | ||
| 438 | ).toBe("remote-next"); | ||
| 439 | expect( | ||
| 440 | environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.worktreePath, | ||
| 441 | ).toBe("/tmp/remote-worktree"); | ||
| 442 | }); | ||
| 443 | }); | ||
| 444 | |||
| 445 | describe("incremental orchestration updates", () => { | ||
| 446 | it("does not mark bootstrap complete for incremental events", () => { | ||
| 447 | const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), { | ||
| 448 | bootstrapComplete: false, | ||
| 449 | }); | ||
| 450 | |||
| 451 | const next = applyOrchestrationEvent( | ||
| 452 | state, | ||
| 453 | makeEvent("thread.meta-updated", { | ||
| 454 | threadId: ThreadId.make("thread-1"), | ||
| 455 | title: "Updated title", | ||
| 456 | updatedAt: "2026-02-27T00:00:01.000Z", | ||
| 457 | }), | ||
| 458 | localEnvironmentId, | ||
| 459 | ); | ||
| 460 | |||
| 461 | expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(false); | ||
| 462 | }); | ||
| 463 | |||
| 464 | it("preserves state identity for no-op project and thread deletes", () => { | ||
| 465 | const thread = makeThread(); | ||
| 466 | const state = makeState(thread); | ||
| 467 | |||
| 468 | const nextAfterProjectDelete = applyOrchestrationEvent( | ||
| 469 | state, | ||
| 470 | makeEvent("project.deleted", { | ||
| 471 | projectId: ProjectId.make("project-missing"), | ||
| 472 | deletedAt: "2026-02-27T00:00:01.000Z", | ||
| 473 | }), | ||
| 474 | localEnvironmentId, | ||
| 475 | ); | ||
| 476 | const nextAfterThreadDelete = applyOrchestrationEvent( | ||
| 477 | state, | ||
| 478 | makeEvent("thread.deleted", { | ||
| 479 | threadId: ThreadId.make("thread-missing"), | ||
| 480 | deletedAt: "2026-02-27T00:00:01.000Z", | ||
| 481 | }), | ||
| 482 | localEnvironmentId, | ||
| 483 | ); | ||
| 484 | |||
| 485 | expect(nextAfterProjectDelete).toBe(state); | ||
| 486 | expect(nextAfterThreadDelete).toBe(state); | ||
| 487 | }); | ||
| 488 | |||
| 489 | it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { | ||
| 490 | const originalProjectId = ProjectId.make("project-1"); | ||
| 491 | const recreatedProjectId = ProjectId.make("project-2"); | ||
| 492 | const state: AppState = makeEmptyState({ | ||
| 493 | projectIds: [originalProjectId], | ||
| 494 | projectById: { | ||
| 495 | [originalProjectId]: { | ||
| 496 | id: originalProjectId, | ||
| 497 | environmentId: localEnvironmentId, | ||
| 498 | name: "Project", | ||
| 499 | cwd: "/tmp/project", | ||
| 500 | defaultModelSelection: { | ||
| 501 | instanceId: ProviderInstanceId.make("codex"), | ||
| 502 | model: DEFAULT_MODEL, | ||
| 503 | }, | ||
| 504 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 505 | updatedAt: "2026-02-27T00:00:00.000Z", | ||
| 506 | scripts: [], | ||
| 507 | }, | ||
| 508 | }, | ||
| 509 | }); | ||
| 510 | |||
| 511 | const next = applyOrchestrationEvent( | ||
| 512 | state, | ||
| 513 | makeEvent("project.created", { | ||
| 514 | projectId: recreatedProjectId, | ||
| 515 | title: "Project Recreated", | ||
| 516 | workspaceRoot: "/tmp/project", | ||
| 517 | defaultModelSelection: { | ||
| 518 | instanceId: ProviderInstanceId.make("codex"), | ||
| 519 | model: DEFAULT_MODEL, | ||
| 520 | }, | ||
| 521 | scripts: [], | ||
| 522 | createdAt: "2026-02-27T00:00:01.000Z", | ||
| 523 | updatedAt: "2026-02-27T00:00:01.000Z", | ||
| 524 | }), | ||
| 525 | localEnvironmentId, | ||
| 526 | ); | ||
| 527 | |||
| 528 | expect(projectsOf(next)).toHaveLength(1); | ||
| 529 | expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); | ||
| 530 | expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); | ||
| 531 | expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); | ||
| 532 | expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]); | ||
| 533 | expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined(); | ||
| 534 | expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe( | ||
| 535 | recreatedProjectId, | ||
| 536 | ); | ||
| 537 | }); | ||
| 538 | |||
| 539 | it("removes stale project index entries when thread.created recreates a thread under a new project", () => { | ||
| 540 | const originalProjectId = ProjectId.make("project-1"); | ||
| 541 | const recreatedProjectId = ProjectId.make("project-2"); | ||
| 542 | const threadId = ThreadId.make("thread-1"); | ||
| 543 | const thread = makeThread({ | ||
| 544 | id: threadId, | ||
| 545 | projectId: originalProjectId, | ||
| 546 | }); | ||
| 547 | const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), { | ||
| 548 | projectIds: [originalProjectId, recreatedProjectId], | ||
| 549 | projectById: { | ||
| 550 | [originalProjectId]: { | ||
| 551 | id: originalProjectId, | ||
| 552 | environmentId: localEnvironmentId, | ||
| 553 | name: "Project 1", | ||
| 554 | cwd: "/tmp/project-1", | ||
| 555 | defaultModelSelection: { | ||
| 556 | instanceId: ProviderInstanceId.make("codex"), | ||
| 557 | model: DEFAULT_MODEL, | ||
| 558 | }, | ||
| 559 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 560 | updatedAt: "2026-02-27T00:00:00.000Z", | ||
| 561 | scripts: [], | ||
| 562 | }, | ||
| 563 | [recreatedProjectId]: { | ||
| 564 | id: recreatedProjectId, | ||
| 565 | environmentId: localEnvironmentId, | ||
| 566 | name: "Project 2", | ||
| 567 | cwd: "/tmp/project-2", | ||
| 568 | defaultModelSelection: { | ||
| 569 | instanceId: ProviderInstanceId.make("codex"), | ||
| 570 | model: DEFAULT_MODEL, | ||
| 571 | }, | ||
| 572 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 573 | updatedAt: "2026-02-27T00:00:00.000Z", | ||
| 574 | scripts: [], | ||
| 575 | }, | ||
| 576 | }, | ||
| 577 | }); | ||
| 578 | |||
| 579 | const next = applyOrchestrationEvent( | ||
| 580 | state, | ||
| 581 | makeEvent("thread.created", { | ||
| 582 | threadId, | ||
| 583 | projectId: recreatedProjectId, | ||
| 584 | title: "Recovered thread", | ||
| 585 | modelSelection: { | ||
| 586 | instanceId: ProviderInstanceId.make("codex"), | ||
| 587 | model: DEFAULT_MODEL, | ||
| 588 | }, | ||
| 589 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 590 | interactionMode: DEFAULT_INTERACTION_MODE, | ||
| 591 | branch: null, | ||
| 592 | worktreePath: null, | ||
| 593 | createdAt: "2026-02-27T00:00:01.000Z", | ||
| 594 | updatedAt: "2026-02-27T00:00:01.000Z", | ||
| 595 | }), | ||
| 596 | localEnvironmentId, | ||
| 597 | ); | ||
| 598 | |||
| 599 | expect(threadsOf(next)).toHaveLength(1); | ||
| 600 | expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); | ||
| 601 | expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined(); | ||
| 602 | expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([ | ||
| 603 | threadId, | ||
| 604 | ]); | ||
| 605 | }); | ||
| 606 | |||
| 607 | it("updates only the affected thread for message events", () => { | ||
| 608 | const thread1 = makeThread({ | ||
| 609 | id: ThreadId.make("thread-1"), | ||
| 610 | messages: [ | ||
| 611 | { | ||
| 612 | id: MessageId.make("message-1"), | ||
| 613 | role: "assistant", | ||
| 614 | text: "hello", | ||
| 615 | turnId: TurnId.make("turn-1"), | ||
| 616 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 617 | completedAt: "2026-02-27T00:00:00.000Z", | ||
| 618 | streaming: false, | ||
| 619 | }, | ||
| 620 | ], | ||
| 621 | }); | ||
| 622 | const thread2 = makeThread({ id: ThreadId.make("thread-2") }); | ||
| 623 | const baseState = makeState(thread1); | ||
| 624 | const baseEnvironmentState = localEnvironmentStateOf(baseState); | ||
| 625 | const state = withActiveEnvironmentState(baseEnvironmentState, { | ||
| 626 | threadIds: [thread1.id, thread2.id], | ||
| 627 | threadShellById: { | ||
| 628 | ...baseEnvironmentState.threadShellById, | ||
| 629 | [thread2.id]: { | ||
| 630 | id: thread2.id, | ||
| 631 | environmentId: thread2.environmentId, | ||
| 632 | codexThreadId: thread2.codexThreadId, | ||
| 633 | projectId: thread2.projectId, | ||
| 634 | title: thread2.title, | ||
| 635 | modelSelection: thread2.modelSelection, | ||
| 636 | runtimeMode: thread2.runtimeMode, | ||
| 637 | interactionMode: thread2.interactionMode, | ||
| 638 | error: thread2.error, | ||
| 639 | createdAt: thread2.createdAt, | ||
| 640 | archivedAt: thread2.archivedAt, | ||
| 641 | updatedAt: thread2.updatedAt, | ||
| 642 | branch: thread2.branch, | ||
| 643 | worktreePath: thread2.worktreePath, | ||
| 644 | }, | ||
| 645 | }, | ||
| 646 | threadSessionById: { | ||
| 647 | ...baseEnvironmentState.threadSessionById, | ||
| 648 | [thread2.id]: thread2.session, | ||
| 649 | }, | ||
| 650 | threadTurnStateById: { | ||
| 651 | ...baseEnvironmentState.threadTurnStateById, | ||
| 652 | [thread2.id]: { | ||
| 653 | latestTurn: thread2.latestTurn, | ||
| 654 | }, | ||
| 655 | }, | ||
| 656 | messageIdsByThreadId: { | ||
| 657 | ...baseEnvironmentState.messageIdsByThreadId, | ||
| 658 | [thread2.id]: [], | ||
| 659 | }, | ||
| 660 | messageByThreadId: { | ||
| 661 | ...baseEnvironmentState.messageByThreadId, | ||
| 662 | [thread2.id]: {}, | ||
| 663 | }, | ||
| 664 | activityIdsByThreadId: { | ||
| 665 | ...baseEnvironmentState.activityIdsByThreadId, | ||
| 666 | [thread2.id]: [], | ||
| 667 | }, | ||
| 668 | activityByThreadId: { | ||
| 669 | ...baseEnvironmentState.activityByThreadId, | ||
| 670 | [thread2.id]: {}, | ||
| 671 | }, | ||
| 672 | proposedPlanIdsByThreadId: { | ||
| 673 | ...baseEnvironmentState.proposedPlanIdsByThreadId, | ||
| 674 | [thread2.id]: [], | ||
| 675 | }, | ||
| 676 | proposedPlanByThreadId: { | ||
| 677 | ...baseEnvironmentState.proposedPlanByThreadId, | ||
| 678 | [thread2.id]: {}, | ||
| 679 | }, | ||
| 680 | turnDiffIdsByThreadId: { | ||
| 681 | ...baseEnvironmentState.turnDiffIdsByThreadId, | ||
| 682 | [thread2.id]: [], | ||
| 683 | }, | ||
| 684 | turnDiffSummaryByThreadId: { | ||
| 685 | ...baseEnvironmentState.turnDiffSummaryByThreadId, | ||
| 686 | [thread2.id]: {}, | ||
| 687 | }, | ||
| 688 | sidebarThreadSummaryById: { | ||
| 689 | ...baseEnvironmentState.sidebarThreadSummaryById, | ||
| 690 | }, | ||
| 691 | threadIdsByProjectId: { | ||
| 692 | [thread1.projectId]: [thread1.id, thread2.id], | ||
| 693 | }, | ||
| 694 | }); | ||
| 695 | |||
| 696 | const next = applyOrchestrationEvent( | ||
| 697 | state, | ||
| 698 | makeEvent("thread.message-sent", { | ||
| 699 | threadId: thread1.id, | ||
| 700 | messageId: MessageId.make("message-1"), | ||
| 701 | role: "assistant", | ||
| 702 | text: " world", | ||
| 703 | turnId: TurnId.make("turn-1"), | ||
| 704 | streaming: true, | ||
| 705 | createdAt: "2026-02-27T00:00:01.000Z", | ||
| 706 | updatedAt: "2026-02-27T00:00:01.000Z", | ||
| 707 | }), | ||
| 708 | localEnvironmentId, | ||
| 709 | ); | ||
| 710 | |||
| 711 | expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); | ||
| 712 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); | ||
| 713 | const nextEnvironmentState = next.environmentStateById[localEnvironmentId]; | ||
| 714 | const previousEnvironmentState = state.environmentStateById[localEnvironmentId]; | ||
| 715 | expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe( | ||
| 716 | previousEnvironmentState?.threadShellById[thread2.id], | ||
| 717 | ); | ||
| 718 | expect(nextEnvironmentState?.threadSessionById[thread2.id]).toBe( | ||
| 719 | previousEnvironmentState?.threadSessionById[thread2.id], | ||
| 720 | ); | ||
| 721 | expect(nextEnvironmentState?.messageIdsByThreadId[thread2.id]).toBe( | ||
| 722 | previousEnvironmentState?.messageIdsByThreadId[thread2.id], | ||
| 723 | ); | ||
| 724 | expect(nextEnvironmentState?.messageByThreadId[thread2.id]).toBe( | ||
| 725 | previousEnvironmentState?.messageByThreadId[thread2.id], | ||
| 726 | ); | ||
| 727 | }); | ||
| 728 | |||
| 729 | it("applies replay batches in sequence and updates session state", () => { | ||
| 730 | const thread = makeThread({ | ||
| 731 | latestTurn: { | ||
| 732 | turnId: TurnId.make("turn-1"), | ||
| 733 | state: "running", | ||
| 734 | requestedAt: "2026-02-27T00:00:00.000Z", | ||
| 735 | startedAt: "2026-02-27T00:00:00.000Z", | ||
| 736 | completedAt: null, | ||
| 737 | assistantMessageId: null, | ||
| 738 | }, | ||
| 739 | }); | ||
| 740 | const state = makeState(thread); | ||
| 741 | |||
| 742 | const next = applyOrchestrationEvents( | ||
| 743 | state, | ||
| 744 | [ | ||
| 745 | makeEvent( | ||
| 746 | "thread.session-set", | ||
| 747 | { | ||
| 748 | threadId: thread.id, | ||
| 749 | session: { | ||
| 750 | threadId: thread.id, | ||
| 751 | status: "running", | ||
| 752 | providerName: "codex", | ||
| 753 | runtimeMode: "full-access", | ||
| 754 | activeTurnId: TurnId.make("turn-1"), | ||
| 755 | lastError: null, | ||
| 756 | updatedAt: "2026-02-27T00:00:02.000Z", | ||
| 757 | }, | ||
| 758 | }, | ||
| 759 | { sequence: 2 }, | ||
| 760 | ), | ||
| 761 | makeEvent( | ||
| 762 | "thread.message-sent", | ||
| 763 | { | ||
| 764 | threadId: thread.id, | ||
| 765 | messageId: MessageId.make("assistant-1"), | ||
| 766 | role: "assistant", | ||
| 767 | text: "done", | ||
| 768 | turnId: TurnId.make("turn-1"), | ||
| 769 | streaming: false, | ||
| 770 | createdAt: "2026-02-27T00:00:03.000Z", | ||
| 771 | updatedAt: "2026-02-27T00:00:03.000Z", | ||
| 772 | }, | ||
| 773 | { sequence: 3 }, | ||
| 774 | ), | ||
| 775 | ], | ||
| 776 | localEnvironmentId, | ||
| 777 | ); | ||
| 778 | |||
| 779 | // A completed assistant message must not settle the turn while the | ||
| 780 | // session is still running it — providers emit interim assistant | ||
| 781 | // messages between tool calls. | ||
| 782 | expect(threadsOf(next)[0]?.session?.status).toBe("running"); | ||
| 783 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); | ||
| 784 | expect(threadsOf(next)[0]?.latestTurn?.completedAt).toBeNull(); | ||
| 785 | expect(threadsOf(next)[0]?.messages).toHaveLength(1); | ||
| 786 | |||
| 787 | const settled = applyOrchestrationEvents( | ||
| 788 | next, | ||
| 789 | [ | ||
| 790 | makeEvent( | ||
| 791 | "thread.session-set", | ||
| 792 | { | ||
| 793 | threadId: thread.id, | ||
| 794 | session: { | ||
| 795 | threadId: thread.id, | ||
| 796 | status: "ready", | ||
| 797 | providerName: "codex", | ||
| 798 | runtimeMode: "full-access", | ||
| 799 | activeTurnId: null, | ||
| 800 | lastError: null, | ||
| 801 | updatedAt: "2026-02-27T00:00:04.000Z", | ||
| 802 | }, | ||
| 803 | }, | ||
| 804 | { sequence: 4 }, | ||
| 805 | ), | ||
| 806 | ], | ||
| 807 | localEnvironmentId, | ||
| 808 | ); | ||
| 809 | |||
| 810 | // Leaving the running session status is the turn-end signal. | ||
| 811 | expect(threadsOf(settled)[0]?.latestTurn?.state).toBe("completed"); | ||
| 812 | expect(threadsOf(settled)[0]?.latestTurn?.completedAt).toBe("2026-02-27T00:00:04.000Z"); | ||
| 813 | }); | ||
| 814 | |||
| 815 | it("does not regress latestTurn when an older turn diff completes late", () => { | ||
| 816 | const state = makeState( | ||
| 817 | makeThread({ | ||
| 818 | latestTurn: { | ||
| 819 | turnId: TurnId.make("turn-2"), | ||
| 820 | state: "running", | ||
| 821 | requestedAt: "2026-02-27T00:00:02.000Z", | ||
| 822 | startedAt: "2026-02-27T00:00:03.000Z", | ||
| 823 | completedAt: null, | ||
| 824 | assistantMessageId: null, | ||
| 825 | }, | ||
| 826 | }), | ||
| 827 | ); | ||
| 828 | |||
| 829 | const next = applyOrchestrationEvent( | ||
| 830 | state, | ||
| 831 | makeEvent("thread.turn-diff-completed", { | ||
| 832 | threadId: ThreadId.make("thread-1"), | ||
| 833 | turnId: TurnId.make("turn-1"), | ||
| 834 | checkpointTurnCount: 1, | ||
| 835 | checkpointRef: CheckpointRef.make("checkpoint-1"), | ||
| 836 | status: "ready", | ||
| 837 | files: [], | ||
| 838 | assistantMessageId: MessageId.make("assistant-1"), | ||
| 839 | completedAt: "2026-02-27T00:00:04.000Z", | ||
| 840 | }), | ||
| 841 | localEnvironmentId, | ||
| 842 | ); | ||
| 843 | |||
| 844 | expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); | ||
| 845 | expect(threadsOf(next)[0]?.latestTurn).toEqual(threadsOf(state)[0]?.latestTurn); | ||
| 846 | }); | ||
| 847 | |||
| 848 | it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { | ||
| 849 | const turnId = TurnId.make("turn-1"); | ||
| 850 | const state = makeState( | ||
| 851 | makeThread({ | ||
| 852 | latestTurn: { | ||
| 853 | turnId, | ||
| 854 | state: "completed", | ||
| 855 | requestedAt: "2026-02-27T00:00:00.000Z", | ||
| 856 | startedAt: "2026-02-27T00:00:00.000Z", | ||
| 857 | completedAt: "2026-02-27T00:00:02.000Z", | ||
| 858 | assistantMessageId: MessageId.make("assistant:turn-1"), | ||
| 859 | }, | ||
| 860 | turnDiffSummaries: [ | ||
| 861 | { | ||
| 862 | turnId, | ||
| 863 | completedAt: "2026-02-27T00:00:02.000Z", | ||
| 864 | status: "ready", | ||
| 865 | checkpointTurnCount: 1, | ||
| 866 | checkpointRef: CheckpointRef.make("checkpoint-1"), | ||
| 867 | assistantMessageId: MessageId.make("assistant:turn-1"), | ||
| 868 | files: [{ path: "src/app.ts", additions: 1, deletions: 0 }], | ||
| 869 | }, | ||
| 870 | ], | ||
| 871 | }), | ||
| 872 | ); | ||
| 873 | |||
| 874 | const next = applyOrchestrationEvent( | ||
| 875 | state, | ||
| 876 | makeEvent("thread.message-sent", { | ||
| 877 | threadId: ThreadId.make("thread-1"), | ||
| 878 | messageId: MessageId.make("assistant-real"), | ||
| 879 | role: "assistant", | ||
| 880 | text: "final answer", | ||
| 881 | turnId, | ||
| 882 | streaming: false, | ||
| 883 | createdAt: "2026-02-27T00:00:03.000Z", | ||
| 884 | updatedAt: "2026-02-27T00:00:03.000Z", | ||
| 885 | }), | ||
| 886 | localEnvironmentId, | ||
| 887 | ); | ||
| 888 | |||
| 889 | expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( | ||
| 890 | MessageId.make("assistant-real"), | ||
| 891 | ); | ||
| 892 | expect(threadsOf(next)[0]?.latestTurn?.assistantMessageId).toBe( | ||
| 893 | MessageId.make("assistant-real"), | ||
| 894 | ); | ||
| 895 | }); | ||
| 896 | |||
| 897 | it("reverts messages, plans, activities, and checkpoints by retained turns", () => { | ||
| 898 | const state = makeState( | ||
| 899 | makeThread({ | ||
| 900 | messages: [ | ||
| 901 | { | ||
| 902 | id: MessageId.make("user-1"), | ||
| 903 | role: "user", | ||
| 904 | text: "first", | ||
| 905 | turnId: TurnId.make("turn-1"), | ||
| 906 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 907 | completedAt: "2026-02-27T00:00:00.000Z", | ||
| 908 | streaming: false, | ||
| 909 | }, | ||
| 910 | { | ||
| 911 | id: MessageId.make("assistant-1"), | ||
| 912 | role: "assistant", | ||
| 913 | text: "first reply", | ||
| 914 | turnId: TurnId.make("turn-1"), | ||
| 915 | createdAt: "2026-02-27T00:00:01.000Z", | ||
| 916 | completedAt: "2026-02-27T00:00:01.000Z", | ||
| 917 | streaming: false, | ||
| 918 | }, | ||
| 919 | { | ||
| 920 | id: MessageId.make("user-2"), | ||
| 921 | role: "user", | ||
| 922 | text: "second", | ||
| 923 | turnId: TurnId.make("turn-2"), | ||
| 924 | createdAt: "2026-02-27T00:00:02.000Z", | ||
| 925 | completedAt: "2026-02-27T00:00:02.000Z", | ||
| 926 | streaming: false, | ||
| 927 | }, | ||
| 928 | ], | ||
| 929 | proposedPlans: [ | ||
| 930 | { | ||
| 931 | id: "plan-1", | ||
| 932 | turnId: TurnId.make("turn-1"), | ||
| 933 | planMarkdown: "plan 1", | ||
| 934 | implementedAt: null, | ||
| 935 | implementationThreadId: null, | ||
| 936 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 937 | updatedAt: "2026-02-27T00:00:00.000Z", | ||
| 938 | }, | ||
| 939 | { | ||
| 940 | id: "plan-2", | ||
| 941 | turnId: TurnId.make("turn-2"), | ||
| 942 | planMarkdown: "plan 2", | ||
| 943 | implementedAt: null, | ||
| 944 | implementationThreadId: null, | ||
| 945 | createdAt: "2026-02-27T00:00:02.000Z", | ||
| 946 | updatedAt: "2026-02-27T00:00:02.000Z", | ||
| 947 | }, | ||
| 948 | ], | ||
| 949 | activities: [ | ||
| 950 | { | ||
| 951 | id: EventId.make("activity-1"), | ||
| 952 | tone: "info", | ||
| 953 | kind: "step", | ||
| 954 | summary: "one", | ||
| 955 | payload: {}, | ||
| 956 | turnId: TurnId.make("turn-1"), | ||
| 957 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 958 | }, | ||
| 959 | { | ||
| 960 | id: EventId.make("activity-2"), | ||
| 961 | tone: "info", | ||
| 962 | kind: "step", | ||
| 963 | summary: "two", | ||
| 964 | payload: {}, | ||
| 965 | turnId: TurnId.make("turn-2"), | ||
| 966 | createdAt: "2026-02-27T00:00:02.000Z", | ||
| 967 | }, | ||
| 968 | ], | ||
| 969 | turnDiffSummaries: [ | ||
| 970 | { | ||
| 971 | turnId: TurnId.make("turn-1"), | ||
| 972 | completedAt: "2026-02-27T00:00:01.000Z", | ||
| 973 | status: "ready", | ||
| 974 | checkpointTurnCount: 1, | ||
| 975 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 976 | files: [], | ||
| 977 | }, | ||
| 978 | { | ||
| 979 | turnId: TurnId.make("turn-2"), | ||
| 980 | completedAt: "2026-02-27T00:00:03.000Z", | ||
| 981 | status: "ready", | ||
| 982 | checkpointTurnCount: 2, | ||
| 983 | checkpointRef: CheckpointRef.make("ref-2"), | ||
| 984 | files: [], | ||
| 985 | }, | ||
| 986 | ], | ||
| 987 | }), | ||
| 988 | ); | ||
| 989 | |||
| 990 | const next = applyOrchestrationEvent( | ||
| 991 | state, | ||
| 992 | makeEvent("thread.reverted", { | ||
| 993 | threadId: ThreadId.make("thread-1"), | ||
| 994 | turnCount: 1, | ||
| 995 | }), | ||
| 996 | localEnvironmentId, | ||
| 997 | ); | ||
| 998 | |||
| 999 | expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ | ||
| 1000 | "user-1", | ||
| 1001 | "assistant-1", | ||
| 1002 | ]); | ||
| 1003 | expect(threadsOf(next)[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); | ||
| 1004 | expect(threadsOf(next)[0]?.activities.map((activity) => activity.id)).toEqual([ | ||
| 1005 | EventId.make("activity-1"), | ||
| 1006 | ]); | ||
| 1007 | expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ | ||
| 1008 | TurnId.make("turn-1"), | ||
| 1009 | ]); | ||
| 1010 | }); | ||
| 1011 | |||
| 1012 | it("clears pending source proposed plans after revert before a new session-set event", () => { | ||
| 1013 | const thread = makeThread({ | ||
| 1014 | latestTurn: { | ||
| 1015 | turnId: TurnId.make("turn-2"), | ||
| 1016 | state: "completed", | ||
| 1017 | requestedAt: "2026-02-27T00:00:02.000Z", | ||
| 1018 | startedAt: "2026-02-27T00:00:02.000Z", | ||
| 1019 | completedAt: "2026-02-27T00:00:03.000Z", | ||
| 1020 | assistantMessageId: MessageId.make("assistant-2"), | ||
| 1021 | sourceProposedPlan: { | ||
| 1022 | threadId: ThreadId.make("thread-source"), | ||
| 1023 | planId: "plan-2" as never, | ||
| 1024 | }, | ||
| 1025 | }, | ||
| 1026 | pendingSourceProposedPlan: { | ||
| 1027 | threadId: ThreadId.make("thread-source"), | ||
| 1028 | planId: "plan-2" as never, | ||
| 1029 | }, | ||
| 1030 | turnDiffSummaries: [ | ||
| 1031 | { | ||
| 1032 | turnId: TurnId.make("turn-1"), | ||
| 1033 | completedAt: "2026-02-27T00:00:01.000Z", | ||
| 1034 | status: "ready", | ||
| 1035 | checkpointTurnCount: 1, | ||
| 1036 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 1037 | files: [], | ||
| 1038 | }, | ||
| 1039 | { | ||
| 1040 | turnId: TurnId.make("turn-2"), | ||
| 1041 | completedAt: "2026-02-27T00:00:03.000Z", | ||
| 1042 | status: "ready", | ||
| 1043 | checkpointTurnCount: 2, | ||
| 1044 | checkpointRef: CheckpointRef.make("ref-2"), | ||
| 1045 | files: [], | ||
| 1046 | }, | ||
| 1047 | ], | ||
| 1048 | }); | ||
| 1049 | const reverted = applyOrchestrationEvent( | ||
| 1050 | makeState(thread), | ||
| 1051 | makeEvent("thread.reverted", { | ||
| 1052 | threadId: thread.id, | ||
| 1053 | turnCount: 1, | ||
| 1054 | }), | ||
| 1055 | localEnvironmentId, | ||
| 1056 | ); | ||
| 1057 | |||
| 1058 | expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); | ||
| 1059 | |||
| 1060 | const next = applyOrchestrationEvent( | ||
| 1061 | reverted, | ||
| 1062 | makeEvent("thread.session-set", { | ||
| 1063 | threadId: thread.id, | ||
| 1064 | session: { | ||
| 1065 | threadId: thread.id, | ||
| 1066 | status: "running", | ||
| 1067 | providerName: "codex", | ||
| 1068 | runtimeMode: "full-access", | ||
| 1069 | activeTurnId: TurnId.make("turn-3"), | ||
| 1070 | lastError: null, | ||
| 1071 | updatedAt: "2026-02-27T00:00:04.000Z", | ||
| 1072 | }, | ||
| 1073 | }), | ||
| 1074 | localEnvironmentId, | ||
| 1075 | ); | ||
| 1076 | |||
| 1077 | expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ | ||
| 1078 | turnId: TurnId.make("turn-3"), | ||
| 1079 | state: "running", | ||
| 1080 | }); | ||
| 1081 | expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); | ||
| 1082 | }); | ||
| 1083 | }); | ||
apps/web/src/store.ts deleted-2050| ... | @@ -1,2050 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | MessageId, | ||
| 4 | OrchestrationCheckpointSummary, | ||
| 5 | OrchestrationEvent, | ||
| 6 | OrchestrationLatestTurn, | ||
| 7 | OrchestrationMessage, | ||
| 8 | OrchestrationProposedPlan, | ||
| 9 | OrchestrationReadModel, | ||
| 10 | OrchestrationShellSnapshot, | ||
| 11 | OrchestrationShellStreamEvent, | ||
| 12 | OrchestrationSession, | ||
| 13 | OrchestrationSessionStatus, | ||
| 14 | OrchestrationThread, | ||
| 15 | OrchestrationThreadShell, | ||
| 16 | OrchestrationThreadActivity, | ||
| 17 | ProjectId, | ||
| 18 | ScopedProjectRef, | ||
| 19 | ScopedThreadRef, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import { isProviderDriverKind, ProviderDriverKind } from "@t3tools/contracts"; | ||
| 22 | import type { ThreadId, TurnId } from "@t3tools/contracts"; | ||
| 23 | import * as Schema from "effect/Schema"; | ||
| 24 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; | ||
| 25 | import { create } from "zustand"; | ||
| 26 | import { | ||
| 27 | type ChatMessage, | ||
| 28 | type Project, | ||
| 29 | type ProposedPlan, | ||
| 30 | type SidebarThreadSummary, | ||
| 31 | type Thread, | ||
| 32 | type ThreadSession, | ||
| 33 | type ThreadShell, | ||
| 34 | type ThreadTurnState, | ||
| 35 | type TurnDiffSummary, | ||
| 36 | } from "./types"; | ||
| 37 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; | ||
| 38 | import { getThreadFromEnvironmentState } from "./threadDerivation"; | ||
| 39 | const isProviderDriverKindValue = Schema.is(ProviderDriverKind); | ||
| 40 | |||
| 41 | export interface EnvironmentState { | ||
| 42 | projectIds: ProjectId[]; | ||
| 43 | projectById: Record<ProjectId, Project>; | ||
| 44 | |||
| 45 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 46 | // Web still stores shell snapshots and thread details in this denormalized | ||
| 47 | // Zustand shape. Mobile uses createShellSnapshotManager and | ||
| 48 | // createThreadDetailManager from @t3tools/client-runtime. New shared behavior | ||
| 49 | // belongs in those managers/reducers, with a web adapter layered on top. | ||
| 50 | // | ||
| 51 | // --------------------------------------------------------------------------- | ||
| 52 | // Thread bookkeeping — written by BOTH shell stream and detail stream. | ||
| 53 | // Both streams ensure the thread is registered here; the bookkeeping is | ||
| 54 | // additive (append-only IDs) so concurrent writes are safe. | ||
| 55 | // --------------------------------------------------------------------------- | ||
| 56 | threadIds: ThreadId[]; | ||
| 57 | threadIdsByProjectId: Record<ProjectId, ThreadId[]>; | ||
| 58 | |||
| 59 | // --------------------------------------------------------------------------- | ||
| 60 | // Thread shell / session / turn — written by BOTH shell stream and detail | ||
| 61 | // stream. The shell stream is the *authoritative* source (server pre- | ||
| 62 | // computes these from the projection pipeline), but the detail stream also | ||
| 63 | // writes them so the active thread has up-to-date state even if the shell | ||
| 64 | // event hasn't arrived yet. Structural equality checks in both write | ||
| 65 | // functions prevent unnecessary React re-renders when both streams deliver | ||
| 66 | // equivalent data. | ||
| 67 | // --------------------------------------------------------------------------- | ||
| 68 | threadShellById: Record<ThreadId, ThreadShell>; | ||
| 69 | threadSessionById: Record<ThreadId, ThreadSession | null>; | ||
| 70 | threadTurnStateById: Record<ThreadId, ThreadTurnState>; | ||
| 71 | |||
| 72 | // --------------------------------------------------------------------------- | ||
| 73 | // Thread detail content — written ONLY by the detail stream | ||
| 74 | // (writeThreadState / syncServerThreadDetail). The shell stream never | ||
| 75 | // touches these. | ||
| 76 | // --------------------------------------------------------------------------- | ||
| 77 | messageIdsByThreadId: Record<ThreadId, MessageId[]>; | ||
| 78 | messageByThreadId: Record<ThreadId, Record<MessageId, ChatMessage>>; | ||
| 79 | activityIdsByThreadId: Record<ThreadId, string[]>; | ||
| 80 | activityByThreadId: Record<ThreadId, Record<string, OrchestrationThreadActivity>>; | ||
| 81 | proposedPlanIdsByThreadId: Record<ThreadId, string[]>; | ||
| 82 | proposedPlanByThreadId: Record<ThreadId, Record<string, ProposedPlan>>; | ||
| 83 | turnDiffIdsByThreadId: Record<ThreadId, TurnId[]>; | ||
| 84 | turnDiffSummaryByThreadId: Record<ThreadId, Record<TurnId, TurnDiffSummary>>; | ||
| 85 | |||
| 86 | // --------------------------------------------------------------------------- | ||
| 87 | // Sidebar summary — written ONLY by the shell stream | ||
| 88 | // (writeThreadShellState / mapThreadShell). Pre-computed server-side with | ||
| 89 | // fields like latestUserMessageAt, hasPendingApprovals, etc. The detail | ||
| 90 | // stream must NOT write here; the shell stream is the single source of | ||
| 91 | // truth for sidebar data. | ||
| 92 | // --------------------------------------------------------------------------- | ||
| 93 | sidebarThreadSummaryById: Record<ThreadId, SidebarThreadSummary>; | ||
| 94 | |||
| 95 | bootstrapComplete: boolean; | ||
| 96 | } | ||
| 97 | |||
| 98 | export interface AppState { | ||
| 99 | activeEnvironmentId: EnvironmentId | null; | ||
| 100 | environmentStateById: Record<string, EnvironmentState>; | ||
| 101 | } | ||
| 102 | |||
| 103 | const initialEnvironmentState: EnvironmentState = { | ||
| 104 | projectIds: [], | ||
| 105 | projectById: {}, | ||
| 106 | threadIds: [], | ||
| 107 | threadIdsByProjectId: {}, | ||
| 108 | threadShellById: {}, | ||
| 109 | threadSessionById: {}, | ||
| 110 | threadTurnStateById: {}, | ||
| 111 | messageIdsByThreadId: {}, | ||
| 112 | messageByThreadId: {}, | ||
| 113 | activityIdsByThreadId: {}, | ||
| 114 | activityByThreadId: {}, | ||
| 115 | proposedPlanIdsByThreadId: {}, | ||
| 116 | proposedPlanByThreadId: {}, | ||
| 117 | turnDiffIdsByThreadId: {}, | ||
| 118 | turnDiffSummaryByThreadId: {}, | ||
| 119 | sidebarThreadSummaryById: {}, | ||
| 120 | bootstrapComplete: false, | ||
| 121 | }; | ||
| 122 | |||
| 123 | const initialState: AppState = { | ||
| 124 | activeEnvironmentId: null, | ||
| 125 | environmentStateById: {}, | ||
| 126 | }; | ||
| 127 | |||
| 128 | const MAX_THREAD_MESSAGES = 2_000; | ||
| 129 | const MAX_THREAD_CHECKPOINTS = 500; | ||
| 130 | const MAX_THREAD_PROPOSED_PLANS = 200; | ||
| 131 | const MAX_THREAD_ACTIVITIES = 500; | ||
| 132 | const EMPTY_THREAD_IDS: ThreadId[] = []; | ||
| 133 | |||
| 134 | function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { | ||
| 135 | return left.length === right.length && left.every((value, index) => value === right[index]); | ||
| 136 | } | ||
| 137 | |||
| 138 | // Accepts the open `instanceId` string carried on `ModelSelection`; malformed | ||
| 139 | // values pass through unchanged, while valid slugs use any registered alias | ||
| 140 | // table for model normalization. | ||
| 141 | function normalizeModelSelection<T extends { instanceId: string; model: string }>(selection: T): T { | ||
| 142 | if (!isProviderDriverKind(selection.instanceId)) { | ||
| 143 | return selection; | ||
| 144 | } | ||
| 145 | return { | ||
| 146 | ...selection, | ||
| 147 | model: resolveModelSlugForProvider(selection.instanceId, selection.model), | ||
| 148 | }; | ||
| 149 | } | ||
| 150 | |||
| 151 | function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): Project["scripts"] { | ||
| 152 | return scripts.map((script) => ({ ...script })); | ||
| 153 | } | ||
| 154 | |||
| 155 | function mapSession(session: OrchestrationSession): ThreadSession { | ||
| 156 | return { | ||
| 157 | provider: toLegacyProvider(session.providerName), | ||
| 158 | providerInstanceId: session.providerInstanceId ?? undefined, | ||
| 159 | status: toLegacySessionStatus(session.status), | ||
| 160 | orchestrationStatus: session.status, | ||
| 161 | activeTurnId: session.activeTurnId ?? undefined, | ||
| 162 | createdAt: session.updatedAt, | ||
| 163 | updatedAt: session.updatedAt, | ||
| 164 | ...(session.lastError ? { lastError: session.lastError } : {}), | ||
| 165 | }; | ||
| 166 | } | ||
| 167 | |||
| 168 | function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage): ChatMessage { | ||
| 169 | const attachments = message.attachments?.map((attachment) => ({ | ||
| 170 | type: "image" as const, | ||
| 171 | id: attachment.id, | ||
| 172 | name: attachment.name, | ||
| 173 | mimeType: attachment.mimeType, | ||
| 174 | sizeBytes: attachment.sizeBytes, | ||
| 175 | })); | ||
| 176 | |||
| 177 | return { | ||
| 178 | id: message.id, | ||
| 179 | role: message.role, | ||
| 180 | text: message.text, | ||
| 181 | turnId: message.turnId, | ||
| 182 | createdAt: message.createdAt, | ||
| 183 | streaming: message.streaming, | ||
| 184 | ...(message.streaming ? {} : { completedAt: message.updatedAt }), | ||
| 185 | ...(attachments && attachments.length > 0 ? { attachments } : {}), | ||
| 186 | }; | ||
| 187 | } | ||
| 188 | |||
| 189 | function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan { | ||
| 190 | return { | ||
| 191 | id: proposedPlan.id, | ||
| 192 | turnId: proposedPlan.turnId, | ||
| 193 | planMarkdown: proposedPlan.planMarkdown, | ||
| 194 | implementedAt: proposedPlan.implementedAt, | ||
| 195 | implementationThreadId: proposedPlan.implementationThreadId, | ||
| 196 | createdAt: proposedPlan.createdAt, | ||
| 197 | updatedAt: proposedPlan.updatedAt, | ||
| 198 | }; | ||
| 199 | } | ||
| 200 | |||
| 201 | function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDiffSummary { | ||
| 202 | return { | ||
| 203 | turnId: checkpoint.turnId, | ||
| 204 | completedAt: checkpoint.completedAt, | ||
| 205 | status: checkpoint.status, | ||
| 206 | assistantMessageId: checkpoint.assistantMessageId ?? undefined, | ||
| 207 | checkpointTurnCount: checkpoint.checkpointTurnCount, | ||
| 208 | checkpointRef: checkpoint.checkpointRef, | ||
| 209 | files: checkpoint.files.map((file) => ({ ...file })), | ||
| 210 | }; | ||
| 211 | } | ||
| 212 | |||
| 213 | function mapProject( | ||
| 214 | project: | ||
| 215 | | OrchestrationReadModel["projects"][number] | ||
| 216 | | OrchestrationShellSnapshot["projects"][number], | ||
| 217 | environmentId: EnvironmentId, | ||
| 218 | ): Project { | ||
| 219 | return { | ||
| 220 | id: project.id, | ||
| 221 | environmentId, | ||
| 222 | name: project.title, | ||
| 223 | cwd: project.workspaceRoot, | ||
| 224 | repositoryIdentity: project.repositoryIdentity ?? null, | ||
| 225 | defaultModelSelection: project.defaultModelSelection | ||
| 226 | ? normalizeModelSelection(project.defaultModelSelection) | ||
| 227 | : null, | ||
| 228 | createdAt: project.createdAt, | ||
| 229 | updatedAt: project.updatedAt, | ||
| 230 | scripts: mapProjectScripts(project.scripts), | ||
| 231 | }; | ||
| 232 | } | ||
| 233 | |||
| 234 | function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { | ||
| 235 | return { | ||
| 236 | id: thread.id, | ||
| 237 | environmentId, | ||
| 238 | codexThreadId: null, | ||
| 239 | projectId: thread.projectId, | ||
| 240 | title: thread.title, | ||
| 241 | modelSelection: normalizeModelSelection(thread.modelSelection), | ||
| 242 | runtimeMode: thread.runtimeMode, | ||
| 243 | interactionMode: thread.interactionMode, | ||
| 244 | session: thread.session ? mapSession(thread.session) : null, | ||
| 245 | messages: thread.messages.map((message) => mapMessage(environmentId, message)), | ||
| 246 | proposedPlans: thread.proposedPlans.map(mapProposedPlan), | ||
| 247 | error: sanitizeThreadErrorMessage(thread.session?.lastError), | ||
| 248 | createdAt: thread.createdAt, | ||
| 249 | archivedAt: thread.archivedAt, | ||
| 250 | updatedAt: thread.updatedAt, | ||
| 251 | latestTurn: thread.latestTurn, | ||
| 252 | pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, | ||
| 253 | branch: thread.branch, | ||
| 254 | worktreePath: thread.worktreePath, | ||
| 255 | turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), | ||
| 256 | activities: thread.activities.map((activity) => ({ ...activity })), | ||
| 257 | }; | ||
| 258 | } | ||
| 259 | |||
| 260 | function mapThreadShell( | ||
| 261 | thread: OrchestrationThreadShell, | ||
| 262 | environmentId: EnvironmentId, | ||
| 263 | ): { | ||
| 264 | shell: ThreadShell; | ||
| 265 | session: ThreadSession | null; | ||
| 266 | turnState: ThreadTurnState; | ||
| 267 | summary: SidebarThreadSummary; | ||
| 268 | } { | ||
| 269 | const shell: ThreadShell = { | ||
| 270 | id: thread.id, | ||
| 271 | environmentId, | ||
| 272 | codexThreadId: null, | ||
| 273 | projectId: thread.projectId, | ||
| 274 | title: thread.title, | ||
| 275 | modelSelection: normalizeModelSelection(thread.modelSelection), | ||
| 276 | runtimeMode: thread.runtimeMode, | ||
| 277 | interactionMode: thread.interactionMode, | ||
| 278 | error: sanitizeThreadErrorMessage(thread.session?.lastError), | ||
| 279 | createdAt: thread.createdAt, | ||
| 280 | archivedAt: thread.archivedAt, | ||
| 281 | updatedAt: thread.updatedAt, | ||
| 282 | branch: thread.branch, | ||
| 283 | worktreePath: thread.worktreePath, | ||
| 284 | }; | ||
| 285 | const session = thread.session ? mapSession(thread.session) : null; | ||
| 286 | const turnState: ThreadTurnState = { | ||
| 287 | latestTurn: thread.latestTurn, | ||
| 288 | pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, | ||
| 289 | }; | ||
| 290 | const summary: SidebarThreadSummary = { | ||
| 291 | id: thread.id, | ||
| 292 | environmentId, | ||
| 293 | projectId: thread.projectId, | ||
| 294 | title: thread.title, | ||
| 295 | interactionMode: thread.interactionMode, | ||
| 296 | session, | ||
| 297 | createdAt: thread.createdAt, | ||
| 298 | archivedAt: thread.archivedAt, | ||
| 299 | updatedAt: thread.updatedAt, | ||
| 300 | latestTurn: thread.latestTurn, | ||
| 301 | branch: thread.branch, | ||
| 302 | worktreePath: thread.worktreePath, | ||
| 303 | latestUserMessageAt: thread.latestUserMessageAt, | ||
| 304 | hasPendingApprovals: thread.hasPendingApprovals, | ||
| 305 | hasPendingUserInput: thread.hasPendingUserInput, | ||
| 306 | hasActionableProposedPlan: thread.hasActionableProposedPlan, | ||
| 307 | }; | ||
| 308 | return { | ||
| 309 | shell, | ||
| 310 | session, | ||
| 311 | turnState, | ||
| 312 | summary, | ||
| 313 | }; | ||
| 314 | } | ||
| 315 | |||
| 316 | function toThreadShell(thread: Thread): ThreadShell { | ||
| 317 | return { | ||
| 318 | id: thread.id, | ||
| 319 | environmentId: thread.environmentId, | ||
| 320 | codexThreadId: thread.codexThreadId, | ||
| 321 | projectId: thread.projectId, | ||
| 322 | title: thread.title, | ||
| 323 | modelSelection: thread.modelSelection, | ||
| 324 | runtimeMode: thread.runtimeMode, | ||
| 325 | interactionMode: thread.interactionMode, | ||
| 326 | error: thread.error, | ||
| 327 | createdAt: thread.createdAt, | ||
| 328 | archivedAt: thread.archivedAt, | ||
| 329 | updatedAt: thread.updatedAt, | ||
| 330 | branch: thread.branch, | ||
| 331 | worktreePath: thread.worktreePath, | ||
| 332 | }; | ||
| 333 | } | ||
| 334 | |||
| 335 | function toThreadTurnState(thread: Thread): ThreadTurnState { | ||
| 336 | return { | ||
| 337 | latestTurn: thread.latestTurn, | ||
| 338 | ...(thread.pendingSourceProposedPlan | ||
| 339 | ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } | ||
| 340 | : {}), | ||
| 341 | }; | ||
| 342 | } | ||
| 343 | |||
| 344 | function sourceProposedPlansEqual( | ||
| 345 | left: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, | ||
| 346 | right: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, | ||
| 347 | ): boolean { | ||
| 348 | if (left === right) return true; | ||
| 349 | if (left === undefined || right === undefined) return false; | ||
| 350 | return left.threadId === right.threadId && left.planId === right.planId; | ||
| 351 | } | ||
| 352 | |||
| 353 | function latestTurnsEqual( | ||
| 354 | left: OrchestrationLatestTurn | null | undefined, | ||
| 355 | right: OrchestrationLatestTurn | null | undefined, | ||
| 356 | ): boolean { | ||
| 357 | if (left === right) return true; | ||
| 358 | if (left == null || right == null) return false; | ||
| 359 | return ( | ||
| 360 | left.turnId === right.turnId && | ||
| 361 | left.state === right.state && | ||
| 362 | left.requestedAt === right.requestedAt && | ||
| 363 | left.startedAt === right.startedAt && | ||
| 364 | left.completedAt === right.completedAt && | ||
| 365 | left.assistantMessageId === right.assistantMessageId && | ||
| 366 | sourceProposedPlansEqual(left.sourceProposedPlan, right.sourceProposedPlan) | ||
| 367 | ); | ||
| 368 | } | ||
| 369 | |||
| 370 | function threadSessionsEqual( | ||
| 371 | left: ThreadSession | null | undefined, | ||
| 372 | right: ThreadSession | null | undefined, | ||
| 373 | ): boolean { | ||
| 374 | if (left === right) return true; | ||
| 375 | if (left == null || right == null) return false; | ||
| 376 | return ( | ||
| 377 | left.provider === right.provider && | ||
| 378 | left.status === right.status && | ||
| 379 | left.orchestrationStatus === right.orchestrationStatus && | ||
| 380 | left.activeTurnId === right.activeTurnId && | ||
| 381 | left.createdAt === right.createdAt && | ||
| 382 | left.updatedAt === right.updatedAt && | ||
| 383 | left.lastError === right.lastError | ||
| 384 | ); | ||
| 385 | } | ||
| 386 | |||
| 387 | function sidebarThreadSummariesEqual( | ||
| 388 | left: SidebarThreadSummary | undefined, | ||
| 389 | right: SidebarThreadSummary, | ||
| 390 | ): boolean { | ||
| 391 | return ( | ||
| 392 | left !== undefined && | ||
| 393 | left.id === right.id && | ||
| 394 | left.projectId === right.projectId && | ||
| 395 | left.title === right.title && | ||
| 396 | left.interactionMode === right.interactionMode && | ||
| 397 | threadSessionsEqual(left.session, right.session) && | ||
| 398 | left.createdAt === right.createdAt && | ||
| 399 | left.archivedAt === right.archivedAt && | ||
| 400 | left.updatedAt === right.updatedAt && | ||
| 401 | latestTurnsEqual(left.latestTurn, right.latestTurn) && | ||
| 402 | left.branch === right.branch && | ||
| 403 | left.worktreePath === right.worktreePath && | ||
| 404 | left.latestUserMessageAt === right.latestUserMessageAt && | ||
| 405 | left.hasPendingApprovals === right.hasPendingApprovals && | ||
| 406 | left.hasPendingUserInput === right.hasPendingUserInput && | ||
| 407 | left.hasActionableProposedPlan === right.hasActionableProposedPlan | ||
| 408 | ); | ||
| 409 | } | ||
| 410 | |||
| 411 | function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): boolean { | ||
| 412 | return ( | ||
| 413 | left !== undefined && | ||
| 414 | left.id === right.id && | ||
| 415 | left.environmentId === right.environmentId && | ||
| 416 | left.codexThreadId === right.codexThreadId && | ||
| 417 | left.projectId === right.projectId && | ||
| 418 | left.title === right.title && | ||
| 419 | left.modelSelection === right.modelSelection && | ||
| 420 | left.runtimeMode === right.runtimeMode && | ||
| 421 | left.interactionMode === right.interactionMode && | ||
| 422 | left.error === right.error && | ||
| 423 | left.createdAt === right.createdAt && | ||
| 424 | left.archivedAt === right.archivedAt && | ||
| 425 | left.updatedAt === right.updatedAt && | ||
| 426 | left.branch === right.branch && | ||
| 427 | left.worktreePath === right.worktreePath | ||
| 428 | ); | ||
| 429 | } | ||
| 430 | |||
| 431 | function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { | ||
| 432 | return ( | ||
| 433 | left !== undefined && | ||
| 434 | latestTurnsEqual(left.latestTurn, right.latestTurn) && | ||
| 435 | sourceProposedPlansEqual(left.pendingSourceProposedPlan, right.pendingSourceProposedPlan) | ||
| 436 | ); | ||
| 437 | } | ||
| 438 | |||
| 439 | function appendId<T extends string>(ids: readonly T[], id: T): T[] { | ||
| 440 | return ids.includes(id) ? [...ids] : [...ids, id]; | ||
| 441 | } | ||
| 442 | |||
| 443 | function removeId<T extends string>(ids: readonly T[], id: T): T[] { | ||
| 444 | return ids.filter((value) => value !== id); | ||
| 445 | } | ||
| 446 | |||
| 447 | function buildMessageSlice(thread: Thread): { | ||
| 448 | ids: MessageId[]; | ||
| 449 | byId: Record<MessageId, ChatMessage>; | ||
| 450 | } { | ||
| 451 | return { | ||
| 452 | ids: thread.messages.map((message) => message.id), | ||
| 453 | byId: Object.fromEntries( | ||
| 454 | thread.messages.map((message) => [message.id, message] as const), | ||
| 455 | ) as Record<MessageId, ChatMessage>, | ||
| 456 | }; | ||
| 457 | } | ||
| 458 | |||
| 459 | function buildActivitySlice(thread: Thread): { | ||
| 460 | ids: string[]; | ||
| 461 | byId: Record<string, OrchestrationThreadActivity>; | ||
| 462 | } { | ||
| 463 | return { | ||
| 464 | ids: thread.activities.map((activity) => activity.id), | ||
| 465 | byId: Object.fromEntries( | ||
| 466 | thread.activities.map((activity) => [activity.id, activity] as const), | ||
| 467 | ) as Record<string, OrchestrationThreadActivity>, | ||
| 468 | }; | ||
| 469 | } | ||
| 470 | |||
| 471 | function buildProposedPlanSlice(thread: Thread): { | ||
| 472 | ids: string[]; | ||
| 473 | byId: Record<string, ProposedPlan>; | ||
| 474 | } { | ||
| 475 | return { | ||
| 476 | ids: thread.proposedPlans.map((plan) => plan.id), | ||
| 477 | byId: Object.fromEntries( | ||
| 478 | thread.proposedPlans.map((plan) => [plan.id, plan] as const), | ||
| 479 | ) as Record<string, ProposedPlan>, | ||
| 480 | }; | ||
| 481 | } | ||
| 482 | |||
| 483 | function buildTurnDiffSlice(thread: Thread): { | ||
| 484 | ids: TurnId[]; | ||
| 485 | byId: Record<TurnId, TurnDiffSummary>; | ||
| 486 | } { | ||
| 487 | return { | ||
| 488 | ids: thread.turnDiffSummaries.map((summary) => summary.turnId), | ||
| 489 | byId: Object.fromEntries( | ||
| 490 | thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), | ||
| 491 | ) as Record<TurnId, TurnDiffSummary>, | ||
| 492 | }; | ||
| 493 | } | ||
| 494 | |||
| 495 | function getProjects(state: EnvironmentState): Project[] { | ||
| 496 | return state.projectIds.flatMap((projectId) => { | ||
| 497 | const project = state.projectById[projectId]; | ||
| 498 | return project ? [project] : []; | ||
| 499 | }); | ||
| 500 | } | ||
| 501 | |||
| 502 | function getThreads(state: EnvironmentState): Thread[] { | ||
| 503 | return state.threadIds.flatMap((threadId) => { | ||
| 504 | const thread = getThreadFromEnvironmentState(state, threadId); | ||
| 505 | return thread ? [thread] : []; | ||
| 506 | }); | ||
| 507 | } | ||
| 508 | |||
| 509 | /** | ||
| 510 | * Ensure a thread is registered in the bookkeeping indices (threadIds, | ||
| 511 | * threadIdsByProjectId). Shared by both the shell stream and detail stream | ||
| 512 | * write paths — the bookkeeping is additive (append-only IDs) so concurrent | ||
| 513 | * writes from both streams are safe. | ||
| 514 | */ | ||
| 515 | function ensureThreadRegistered( | ||
| 516 | state: EnvironmentState, | ||
| 517 | threadId: ThreadId, | ||
| 518 | nextProjectId: ProjectId, | ||
| 519 | previousProjectId: ProjectId | undefined, | ||
| 520 | ): EnvironmentState { | ||
| 521 | let nextState = state; | ||
| 522 | |||
| 523 | if (!state.threadIds.includes(threadId)) { | ||
| 524 | nextState = { | ||
| 525 | ...nextState, | ||
| 526 | threadIds: [...nextState.threadIds, threadId], | ||
| 527 | }; | ||
| 528 | } | ||
| 529 | |||
| 530 | if (previousProjectId !== nextProjectId) { | ||
| 531 | let threadIdsByProjectId = nextState.threadIdsByProjectId; | ||
| 532 | if (previousProjectId) { | ||
| 533 | const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; | ||
| 534 | const nextIds = removeId(previousIds, threadId); | ||
| 535 | if (nextIds.length === 0) { | ||
| 536 | const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; | ||
| 537 | threadIdsByProjectId = rest as Record<ProjectId, ThreadId[]>; | ||
| 538 | } else if (!arraysEqual(previousIds, nextIds)) { | ||
| 539 | threadIdsByProjectId = { | ||
| 540 | ...threadIdsByProjectId, | ||
| 541 | [previousProjectId]: nextIds, | ||
| 542 | }; | ||
| 543 | } | ||
| 544 | } | ||
| 545 | const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; | ||
| 546 | const nextProjectThreadIds = appendId(projectThreadIds, threadId); | ||
| 547 | if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { | ||
| 548 | threadIdsByProjectId = { | ||
| 549 | ...threadIdsByProjectId, | ||
| 550 | [nextProjectId]: nextProjectThreadIds, | ||
| 551 | }; | ||
| 552 | } | ||
| 553 | if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { | ||
| 554 | nextState = { | ||
| 555 | ...nextState, | ||
| 556 | threadIdsByProjectId, | ||
| 557 | }; | ||
| 558 | } | ||
| 559 | } | ||
| 560 | |||
| 561 | return nextState; | ||
| 562 | } | ||
| 563 | |||
| 564 | /** | ||
| 565 | * Write thread state from the **detail stream** (per-thread subscription). | ||
| 566 | * | ||
| 567 | * Owns: messages, activities, proposed plans, turn diff summaries. | ||
| 568 | * Also writes threadShellById / threadSessionById / threadTurnStateById so | ||
| 569 | * the active thread has up-to-date state even if the shell stream event | ||
| 570 | * hasn't arrived yet (both streams use structural equality checks to avoid | ||
| 571 | * unnecessary re-renders when delivering equivalent data). | ||
| 572 | * Does NOT write sidebarThreadSummaryById — that is shell-stream-only. | ||
| 573 | */ | ||
| 574 | function writeThreadState( | ||
| 575 | state: EnvironmentState, | ||
| 576 | nextThread: Thread, | ||
| 577 | previousThread?: Thread, | ||
| 578 | ): EnvironmentState { | ||
| 579 | const nextShell = toThreadShell(nextThread); | ||
| 580 | const nextTurnState = toThreadTurnState(nextThread); | ||
| 581 | const previousShell = state.threadShellById[nextThread.id]; | ||
| 582 | const previousTurnState = state.threadTurnStateById[nextThread.id]; | ||
| 583 | |||
| 584 | let nextState = ensureThreadRegistered( | ||
| 585 | state, | ||
| 586 | nextThread.id, | ||
| 587 | nextThread.projectId, | ||
| 588 | previousThread?.projectId, | ||
| 589 | ); | ||
| 590 | |||
| 591 | if (!threadShellsEqual(previousShell, nextShell)) { | ||
| 592 | nextState = { | ||
| 593 | ...nextState, | ||
| 594 | threadShellById: { | ||
| 595 | ...nextState.threadShellById, | ||
| 596 | [nextThread.id]: nextShell, | ||
| 597 | }, | ||
| 598 | }; | ||
| 599 | } | ||
| 600 | |||
| 601 | if (!threadSessionsEqual(previousThread?.session ?? null, nextThread.session)) { | ||
| 602 | nextState = { | ||
| 603 | ...nextState, | ||
| 604 | threadSessionById: { | ||
| 605 | ...nextState.threadSessionById, | ||
| 606 | [nextThread.id]: nextThread.session, | ||
| 607 | }, | ||
| 608 | }; | ||
| 609 | } | ||
| 610 | |||
| 611 | if (!threadTurnStatesEqual(previousTurnState, nextTurnState)) { | ||
| 612 | nextState = { | ||
| 613 | ...nextState, | ||
| 614 | threadTurnStateById: { | ||
| 615 | ...nextState.threadTurnStateById, | ||
| 616 | [nextThread.id]: nextTurnState, | ||
| 617 | }, | ||
| 618 | }; | ||
| 619 | } | ||
| 620 | |||
| 621 | if (previousThread?.messages !== nextThread.messages) { | ||
| 622 | const nextMessageSlice = buildMessageSlice(nextThread); | ||
| 623 | nextState = { | ||
| 624 | ...nextState, | ||
| 625 | messageIdsByThreadId: { | ||
| 626 | ...nextState.messageIdsByThreadId, | ||
| 627 | [nextThread.id]: nextMessageSlice.ids, | ||
| 628 | }, | ||
| 629 | messageByThreadId: { | ||
| 630 | ...nextState.messageByThreadId, | ||
| 631 | [nextThread.id]: nextMessageSlice.byId, | ||
| 632 | }, | ||
| 633 | }; | ||
| 634 | } | ||
| 635 | |||
| 636 | if (previousThread?.activities !== nextThread.activities) { | ||
| 637 | const nextActivitySlice = buildActivitySlice(nextThread); | ||
| 638 | nextState = { | ||
| 639 | ...nextState, | ||
| 640 | activityIdsByThreadId: { | ||
| 641 | ...nextState.activityIdsByThreadId, | ||
| 642 | [nextThread.id]: nextActivitySlice.ids, | ||
| 643 | }, | ||
| 644 | activityByThreadId: { | ||
| 645 | ...nextState.activityByThreadId, | ||
| 646 | [nextThread.id]: nextActivitySlice.byId, | ||
| 647 | }, | ||
| 648 | }; | ||
| 649 | } | ||
| 650 | |||
| 651 | if (previousThread?.proposedPlans !== nextThread.proposedPlans) { | ||
| 652 | const nextProposedPlanSlice = buildProposedPlanSlice(nextThread); | ||
| 653 | nextState = { | ||
| 654 | ...nextState, | ||
| 655 | proposedPlanIdsByThreadId: { | ||
| 656 | ...nextState.proposedPlanIdsByThreadId, | ||
| 657 | [nextThread.id]: nextProposedPlanSlice.ids, | ||
| 658 | }, | ||
| 659 | proposedPlanByThreadId: { | ||
| 660 | ...nextState.proposedPlanByThreadId, | ||
| 661 | [nextThread.id]: nextProposedPlanSlice.byId, | ||
| 662 | }, | ||
| 663 | }; | ||
| 664 | } | ||
| 665 | |||
| 666 | if (previousThread?.turnDiffSummaries !== nextThread.turnDiffSummaries) { | ||
| 667 | const nextTurnDiffSlice = buildTurnDiffSlice(nextThread); | ||
| 668 | nextState = { | ||
| 669 | ...nextState, | ||
| 670 | turnDiffIdsByThreadId: { | ||
| 671 | ...nextState.turnDiffIdsByThreadId, | ||
| 672 | [nextThread.id]: nextTurnDiffSlice.ids, | ||
| 673 | }, | ||
| 674 | turnDiffSummaryByThreadId: { | ||
| 675 | ...nextState.turnDiffSummaryByThreadId, | ||
| 676 | [nextThread.id]: nextTurnDiffSlice.byId, | ||
| 677 | }, | ||
| 678 | }; | ||
| 679 | } | ||
| 680 | |||
| 681 | return nextState; | ||
| 682 | } | ||
| 683 | |||
| 684 | /** | ||
| 685 | * Write thread state from the **shell stream** (all-threads subscription). | ||
| 686 | * | ||
| 687 | * Owns: sidebarThreadSummaryById (pre-computed server-side sidebar data). | ||
| 688 | * Also writes threadShellById / threadSessionById / threadTurnStateById as | ||
| 689 | * the authoritative source for these fields. The detail stream may also | ||
| 690 | * write them for the focused thread (see writeThreadState); structural | ||
| 691 | * equality checks prevent unnecessary re-renders. | ||
| 692 | * Does NOT write message/activity/proposedPlan/turnDiff content — that is | ||
| 693 | * detail-stream-only. | ||
| 694 | */ | ||
| 695 | function writeThreadShellState( | ||
| 696 | state: EnvironmentState, | ||
| 697 | nextThread: { | ||
| 698 | shell: ThreadShell; | ||
| 699 | session: ThreadSession | null; | ||
| 700 | turnState: ThreadTurnState; | ||
| 701 | summary: SidebarThreadSummary; | ||
| 702 | }, | ||
| 703 | ): EnvironmentState { | ||
| 704 | const previousShell = state.threadShellById[nextThread.shell.id]; | ||
| 705 | |||
| 706 | let nextState = ensureThreadRegistered( | ||
| 707 | state, | ||
| 708 | nextThread.shell.id, | ||
| 709 | nextThread.shell.projectId, | ||
| 710 | previousShell?.projectId, | ||
| 711 | ); | ||
| 712 | |||
| 713 | if (!threadShellsEqual(previousShell, nextThread.shell)) { | ||
| 714 | nextState = { | ||
| 715 | ...nextState, | ||
| 716 | threadShellById: { | ||
| 717 | ...nextState.threadShellById, | ||
| 718 | [nextThread.shell.id]: nextThread.shell, | ||
| 719 | }, | ||
| 720 | }; | ||
| 721 | } | ||
| 722 | |||
| 723 | if ( | ||
| 724 | !threadSessionsEqual(state.threadSessionById[nextThread.shell.id] ?? null, nextThread.session) | ||
| 725 | ) { | ||
| 726 | nextState = { | ||
| 727 | ...nextState, | ||
| 728 | threadSessionById: { | ||
| 729 | ...nextState.threadSessionById, | ||
| 730 | [nextThread.shell.id]: nextThread.session, | ||
| 731 | }, | ||
| 732 | }; | ||
| 733 | } | ||
| 734 | |||
| 735 | if ( | ||
| 736 | !threadTurnStatesEqual(state.threadTurnStateById[nextThread.shell.id], nextThread.turnState) | ||
| 737 | ) { | ||
| 738 | nextState = { | ||
| 739 | ...nextState, | ||
| 740 | threadTurnStateById: { | ||
| 741 | ...nextState.threadTurnStateById, | ||
| 742 | [nextThread.shell.id]: nextThread.turnState, | ||
| 743 | }, | ||
| 744 | }; | ||
| 745 | } | ||
| 746 | |||
| 747 | if ( | ||
| 748 | !sidebarThreadSummariesEqual( | ||
| 749 | state.sidebarThreadSummaryById[nextThread.shell.id], | ||
| 750 | nextThread.summary, | ||
| 751 | ) | ||
| 752 | ) { | ||
| 753 | nextState = { | ||
| 754 | ...nextState, | ||
| 755 | sidebarThreadSummaryById: { | ||
| 756 | ...nextState.sidebarThreadSummaryById, | ||
| 757 | [nextThread.shell.id]: nextThread.summary, | ||
| 758 | }, | ||
| 759 | }; | ||
| 760 | } | ||
| 761 | |||
| 762 | return nextState; | ||
| 763 | } | ||
| 764 | |||
| 765 | function retainThreadScopedRecord<T>( | ||
| 766 | record: Record<ThreadId, T>, | ||
| 767 | nextThreadIds: ReadonlySet<ThreadId>, | ||
| 768 | ): Record<ThreadId, T> { | ||
| 769 | return Object.fromEntries( | ||
| 770 | Object.entries(record).flatMap(([threadId, value]) => | ||
| 771 | nextThreadIds.has(threadId as ThreadId) ? [[threadId, value] as const] : [], | ||
| 772 | ), | ||
| 773 | ) as Record<ThreadId, T>; | ||
| 774 | } | ||
| 775 | |||
| 776 | function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { | ||
| 777 | const shell = state.threadShellById[threadId]; | ||
| 778 | if (!shell) { | ||
| 779 | return state; | ||
| 780 | } | ||
| 781 | |||
| 782 | const nextThreadIds = removeId(state.threadIds, threadId); | ||
| 783 | const currentProjectThreadIds = state.threadIdsByProjectId[shell.projectId] ?? EMPTY_THREAD_IDS; | ||
| 784 | const nextProjectThreadIds = removeId(currentProjectThreadIds, threadId); | ||
| 785 | const nextThreadIdsByProjectId = | ||
| 786 | nextProjectThreadIds.length === 0 | ||
| 787 | ? (() => { | ||
| 788 | const { [shell.projectId]: _removed, ...rest } = state.threadIdsByProjectId; | ||
| 789 | return rest as Record<ProjectId, ThreadId[]>; | ||
| 790 | })() | ||
| 791 | : { | ||
| 792 | ...state.threadIdsByProjectId, | ||
| 793 | [shell.projectId]: nextProjectThreadIds, | ||
| 794 | }; | ||
| 795 | |||
| 796 | const { [threadId]: _removedShell, ...threadShellById } = state.threadShellById; | ||
| 797 | const { [threadId]: _removedSession, ...threadSessionById } = state.threadSessionById; | ||
| 798 | const { [threadId]: _removedTurnState, ...threadTurnStateById } = state.threadTurnStateById; | ||
| 799 | const { [threadId]: _removedMessageIds, ...messageIdsByThreadId } = state.messageIdsByThreadId; | ||
| 800 | const { [threadId]: _removedMessages, ...messageByThreadId } = state.messageByThreadId; | ||
| 801 | const { [threadId]: _removedActivityIds, ...activityIdsByThreadId } = state.activityIdsByThreadId; | ||
| 802 | const { [threadId]: _removedActivities, ...activityByThreadId } = state.activityByThreadId; | ||
| 803 | const { [threadId]: _removedPlanIds, ...proposedPlanIdsByThreadId } = | ||
| 804 | state.proposedPlanIdsByThreadId; | ||
| 805 | const { [threadId]: _removedPlans, ...proposedPlanByThreadId } = state.proposedPlanByThreadId; | ||
| 806 | const { [threadId]: _removedTurnDiffIds, ...turnDiffIdsByThreadId } = state.turnDiffIdsByThreadId; | ||
| 807 | const { [threadId]: _removedTurnDiffs, ...turnDiffSummaryByThreadId } = | ||
| 808 | state.turnDiffSummaryByThreadId; | ||
| 809 | const { [threadId]: _removedSidebarSummary, ...sidebarThreadSummaryById } = | ||
| 810 | state.sidebarThreadSummaryById; | ||
| 811 | |||
| 812 | return { | ||
| 813 | ...state, | ||
| 814 | threadIds: nextThreadIds, | ||
| 815 | threadIdsByProjectId: nextThreadIdsByProjectId, | ||
| 816 | threadShellById, | ||
| 817 | threadSessionById, | ||
| 818 | threadTurnStateById, | ||
| 819 | messageIdsByThreadId, | ||
| 820 | messageByThreadId, | ||
| 821 | activityIdsByThreadId, | ||
| 822 | activityByThreadId, | ||
| 823 | proposedPlanIdsByThreadId, | ||
| 824 | proposedPlanByThreadId, | ||
| 825 | turnDiffIdsByThreadId, | ||
| 826 | turnDiffSummaryByThreadId, | ||
| 827 | sidebarThreadSummaryById, | ||
| 828 | }; | ||
| 829 | } | ||
| 830 | |||
| 831 | function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { | ||
| 832 | if (status === "error") { | ||
| 833 | return "error" as const; | ||
| 834 | } | ||
| 835 | if (status === "missing") { | ||
| 836 | return "interrupted" as const; | ||
| 837 | } | ||
| 838 | return "completed" as const; | ||
| 839 | } | ||
| 840 | |||
| 841 | function compareActivities( | ||
| 842 | left: Thread["activities"][number], | ||
| 843 | right: Thread["activities"][number], | ||
| 844 | ): number { | ||
| 845 | if (left.sequence !== undefined && right.sequence !== undefined) { | ||
| 846 | if (left.sequence !== right.sequence) { | ||
| 847 | return left.sequence - right.sequence; | ||
| 848 | } | ||
| 849 | } else if (left.sequence !== undefined) { | ||
| 850 | return 1; | ||
| 851 | } else if (right.sequence !== undefined) { | ||
| 852 | return -1; | ||
| 853 | } | ||
| 854 | |||
| 855 | return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); | ||
| 856 | } | ||
| 857 | |||
| 858 | function buildLatestTurn(params: { | ||
| 859 | previous: Thread["latestTurn"]; | ||
| 860 | turnId: NonNullable<Thread["latestTurn"]>["turnId"]; | ||
| 861 | state: NonNullable<Thread["latestTurn"]>["state"]; | ||
| 862 | requestedAt: string; | ||
| 863 | startedAt: string | null; | ||
| 864 | completedAt: string | null; | ||
| 865 | assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"]; | ||
| 866 | sourceProposedPlan?: Thread["pendingSourceProposedPlan"]; | ||
| 867 | }): NonNullable<Thread["latestTurn"]> { | ||
| 868 | const resolvedPlan = | ||
| 869 | params.previous?.turnId === params.turnId | ||
| 870 | ? params.previous.sourceProposedPlan | ||
| 871 | : params.sourceProposedPlan; | ||
| 872 | return { | ||
| 873 | turnId: params.turnId, | ||
| 874 | state: params.state, | ||
| 875 | requestedAt: params.requestedAt, | ||
| 876 | startedAt: params.startedAt, | ||
| 877 | completedAt: params.completedAt, | ||
| 878 | assistantMessageId: params.assistantMessageId, | ||
| 879 | ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}), | ||
| 880 | }; | ||
| 881 | } | ||
| 882 | |||
| 883 | /** | ||
| 884 | * Turn state to settle a still-running latest turn with when its session | ||
| 885 | * leaves the "running" status, or null while the session is (re)starting or | ||
| 886 | * running and the turn must stay unsettled. | ||
| 887 | */ | ||
| 888 | function settledTurnStateForSessionStatus( | ||
| 889 | status: OrchestrationSessionStatus, | ||
| 890 | ): "completed" | "interrupted" | "error" | null { | ||
| 891 | switch (status) { | ||
| 892 | case "idle": | ||
| 893 | case "ready": | ||
| 894 | return "completed"; | ||
| 895 | case "error": | ||
| 896 | return "error"; | ||
| 897 | case "interrupted": | ||
| 898 | case "stopped": | ||
| 899 | return "interrupted"; | ||
| 900 | case "starting": | ||
| 901 | case "running": | ||
| 902 | return null; | ||
| 903 | } | ||
| 904 | } | ||
| 905 | |||
| 906 | function rebindTurnDiffSummariesForAssistantMessage( | ||
| 907 | turnDiffSummaries: ReadonlyArray<TurnDiffSummary>, | ||
| 908 | turnId: TurnId, | ||
| 909 | assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"], | ||
| 910 | ): TurnDiffSummary[] { | ||
| 911 | let changed = false; | ||
| 912 | const nextSummaries = turnDiffSummaries.map((summary) => { | ||
| 913 | if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { | ||
| 914 | return summary; | ||
| 915 | } | ||
| 916 | changed = true; | ||
| 917 | return { | ||
| 918 | ...summary, | ||
| 919 | assistantMessageId: assistantMessageId ?? undefined, | ||
| 920 | }; | ||
| 921 | }); | ||
| 922 | return changed ? nextSummaries : [...turnDiffSummaries]; | ||
| 923 | } | ||
| 924 | |||
| 925 | function retainThreadMessagesAfterRevert( | ||
| 926 | messages: ReadonlyArray<ChatMessage>, | ||
| 927 | retainedTurnIds: ReadonlySet<string>, | ||
| 928 | turnCount: number, | ||
| 929 | ): ChatMessage[] { | ||
| 930 | const retainedMessageIds = new Set<string>(); | ||
| 931 | for (const message of messages) { | ||
| 932 | if (message.role === "system") { | ||
| 933 | retainedMessageIds.add(message.id); | ||
| 934 | continue; | ||
| 935 | } | ||
| 936 | if ( | ||
| 937 | message.turnId !== undefined && | ||
| 938 | message.turnId !== null && | ||
| 939 | retainedTurnIds.has(message.turnId) | ||
| 940 | ) { | ||
| 941 | retainedMessageIds.add(message.id); | ||
| 942 | } | ||
| 943 | } | ||
| 944 | |||
| 945 | const retainedUserCount = messages.filter( | ||
| 946 | (message) => message.role === "user" && retainedMessageIds.has(message.id), | ||
| 947 | ).length; | ||
| 948 | const missingUserCount = Math.max(0, turnCount - retainedUserCount); | ||
| 949 | if (missingUserCount > 0) { | ||
| 950 | const fallbackUserMessages = messages | ||
| 951 | .filter( | ||
| 952 | (message) => | ||
| 953 | message.role === "user" && | ||
| 954 | !retainedMessageIds.has(message.id) && | ||
| 955 | (message.turnId === undefined || | ||
| 956 | message.turnId === null || | ||
| 957 | retainedTurnIds.has(message.turnId)), | ||
| 958 | ) | ||
| 959 | .toSorted( | ||
| 960 | (left, right) => | ||
| 961 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | ||
| 962 | ) | ||
| 963 | .slice(0, missingUserCount); | ||
| 964 | for (const message of fallbackUserMessages) { | ||
| 965 | retainedMessageIds.add(message.id); | ||
| 966 | } | ||
| 967 | } | ||
| 968 | |||
| 969 | const retainedAssistantCount = messages.filter( | ||
| 970 | (message) => message.role === "assistant" && retainedMessageIds.has(message.id), | ||
| 971 | ).length; | ||
| 972 | const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); | ||
| 973 | if (missingAssistantCount > 0) { | ||
| 974 | const fallbackAssistantMessages = messages | ||
| 975 | .filter( | ||
| 976 | (message) => | ||
| 977 | message.role === "assistant" && | ||
| 978 | !retainedMessageIds.has(message.id) && | ||
| 979 | (message.turnId === undefined || | ||
| 980 | message.turnId === null || | ||
| 981 | retainedTurnIds.has(message.turnId)), | ||
| 982 | ) | ||
| 983 | .toSorted( | ||
| 984 | (left, right) => | ||
| 985 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | ||
| 986 | ) | ||
| 987 | .slice(0, missingAssistantCount); | ||
| 988 | for (const message of fallbackAssistantMessages) { | ||
| 989 | retainedMessageIds.add(message.id); | ||
| 990 | } | ||
| 991 | } | ||
| 992 | |||
| 993 | return messages.filter((message) => retainedMessageIds.has(message.id)); | ||
| 994 | } | ||
| 995 | |||
| 996 | function retainThreadActivitiesAfterRevert( | ||
| 997 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 998 | retainedTurnIds: ReadonlySet<string>, | ||
| 999 | ): OrchestrationThreadActivity[] { | ||
| 1000 | return activities.filter( | ||
| 1001 | (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), | ||
| 1002 | ); | ||
| 1003 | } | ||
| 1004 | |||
| 1005 | function retainThreadProposedPlansAfterRevert( | ||
| 1006 | proposedPlans: ReadonlyArray<ProposedPlan>, | ||
| 1007 | retainedTurnIds: ReadonlySet<string>, | ||
| 1008 | ): ProposedPlan[] { | ||
| 1009 | return proposedPlans.filter( | ||
| 1010 | (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), | ||
| 1011 | ); | ||
| 1012 | } | ||
| 1013 | |||
| 1014 | function toLegacySessionStatus( | ||
| 1015 | status: OrchestrationSessionStatus, | ||
| 1016 | ): "connecting" | "ready" | "running" | "error" | "closed" { | ||
| 1017 | switch (status) { | ||
| 1018 | case "starting": | ||
| 1019 | return "connecting"; | ||
| 1020 | case "running": | ||
| 1021 | return "running"; | ||
| 1022 | case "error": | ||
| 1023 | return "error"; | ||
| 1024 | case "ready": | ||
| 1025 | case "interrupted": | ||
| 1026 | return "ready"; | ||
| 1027 | case "idle": | ||
| 1028 | case "stopped": | ||
| 1029 | return "closed"; | ||
| 1030 | } | ||
| 1031 | } | ||
| 1032 | |||
| 1033 | function toLegacyProvider(providerName: string | null): ProviderDriverKind { | ||
| 1034 | if (isProviderDriverKindValue(providerName)) { | ||
| 1035 | return providerName; | ||
| 1036 | } | ||
| 1037 | return ProviderDriverKind.make("codex"); | ||
| 1038 | } | ||
| 1039 | |||
| 1040 | function updateThreadState( | ||
| 1041 | state: EnvironmentState, | ||
| 1042 | threadId: ThreadId, | ||
| 1043 | updater: (thread: Thread) => Thread, | ||
| 1044 | ): EnvironmentState { | ||
| 1045 | const currentThread = getThreadFromEnvironmentState(state, threadId); | ||
| 1046 | if (!currentThread) { | ||
| 1047 | return state; | ||
| 1048 | } | ||
| 1049 | const nextThread = updater(currentThread); | ||
| 1050 | if (nextThread === currentThread) { | ||
| 1051 | return state; | ||
| 1052 | } | ||
| 1053 | return writeThreadState(state, nextThread, currentThread); | ||
| 1054 | } | ||
| 1055 | |||
| 1056 | function buildProjectState( | ||
| 1057 | projects: ReadonlyArray<Project>, | ||
| 1058 | ): Pick<EnvironmentState, "projectIds" | "projectById"> { | ||
| 1059 | return { | ||
| 1060 | projectIds: projects.map((project) => project.id), | ||
| 1061 | projectById: Object.fromEntries( | ||
| 1062 | projects.map((project) => [project.id, project] as const), | ||
| 1063 | ) as Record<ProjectId, Project>, | ||
| 1064 | }; | ||
| 1065 | } | ||
| 1066 | |||
| 1067 | function getStoredEnvironmentState( | ||
| 1068 | state: AppState, | ||
| 1069 | environmentId: EnvironmentId, | ||
| 1070 | ): EnvironmentState { | ||
| 1071 | return state.environmentStateById[environmentId] ?? initialEnvironmentState; | ||
| 1072 | } | ||
| 1073 | |||
| 1074 | function commitEnvironmentState( | ||
| 1075 | state: AppState, | ||
| 1076 | environmentId: EnvironmentId, | ||
| 1077 | nextEnvironmentState: EnvironmentState, | ||
| 1078 | ): AppState { | ||
| 1079 | const currentEnvironmentState = state.environmentStateById[environmentId]; | ||
| 1080 | const environmentStateById = | ||
| 1081 | currentEnvironmentState === nextEnvironmentState | ||
| 1082 | ? state.environmentStateById | ||
| 1083 | : { | ||
| 1084 | ...state.environmentStateById, | ||
| 1085 | [environmentId]: nextEnvironmentState, | ||
| 1086 | }; | ||
| 1087 | |||
| 1088 | if (environmentStateById === state.environmentStateById) { | ||
| 1089 | return state; | ||
| 1090 | } | ||
| 1091 | |||
| 1092 | return { | ||
| 1093 | ...state, | ||
| 1094 | environmentStateById, | ||
| 1095 | }; | ||
| 1096 | } | ||
| 1097 | |||
| 1098 | function syncEnvironmentShellSnapshot( | ||
| 1099 | state: EnvironmentState, | ||
| 1100 | snapshot: OrchestrationShellSnapshot, | ||
| 1101 | environmentId: EnvironmentId, | ||
| 1102 | ): EnvironmentState { | ||
| 1103 | const nextProjects = snapshot.projects.map((project) => mapProject(project, environmentId)); | ||
| 1104 | const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); | ||
| 1105 | let nextState: EnvironmentState = { | ||
| 1106 | ...state, | ||
| 1107 | ...buildProjectState(nextProjects), | ||
| 1108 | threadIds: [], | ||
| 1109 | threadIdsByProjectId: {}, | ||
| 1110 | threadShellById: {}, | ||
| 1111 | threadSessionById: {}, | ||
| 1112 | threadTurnStateById: {}, | ||
| 1113 | sidebarThreadSummaryById: {}, | ||
| 1114 | messageIdsByThreadId: retainThreadScopedRecord(state.messageIdsByThreadId, nextThreadIds), | ||
| 1115 | messageByThreadId: retainThreadScopedRecord(state.messageByThreadId, nextThreadIds), | ||
| 1116 | activityIdsByThreadId: retainThreadScopedRecord(state.activityIdsByThreadId, nextThreadIds), | ||
| 1117 | activityByThreadId: retainThreadScopedRecord(state.activityByThreadId, nextThreadIds), | ||
| 1118 | proposedPlanIdsByThreadId: retainThreadScopedRecord( | ||
| 1119 | state.proposedPlanIdsByThreadId, | ||
| 1120 | nextThreadIds, | ||
| 1121 | ), | ||
| 1122 | proposedPlanByThreadId: retainThreadScopedRecord(state.proposedPlanByThreadId, nextThreadIds), | ||
| 1123 | turnDiffIdsByThreadId: retainThreadScopedRecord(state.turnDiffIdsByThreadId, nextThreadIds), | ||
| 1124 | turnDiffSummaryByThreadId: retainThreadScopedRecord( | ||
| 1125 | state.turnDiffSummaryByThreadId, | ||
| 1126 | nextThreadIds, | ||
| 1127 | ), | ||
| 1128 | bootstrapComplete: true, | ||
| 1129 | }; | ||
| 1130 | |||
| 1131 | for (const thread of snapshot.threads) { | ||
| 1132 | nextState = writeThreadShellState(nextState, mapThreadShell(thread, environmentId)); | ||
| 1133 | } | ||
| 1134 | |||
| 1135 | return nextState; | ||
| 1136 | } | ||
| 1137 | |||
| 1138 | export function syncServerShellSnapshot( | ||
| 1139 | state: AppState, | ||
| 1140 | snapshot: OrchestrationShellSnapshot, | ||
| 1141 | environmentId: EnvironmentId, | ||
| 1142 | ): AppState { | ||
| 1143 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 1144 | // Keep web-specific projection here only until the store can consume | ||
| 1145 | // createShellSnapshotManager or a shared adapter over its reducer. | ||
| 1146 | return commitEnvironmentState( | ||
| 1147 | state, | ||
| 1148 | environmentId, | ||
| 1149 | syncEnvironmentShellSnapshot( | ||
| 1150 | getStoredEnvironmentState(state, environmentId), | ||
| 1151 | snapshot, | ||
| 1152 | environmentId, | ||
| 1153 | ), | ||
| 1154 | ); | ||
| 1155 | } | ||
| 1156 | |||
| 1157 | export function syncServerThreadDetail( | ||
| 1158 | state: AppState, | ||
| 1159 | thread: OrchestrationThread, | ||
| 1160 | environmentId: EnvironmentId, | ||
| 1161 | ): AppState { | ||
| 1162 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 1163 | // Keep web-specific projection here only until the store can consume | ||
| 1164 | // createThreadDetailManager or a shared adapter over its reducer. | ||
| 1165 | const environmentState = getStoredEnvironmentState(state, environmentId); | ||
| 1166 | const previousThread = getThreadFromEnvironmentState(environmentState, thread.id); | ||
| 1167 | return commitEnvironmentState( | ||
| 1168 | state, | ||
| 1169 | environmentId, | ||
| 1170 | writeThreadState(environmentState, mapThread(thread, environmentId), previousThread), | ||
| 1171 | ); | ||
| 1172 | } | ||
| 1173 | |||
| 1174 | function applyEnvironmentOrchestrationEvent( | ||
| 1175 | state: EnvironmentState, | ||
| 1176 | event: OrchestrationEvent, | ||
| 1177 | environmentId: EnvironmentId, | ||
| 1178 | ): EnvironmentState { | ||
| 1179 | switch (event.type) { | ||
| 1180 | case "project.created": { | ||
| 1181 | const nextProject = mapProject( | ||
| 1182 | { | ||
| 1183 | id: event.payload.projectId, | ||
| 1184 | title: event.payload.title, | ||
| 1185 | workspaceRoot: event.payload.workspaceRoot, | ||
| 1186 | repositoryIdentity: event.payload.repositoryIdentity ?? null, | ||
| 1187 | defaultModelSelection: event.payload.defaultModelSelection, | ||
| 1188 | scripts: event.payload.scripts, | ||
| 1189 | createdAt: event.payload.createdAt, | ||
| 1190 | updatedAt: event.payload.updatedAt, | ||
| 1191 | deletedAt: null, | ||
| 1192 | }, | ||
| 1193 | environmentId, | ||
| 1194 | ); | ||
| 1195 | const existingProjectId = | ||
| 1196 | state.projectIds.find( | ||
| 1197 | (projectId) => | ||
| 1198 | projectId === event.payload.projectId || | ||
| 1199 | state.projectById[projectId]?.cwd === event.payload.workspaceRoot, | ||
| 1200 | ) ?? null; | ||
| 1201 | let projectById = state.projectById; | ||
| 1202 | let projectIds = state.projectIds; | ||
| 1203 | |||
| 1204 | if (existingProjectId !== null && existingProjectId !== nextProject.id) { | ||
| 1205 | const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; | ||
| 1206 | projectById = { | ||
| 1207 | ...restProjectById, | ||
| 1208 | [nextProject.id]: nextProject, | ||
| 1209 | }; | ||
| 1210 | projectIds = state.projectIds.map((projectId) => | ||
| 1211 | projectId === existingProjectId ? nextProject.id : projectId, | ||
| 1212 | ); | ||
| 1213 | } else { | ||
| 1214 | projectById = { | ||
| 1215 | ...state.projectById, | ||
| 1216 | [nextProject.id]: nextProject, | ||
| 1217 | }; | ||
| 1218 | projectIds = | ||
| 1219 | existingProjectId === null && !state.projectIds.includes(nextProject.id) | ||
| 1220 | ? [...state.projectIds, nextProject.id] | ||
| 1221 | : state.projectIds; | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | return { | ||
| 1225 | ...state, | ||
| 1226 | projectById, | ||
| 1227 | projectIds, | ||
| 1228 | }; | ||
| 1229 | } | ||
| 1230 | |||
| 1231 | case "project.meta-updated": { | ||
| 1232 | const project = state.projectById[event.payload.projectId]; | ||
| 1233 | if (!project) { | ||
| 1234 | return state; | ||
| 1235 | } | ||
| 1236 | const nextProject: Project = { | ||
| 1237 | ...project, | ||
| 1238 | ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), | ||
| 1239 | ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), | ||
| 1240 | ...(event.payload.repositoryIdentity !== undefined | ||
| 1241 | ? { repositoryIdentity: event.payload.repositoryIdentity ?? null } | ||
| 1242 | : {}), | ||
| 1243 | ...(event.payload.defaultModelSelection !== undefined | ||
| 1244 | ? { | ||
| 1245 | defaultModelSelection: event.payload.defaultModelSelection | ||
| 1246 | ? normalizeModelSelection(event.payload.defaultModelSelection) | ||
| 1247 | : null, | ||
| 1248 | } | ||
| 1249 | : {}), | ||
| 1250 | ...(event.payload.scripts !== undefined | ||
| 1251 | ? { scripts: mapProjectScripts(event.payload.scripts) } | ||
| 1252 | : {}), | ||
| 1253 | updatedAt: event.payload.updatedAt, | ||
| 1254 | }; | ||
| 1255 | return { | ||
| 1256 | ...state, | ||
| 1257 | projectById: { | ||
| 1258 | ...state.projectById, | ||
| 1259 | [event.payload.projectId]: nextProject, | ||
| 1260 | }, | ||
| 1261 | }; | ||
| 1262 | } | ||
| 1263 | |||
| 1264 | case "project.deleted": { | ||
| 1265 | if (!state.projectById[event.payload.projectId]) { | ||
| 1266 | return state; | ||
| 1267 | } | ||
| 1268 | const { [event.payload.projectId]: _removedProject, ...projectById } = state.projectById; | ||
| 1269 | return { | ||
| 1270 | ...state, | ||
| 1271 | projectById, | ||
| 1272 | projectIds: removeId(state.projectIds, event.payload.projectId), | ||
| 1273 | }; | ||
| 1274 | } | ||
| 1275 | |||
| 1276 | case "thread.created": { | ||
| 1277 | const previousThread = getThreadFromEnvironmentState(state, event.payload.threadId); | ||
| 1278 | const nextThread = mapThread( | ||
| 1279 | { | ||
| 1280 | id: event.payload.threadId, | ||
| 1281 | projectId: event.payload.projectId, | ||
| 1282 | title: event.payload.title, | ||
| 1283 | modelSelection: event.payload.modelSelection, | ||
| 1284 | runtimeMode: event.payload.runtimeMode, | ||
| 1285 | interactionMode: event.payload.interactionMode, | ||
| 1286 | branch: event.payload.branch, | ||
| 1287 | worktreePath: event.payload.worktreePath, | ||
| 1288 | latestTurn: null, | ||
| 1289 | createdAt: event.payload.createdAt, | ||
| 1290 | updatedAt: event.payload.updatedAt, | ||
| 1291 | archivedAt: null, | ||
| 1292 | deletedAt: null, | ||
| 1293 | messages: [], | ||
| 1294 | proposedPlans: [], | ||
| 1295 | activities: [], | ||
| 1296 | checkpoints: [], | ||
| 1297 | session: null, | ||
| 1298 | }, | ||
| 1299 | environmentId, | ||
| 1300 | ); | ||
| 1301 | return writeThreadState(state, nextThread, previousThread); | ||
| 1302 | } | ||
| 1303 | |||
| 1304 | case "thread.deleted": | ||
| 1305 | return removeThreadState(state, event.payload.threadId); | ||
| 1306 | |||
| 1307 | case "thread.archived": | ||
| 1308 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | ||
| 1309 | ...thread, | ||
| 1310 | archivedAt: event.payload.archivedAt, | ||
| 1311 | updatedAt: event.payload.updatedAt, | ||
| 1312 | })); | ||
| 1313 | |||
| 1314 | case "thread.unarchived": | ||
| 1315 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | ||
| 1316 | ...thread, | ||
| 1317 | archivedAt: null, | ||
| 1318 | updatedAt: event.payload.updatedAt, | ||
| 1319 | })); | ||
| 1320 | |||
| 1321 | case "thread.meta-updated": | ||
| 1322 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | ||
| 1323 | ...thread, | ||
| 1324 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | ||
| 1325 | ...(event.payload.modelSelection !== undefined | ||
| 1326 | ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } | ||
| 1327 | : {}), | ||
| 1328 | ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), | ||
| 1329 | ...(event.payload.worktreePath !== undefined | ||
| 1330 | ? { worktreePath: event.payload.worktreePath } | ||
| 1331 | : {}), | ||
| 1332 | updatedAt: event.payload.updatedAt, | ||
| 1333 | })); | ||
| 1334 | |||
| 1335 | case "thread.runtime-mode-set": | ||
| 1336 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | ||
| 1337 | ...thread, | ||
| 1338 | runtimeMode: event.payload.runtimeMode, | ||
| 1339 | updatedAt: event.payload.updatedAt, | ||
| 1340 | })); | ||
| 1341 | |||
| 1342 | case "thread.interaction-mode-set": | ||
| 1343 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | ||
| 1344 | ...thread, | ||
| 1345 | interactionMode: event.payload.interactionMode, | ||
| 1346 | updatedAt: event.payload.updatedAt, | ||
| 1347 | })); | ||
| 1348 | |||
| 1349 | case "thread.turn-start-requested": | ||
| 1350 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | ||
| 1351 | ...thread, | ||
| 1352 | ...(event.payload.modelSelection !== undefined | ||
| 1353 | ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } | ||
| 1354 | : {}), | ||
| 1355 | runtimeMode: event.payload.runtimeMode, | ||
| 1356 | interactionMode: event.payload.interactionMode, | ||
| 1357 | pendingSourceProposedPlan: event.payload.sourceProposedPlan, | ||
| 1358 | updatedAt: event.occurredAt, | ||
| 1359 | })); | ||
| 1360 | |||
| 1361 | case "thread.turn-interrupt-requested": { | ||
| 1362 | if (event.payload.turnId === undefined) { | ||
| 1363 | return state; | ||
| 1364 | } | ||
| 1365 | return updateThreadState(state, event.payload.threadId, (thread) => { | ||
| 1366 | const latestTurn = thread.latestTurn; | ||
| 1367 | if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { | ||
| 1368 | return thread; | ||
| 1369 | } | ||
| 1370 | return { | ||
| 1371 | ...thread, | ||
| 1372 | latestTurn: buildLatestTurn({ | ||
| 1373 | previous: latestTurn, | ||
| 1374 | turnId: event.payload.turnId, | ||
| 1375 | state: "interrupted", | ||
| 1376 | requestedAt: latestTurn.requestedAt, | ||
| 1377 | startedAt: latestTurn.startedAt ?? event.payload.createdAt, | ||
| 1378 | completedAt: latestTurn.completedAt ?? event.payload.createdAt, | ||
| 1379 | assistantMessageId: latestTurn.assistantMessageId, | ||
| 1380 | }), | ||
| 1381 | updatedAt: event.occurredAt, | ||
| 1382 | }; | ||
| 1383 | }); | ||
| 1384 | } | ||
| 1385 | |||
| 1386 | case "thread.message-sent": | ||
| 1387 | return updateThreadState(state, event.payload.threadId, (thread) => { | ||
| 1388 | const message = mapMessage(thread.environmentId, { | ||
| 1389 | id: event.payload.messageId, | ||
| 1390 | role: event.payload.role, | ||
| 1391 | text: event.payload.text, | ||
| 1392 | ...(event.payload.attachments !== undefined | ||
| 1393 | ? { attachments: event.payload.attachments } | ||
| 1394 | : {}), | ||
| 1395 | turnId: event.payload.turnId, | ||
| 1396 | streaming: event.payload.streaming, | ||
| 1397 | createdAt: event.payload.createdAt, | ||
| 1398 | updatedAt: event.payload.updatedAt, | ||
| 1399 | }); | ||
| 1400 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); | ||
| 1401 | const messages = existingMessage | ||
| 1402 | ? thread.messages.map((entry) => | ||
| 1403 | entry.id !== message.id | ||
| 1404 | ? entry | ||
| 1405 | : { | ||
| 1406 | ...entry, | ||
| 1407 | text: message.streaming | ||
| 1408 | ? `${entry.text}${message.text}` | ||
| 1409 | : message.text.length > 0 | ||
| 1410 | ? message.text | ||
| 1411 | : entry.text, | ||
| 1412 | streaming: message.streaming, | ||
| 1413 | ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), | ||
| 1414 | ...(message.streaming | ||
| 1415 | ? entry.completedAt !== undefined | ||
| 1416 | ? { completedAt: entry.completedAt } | ||
| 1417 | : {} | ||
| 1418 | : message.completedAt !== undefined | ||
| 1419 | ? { completedAt: message.completedAt } | ||
| 1420 | : {}), | ||
| 1421 | ...(message.attachments !== undefined | ||
| 1422 | ? { attachments: message.attachments } | ||
| 1423 | : {}), | ||
| 1424 | }, | ||
| 1425 | ) | ||
| 1426 | : [...thread.messages, message]; | ||
| 1427 | const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); | ||
| 1428 | const turnDiffSummaries = | ||
| 1429 | event.payload.role === "assistant" && event.payload.turnId !== null | ||
| 1430 | ? rebindTurnDiffSummariesForAssistantMessage( | ||
| 1431 | thread.turnDiffSummaries, | ||
| 1432 | event.payload.turnId, | ||
| 1433 | event.payload.messageId, | ||
| 1434 | ) | ||
| 1435 | : thread.turnDiffSummaries; | ||
| 1436 | // A completed assistant message only settles the turn once the | ||
| 1437 | // session is no longer running it — providers may emit several | ||
| 1438 | // assistant messages per turn (commentary between tool calls), and | ||
| 1439 | // the turn must stay unsettled until the provider reports turn end. | ||
| 1440 | const turnStillRunning = | ||
| 1441 | event.payload.turnId !== null && | ||
| 1442 | thread.session?.orchestrationStatus === "running" && | ||
| 1443 | thread.session.activeTurnId === event.payload.turnId; | ||
| 1444 | const settlesTurn = !event.payload.streaming && !turnStillRunning; | ||
| 1445 | const latestTurn: Thread["latestTurn"] = | ||
| 1446 | event.payload.role === "assistant" && | ||
| 1447 | event.payload.turnId !== null && | ||
| 1448 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | ||
| 1449 | ? buildLatestTurn({ | ||
| 1450 | previous: thread.latestTurn, | ||
| 1451 | turnId: event.payload.turnId, | ||
| 1452 | state: settlesTurn | ||
| 1453 | ? thread.latestTurn?.state === "interrupted" | ||
| 1454 | ? "interrupted" | ||
| 1455 | : thread.latestTurn?.state === "error" | ||
| 1456 | ? "error" | ||
| 1457 | : "completed" | ||
| 1458 | : "running", | ||
| 1459 | requestedAt: | ||
| 1460 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 1461 | ? thread.latestTurn.requestedAt | ||
| 1462 | : event.payload.createdAt, | ||
| 1463 | startedAt: | ||
| 1464 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 1465 | ? (thread.latestTurn.startedAt ?? event.payload.createdAt) | ||
| 1466 | : event.payload.createdAt, | ||
| 1467 | sourceProposedPlan: thread.pendingSourceProposedPlan, | ||
| 1468 | completedAt: settlesTurn | ||
| 1469 | ? event.payload.updatedAt | ||
| 1470 | : thread.latestTurn?.turnId === event.payload.turnId | ||
| 1471 | ? (thread.latestTurn.completedAt ?? null) | ||
| 1472 | : null, | ||
| 1473 | assistantMessageId: event.payload.messageId, | ||
| 1474 | }) | ||
| 1475 | : thread.latestTurn; | ||
| 1476 | return { | ||
| 1477 | ...thread, | ||
| 1478 | messages: cappedMessages, | ||
| 1479 | turnDiffSummaries, | ||
| 1480 | latestTurn, | ||
| 1481 | updatedAt: event.occurredAt, | ||
| 1482 | }; | ||
| 1483 | }); | ||
| 1484 | |||
| 1485 | case "thread.session-set": | ||
| 1486 | return updateThreadState(state, event.payload.threadId, (thread) => { | ||
| 1487 | // Leaving the "running" session status is the turn-end signal: | ||
| 1488 | // settle a still-running latest turn so its duration reflects the | ||
| 1489 | // whole turn, not the last assistant message. | ||
| 1490 | const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); | ||
| 1491 | const latestTurn: Thread["latestTurn"] = | ||
| 1492 | event.payload.session.status === "running" && event.payload.session.activeTurnId !== null | ||
| 1493 | ? buildLatestTurn({ | ||
| 1494 | previous: thread.latestTurn, | ||
| 1495 | turnId: event.payload.session.activeTurnId, | ||
| 1496 | state: "running", | ||
| 1497 | requestedAt: | ||
| 1498 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 1499 | ? thread.latestTurn.requestedAt | ||
| 1500 | : event.payload.session.updatedAt, | ||
| 1501 | startedAt: | ||
| 1502 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 1503 | ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) | ||
| 1504 | : event.payload.session.updatedAt, | ||
| 1505 | completedAt: null, | ||
| 1506 | assistantMessageId: | ||
| 1507 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 1508 | ? thread.latestTurn.assistantMessageId | ||
| 1509 | : null, | ||
| 1510 | sourceProposedPlan: thread.pendingSourceProposedPlan, | ||
| 1511 | }) | ||
| 1512 | : thread.latestTurn !== null && | ||
| 1513 | thread.latestTurn.state === "running" && | ||
| 1514 | settledTurnState !== null | ||
| 1515 | ? buildLatestTurn({ | ||
| 1516 | previous: thread.latestTurn, | ||
| 1517 | turnId: thread.latestTurn.turnId, | ||
| 1518 | state: settledTurnState, | ||
| 1519 | requestedAt: thread.latestTurn.requestedAt, | ||
| 1520 | startedAt: thread.latestTurn.startedAt, | ||
| 1521 | // A running turn's completedAt can only hold a mid-turn | ||
| 1522 | // placeholder checkpoint timestamp — the session leaving | ||
| 1523 | // "running" is the authoritative turn end. | ||
| 1524 | completedAt: event.payload.session.updatedAt, | ||
| 1525 | assistantMessageId: thread.latestTurn.assistantMessageId, | ||
| 1526 | }) | ||
| 1527 | : thread.latestTurn; | ||
| 1528 | return { | ||
| 1529 | ...thread, | ||
| 1530 | session: mapSession(event.payload.session), | ||
| 1531 | error: sanitizeThreadErrorMessage(event.payload.session.lastError), | ||
| 1532 | latestTurn, | ||
| 1533 | updatedAt: event.occurredAt, | ||
| 1534 | }; | ||
| 1535 | }); | ||
| 1536 | |||
| 1537 | case "thread.session-stop-requested": | ||
| 1538 | return updateThreadState(state, event.payload.threadId, (thread) => | ||
| 1539 | thread.session === null | ||
| 1540 | ? thread | ||
| 1541 | : { | ||
| 1542 | ...thread, | ||
| 1543 | session: { | ||
| 1544 | ...thread.session, | ||
| 1545 | status: "closed", | ||
| 1546 | orchestrationStatus: "stopped", | ||
| 1547 | activeTurnId: undefined, | ||
| 1548 | updatedAt: event.payload.createdAt, | ||
| 1549 | }, | ||
| 1550 | updatedAt: event.occurredAt, | ||
| 1551 | }, | ||
| 1552 | ); | ||
| 1553 | |||
| 1554 | case "thread.proposed-plan-upserted": | ||
| 1555 | return updateThreadState(state, event.payload.threadId, (thread) => { | ||
| 1556 | const proposedPlan = mapProposedPlan(event.payload.proposedPlan); | ||
| 1557 | const proposedPlans = [ | ||
| 1558 | ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id), | ||
| 1559 | proposedPlan, | ||
| 1560 | ] | ||
| 1561 | .toSorted( | ||
| 1562 | (left, right) => | ||
| 1563 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | ||
| 1564 | ) | ||
| 1565 | .slice(-MAX_THREAD_PROPOSED_PLANS); | ||
| 1566 | return { | ||
| 1567 | ...thread, | ||
| 1568 | proposedPlans, | ||
| 1569 | updatedAt: event.occurredAt, | ||
| 1570 | }; | ||
| 1571 | }); | ||
| 1572 | |||
| 1573 | case "thread.turn-diff-completed": | ||
| 1574 | return updateThreadState(state, event.payload.threadId, (thread) => { | ||
| 1575 | const checkpoint = mapTurnDiffSummary({ | ||
| 1576 | turnId: event.payload.turnId, | ||
| 1577 | checkpointTurnCount: event.payload.checkpointTurnCount, | ||
| 1578 | checkpointRef: event.payload.checkpointRef, | ||
| 1579 | status: event.payload.status, | ||
| 1580 | files: event.payload.files, | ||
| 1581 | assistantMessageId: event.payload.assistantMessageId, | ||
| 1582 | completedAt: event.payload.completedAt, | ||
| 1583 | }); | ||
| 1584 | const existing = thread.turnDiffSummaries.find( | ||
| 1585 | (entry) => entry.turnId === checkpoint.turnId, | ||
| 1586 | ); | ||
| 1587 | if (existing && existing.status !== "missing" && checkpoint.status === "missing") { | ||
| 1588 | return thread; | ||
| 1589 | } | ||
| 1590 | const turnDiffSummaries = [ | ||
| 1591 | ...thread.turnDiffSummaries.filter((entry) => entry.turnId !== checkpoint.turnId), | ||
| 1592 | checkpoint, | ||
| 1593 | ] | ||
| 1594 | .toSorted( | ||
| 1595 | (left, right) => | ||
| 1596 | (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - | ||
| 1597 | (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), | ||
| 1598 | ) | ||
| 1599 | .slice(-MAX_THREAD_CHECKPOINTS); | ||
| 1600 | // Mid-turn diff updates produce placeholder checkpoints; record the | ||
| 1601 | // diff summary, but don't settle a turn its session is still running. | ||
| 1602 | const turnStillRunning = | ||
| 1603 | thread.session?.orchestrationStatus === "running" && | ||
| 1604 | thread.session.activeTurnId === event.payload.turnId; | ||
| 1605 | const latestTurn = | ||
| 1606 | !turnStillRunning && | ||
| 1607 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | ||
| 1608 | ? buildLatestTurn({ | ||
| 1609 | previous: thread.latestTurn, | ||
| 1610 | turnId: event.payload.turnId, | ||
| 1611 | state: checkpointStatusToLatestTurnState(event.payload.status), | ||
| 1612 | requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, | ||
| 1613 | startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, | ||
| 1614 | completedAt: event.payload.completedAt, | ||
| 1615 | assistantMessageId: event.payload.assistantMessageId, | ||
| 1616 | sourceProposedPlan: thread.pendingSourceProposedPlan, | ||
| 1617 | }) | ||
| 1618 | : thread.latestTurn; | ||
| 1619 | return { | ||
| 1620 | ...thread, | ||
| 1621 | turnDiffSummaries, | ||
| 1622 | latestTurn, | ||
| 1623 | updatedAt: event.occurredAt, | ||
| 1624 | }; | ||
| 1625 | }); | ||
| 1626 | |||
| 1627 | case "thread.reverted": | ||
| 1628 | return updateThreadState(state, event.payload.threadId, (thread) => { | ||
| 1629 | const turnDiffSummaries = thread.turnDiffSummaries | ||
| 1630 | .filter( | ||
| 1631 | (entry) => | ||
| 1632 | entry.checkpointTurnCount !== undefined && | ||
| 1633 | entry.checkpointTurnCount <= event.payload.turnCount, | ||
| 1634 | ) | ||
| 1635 | .toSorted( | ||
| 1636 | (left, right) => | ||
| 1637 | (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - | ||
| 1638 | (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), | ||
| 1639 | ) | ||
| 1640 | .slice(-MAX_THREAD_CHECKPOINTS); | ||
| 1641 | const retainedTurnIds = new Set(turnDiffSummaries.map((entry) => entry.turnId)); | ||
| 1642 | const messages = retainThreadMessagesAfterRevert( | ||
| 1643 | thread.messages, | ||
| 1644 | retainedTurnIds, | ||
| 1645 | event.payload.turnCount, | ||
| 1646 | ).slice(-MAX_THREAD_MESSAGES); | ||
| 1647 | const proposedPlans = retainThreadProposedPlansAfterRevert( | ||
| 1648 | thread.proposedPlans, | ||
| 1649 | retainedTurnIds, | ||
| 1650 | ).slice(-MAX_THREAD_PROPOSED_PLANS); | ||
| 1651 | const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); | ||
| 1652 | const latestCheckpoint = turnDiffSummaries.at(-1) ?? null; | ||
| 1653 | |||
| 1654 | return { | ||
| 1655 | ...thread, | ||
| 1656 | turnDiffSummaries, | ||
| 1657 | messages, | ||
| 1658 | proposedPlans, | ||
| 1659 | activities, | ||
| 1660 | pendingSourceProposedPlan: undefined, | ||
| 1661 | latestTurn: | ||
| 1662 | latestCheckpoint === null | ||
| 1663 | ? null | ||
| 1664 | : { | ||
| 1665 | turnId: latestCheckpoint.turnId, | ||
| 1666 | state: checkpointStatusToLatestTurnState( | ||
| 1667 | (latestCheckpoint.status ?? "ready") as "ready" | "missing" | "error", | ||
| 1668 | ), | ||
| 1669 | requestedAt: latestCheckpoint.completedAt, | ||
| 1670 | startedAt: latestCheckpoint.completedAt, | ||
| 1671 | completedAt: latestCheckpoint.completedAt, | ||
| 1672 | assistantMessageId: latestCheckpoint.assistantMessageId ?? null, | ||
| 1673 | }, | ||
| 1674 | updatedAt: event.occurredAt, | ||
| 1675 | }; | ||
| 1676 | }); | ||
| 1677 | |||
| 1678 | case "thread.activity-appended": | ||
| 1679 | return updateThreadState(state, event.payload.threadId, (thread) => { | ||
| 1680 | const activities = [ | ||
| 1681 | ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), | ||
| 1682 | { ...event.payload.activity }, | ||
| 1683 | ] | ||
| 1684 | .toSorted(compareActivities) | ||
| 1685 | .slice(-MAX_THREAD_ACTIVITIES); | ||
| 1686 | return { | ||
| 1687 | ...thread, | ||
| 1688 | activities, | ||
| 1689 | updatedAt: event.occurredAt, | ||
| 1690 | }; | ||
| 1691 | }); | ||
| 1692 | |||
| 1693 | case "thread.approval-response-requested": | ||
| 1694 | case "thread.user-input-response-requested": | ||
| 1695 | return state; | ||
| 1696 | } | ||
| 1697 | |||
| 1698 | return state; | ||
| 1699 | } | ||
| 1700 | |||
| 1701 | function applyEnvironmentShellEvent( | ||
| 1702 | state: EnvironmentState, | ||
| 1703 | event: OrchestrationShellStreamEvent, | ||
| 1704 | environmentId: EnvironmentId, | ||
| 1705 | ): EnvironmentState { | ||
| 1706 | switch (event.kind) { | ||
| 1707 | case "project-upserted": { | ||
| 1708 | const nextProject = mapProject(event.project, environmentId); | ||
| 1709 | const existingProjectId = | ||
| 1710 | state.projectIds.find( | ||
| 1711 | (projectId) => | ||
| 1712 | projectId === event.project.id || | ||
| 1713 | state.projectById[projectId]?.cwd === event.project.workspaceRoot, | ||
| 1714 | ) ?? null; | ||
| 1715 | let projectById = state.projectById; | ||
| 1716 | let projectIds = state.projectIds; | ||
| 1717 | |||
| 1718 | if (existingProjectId !== null && existingProjectId !== nextProject.id) { | ||
| 1719 | const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; | ||
| 1720 | projectById = { | ||
| 1721 | ...restProjectById, | ||
| 1722 | [nextProject.id]: nextProject, | ||
| 1723 | }; | ||
| 1724 | projectIds = state.projectIds.map((projectId) => | ||
| 1725 | projectId === existingProjectId ? nextProject.id : projectId, | ||
| 1726 | ); | ||
| 1727 | } else { | ||
| 1728 | projectById = { | ||
| 1729 | ...state.projectById, | ||
| 1730 | [nextProject.id]: nextProject, | ||
| 1731 | }; | ||
| 1732 | projectIds = | ||
| 1733 | existingProjectId === null && !state.projectIds.includes(nextProject.id) | ||
| 1734 | ? [...state.projectIds, nextProject.id] | ||
| 1735 | : state.projectIds; | ||
| 1736 | } | ||
| 1737 | |||
| 1738 | return { | ||
| 1739 | ...state, | ||
| 1740 | projectById, | ||
| 1741 | projectIds, | ||
| 1742 | }; | ||
| 1743 | } | ||
| 1744 | case "project-removed": { | ||
| 1745 | if (!state.projectById[event.projectId]) { | ||
| 1746 | return state; | ||
| 1747 | } | ||
| 1748 | const { [event.projectId]: _removedProject, ...projectById } = state.projectById; | ||
| 1749 | return { | ||
| 1750 | ...state, | ||
| 1751 | projectById, | ||
| 1752 | projectIds: removeId(state.projectIds, event.projectId), | ||
| 1753 | }; | ||
| 1754 | } | ||
| 1755 | case "thread-upserted": | ||
| 1756 | return writeThreadShellState(state, mapThreadShell(event.thread, environmentId)); | ||
| 1757 | case "thread-removed": | ||
| 1758 | return removeThreadState(state, event.threadId); | ||
| 1759 | } | ||
| 1760 | } | ||
| 1761 | |||
| 1762 | export function applyOrchestrationEvents( | ||
| 1763 | state: AppState, | ||
| 1764 | events: ReadonlyArray<OrchestrationEvent>, | ||
| 1765 | environmentId: EnvironmentId, | ||
| 1766 | ): AppState { | ||
| 1767 | if (events.length === 0) { | ||
| 1768 | return state; | ||
| 1769 | } | ||
| 1770 | const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); | ||
| 1771 | const nextEnvironmentState = events.reduce( | ||
| 1772 | (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), | ||
| 1773 | currentEnvironmentState, | ||
| 1774 | ); | ||
| 1775 | return commitEnvironmentState(state, environmentId, nextEnvironmentState); | ||
| 1776 | } | ||
| 1777 | |||
| 1778 | function getEnvironmentEntries( | ||
| 1779 | state: AppState, | ||
| 1780 | ): ReadonlyArray<readonly [EnvironmentId, EnvironmentState]> { | ||
| 1781 | return Object.entries(state.environmentStateById) as unknown as ReadonlyArray< | ||
| 1782 | readonly [EnvironmentId, EnvironmentState] | ||
| 1783 | >; | ||
| 1784 | } | ||
| 1785 | |||
| 1786 | export function selectEnvironmentState( | ||
| 1787 | state: AppState, | ||
| 1788 | environmentId: EnvironmentId | null | undefined, | ||
| 1789 | ): EnvironmentState { | ||
| 1790 | return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState; | ||
| 1791 | } | ||
| 1792 | |||
| 1793 | export function selectProjectsForEnvironment( | ||
| 1794 | state: AppState, | ||
| 1795 | environmentId: EnvironmentId | null | undefined, | ||
| 1796 | ): Project[] { | ||
| 1797 | return getProjects(selectEnvironmentState(state, environmentId)); | ||
| 1798 | } | ||
| 1799 | |||
| 1800 | export function selectThreadsForEnvironment( | ||
| 1801 | state: AppState, | ||
| 1802 | environmentId: EnvironmentId | null | undefined, | ||
| 1803 | ): Thread[] { | ||
| 1804 | return getThreads(selectEnvironmentState(state, environmentId)); | ||
| 1805 | } | ||
| 1806 | |||
| 1807 | export function selectProjectsAcrossEnvironments(state: AppState): Project[] { | ||
| 1808 | return getEnvironmentEntries(state).flatMap(([, environmentState]) => | ||
| 1809 | getProjects(environmentState), | ||
| 1810 | ); | ||
| 1811 | } | ||
| 1812 | |||
| 1813 | export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { | ||
| 1814 | return getEnvironmentEntries(state).flatMap(([, environmentState]) => | ||
| 1815 | getThreads(environmentState), | ||
| 1816 | ); | ||
| 1817 | } | ||
| 1818 | |||
| 1819 | /** Like `selectThreadsAcrossEnvironments` but returns stable `ThreadShell` references from the store (no derived data). */ | ||
| 1820 | export function selectThreadShellsAcrossEnvironments(state: AppState): ThreadShell[] { | ||
| 1821 | return getEnvironmentEntries(state).flatMap(([, environmentState]) => | ||
| 1822 | environmentState.threadIds.flatMap((threadId) => { | ||
| 1823 | const shell = environmentState.threadShellById[threadId]; | ||
| 1824 | return shell ? [shell] : []; | ||
| 1825 | }), | ||
| 1826 | ); | ||
| 1827 | } | ||
| 1828 | |||
| 1829 | export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { | ||
| 1830 | return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => | ||
| 1831 | environmentState.threadIds.flatMap((threadId) => { | ||
| 1832 | const thread = environmentState.sidebarThreadSummaryById[threadId]; | ||
| 1833 | return thread && thread.environmentId === environmentId ? [thread] : []; | ||
| 1834 | }), | ||
| 1835 | ); | ||
| 1836 | } | ||
| 1837 | |||
| 1838 | export function selectSidebarThreadsForProjectRef( | ||
| 1839 | state: AppState, | ||
| 1840 | ref: ScopedProjectRef | null | undefined, | ||
| 1841 | ): SidebarThreadSummary[] { | ||
| 1842 | if (!ref) { | ||
| 1843 | return []; | ||
| 1844 | } | ||
| 1845 | |||
| 1846 | const environmentState = selectEnvironmentState(state, ref.environmentId); | ||
| 1847 | const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? EMPTY_THREAD_IDS; | ||
| 1848 | return threadIds.flatMap((threadId) => { | ||
| 1849 | const thread = environmentState.sidebarThreadSummaryById[threadId]; | ||
| 1850 | return thread ? [thread] : []; | ||
| 1851 | }); | ||
| 1852 | } | ||
| 1853 | |||
| 1854 | export function selectSidebarThreadsForProjectRefs( | ||
| 1855 | state: AppState, | ||
| 1856 | refs: readonly ScopedProjectRef[], | ||
| 1857 | ): SidebarThreadSummary[] { | ||
| 1858 | if (refs.length === 0) return []; | ||
| 1859 | if (refs.length === 1) return selectSidebarThreadsForProjectRef(state, refs[0]); | ||
| 1860 | return refs.flatMap((ref) => selectSidebarThreadsForProjectRef(state, ref)); | ||
| 1861 | } | ||
| 1862 | |||
| 1863 | export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { | ||
| 1864 | return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; | ||
| 1865 | } | ||
| 1866 | |||
| 1867 | export function selectProjectByRef( | ||
| 1868 | state: AppState, | ||
| 1869 | ref: ScopedProjectRef | null | undefined, | ||
| 1870 | ): Project | undefined { | ||
| 1871 | return ref | ||
| 1872 | ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] | ||
| 1873 | : undefined; | ||
| 1874 | } | ||
| 1875 | |||
| 1876 | export function selectThreadByRef( | ||
| 1877 | state: AppState, | ||
| 1878 | ref: ScopedThreadRef | null | undefined, | ||
| 1879 | ): Thread | undefined { | ||
| 1880 | return ref | ||
| 1881 | ? getThreadFromEnvironmentState(selectEnvironmentState(state, ref.environmentId), ref.threadId) | ||
| 1882 | : undefined; | ||
| 1883 | } | ||
| 1884 | |||
| 1885 | export function selectThreadExistsByRef( | ||
| 1886 | state: AppState, | ||
| 1887 | ref: ScopedThreadRef | null | undefined, | ||
| 1888 | ): boolean { | ||
| 1889 | return ref | ||
| 1890 | ? selectEnvironmentState(state, ref.environmentId).threadShellById[ref.threadId] !== undefined | ||
| 1891 | : false; | ||
| 1892 | } | ||
| 1893 | |||
| 1894 | export function selectSidebarThreadSummaryByRef( | ||
| 1895 | state: AppState, | ||
| 1896 | ref: ScopedThreadRef | null | undefined, | ||
| 1897 | ): SidebarThreadSummary | undefined { | ||
| 1898 | return ref | ||
| 1899 | ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] | ||
| 1900 | : undefined; | ||
| 1901 | } | ||
| 1902 | |||
| 1903 | export function selectThreadIdsByProjectRef( | ||
| 1904 | state: AppState, | ||
| 1905 | ref: ScopedProjectRef | null | undefined, | ||
| 1906 | ): ThreadId[] { | ||
| 1907 | return ref | ||
| 1908 | ? (selectEnvironmentState(state, ref.environmentId).threadIdsByProjectId[ref.projectId] ?? | ||
| 1909 | EMPTY_THREAD_IDS) | ||
| 1910 | : EMPTY_THREAD_IDS; | ||
| 1911 | } | ||
| 1912 | |||
| 1913 | export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { | ||
| 1914 | if (state.activeEnvironmentId === null) { | ||
| 1915 | return state; | ||
| 1916 | } | ||
| 1917 | |||
| 1918 | const nextEnvironmentState = updateThreadState( | ||
| 1919 | getStoredEnvironmentState(state, state.activeEnvironmentId), | ||
| 1920 | threadId, | ||
| 1921 | (thread) => { | ||
| 1922 | if (thread.error === error) return thread; | ||
| 1923 | return { ...thread, error }; | ||
| 1924 | }, | ||
| 1925 | ); | ||
| 1926 | return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); | ||
| 1927 | } | ||
| 1928 | |||
| 1929 | export function applyOrchestrationEvent( | ||
| 1930 | state: AppState, | ||
| 1931 | event: OrchestrationEvent, | ||
| 1932 | environmentId: EnvironmentId, | ||
| 1933 | ): AppState { | ||
| 1934 | return commitEnvironmentState( | ||
| 1935 | state, | ||
| 1936 | environmentId, | ||
| 1937 | applyEnvironmentOrchestrationEvent( | ||
| 1938 | getStoredEnvironmentState(state, environmentId), | ||
| 1939 | event, | ||
| 1940 | environmentId, | ||
| 1941 | ), | ||
| 1942 | ); | ||
| 1943 | } | ||
| 1944 | |||
| 1945 | export function applyShellEvent( | ||
| 1946 | state: AppState, | ||
| 1947 | event: OrchestrationShellStreamEvent, | ||
| 1948 | environmentId: EnvironmentId, | ||
| 1949 | ): AppState { | ||
| 1950 | return commitEnvironmentState( | ||
| 1951 | state, | ||
| 1952 | environmentId, | ||
| 1953 | applyEnvironmentShellEvent( | ||
| 1954 | getStoredEnvironmentState(state, environmentId), | ||
| 1955 | event, | ||
| 1956 | environmentId, | ||
| 1957 | ), | ||
| 1958 | ); | ||
| 1959 | } | ||
| 1960 | |||
| 1961 | export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { | ||
| 1962 | if (state.activeEnvironmentId === environmentId) { | ||
| 1963 | return state; | ||
| 1964 | } | ||
| 1965 | |||
| 1966 | return { | ||
| 1967 | ...state, | ||
| 1968 | activeEnvironmentId: environmentId, | ||
| 1969 | }; | ||
| 1970 | } | ||
| 1971 | |||
| 1972 | export function removeEnvironmentState(state: AppState, environmentId: EnvironmentId): AppState { | ||
| 1973 | if (!state.environmentStateById[environmentId] && state.activeEnvironmentId !== environmentId) { | ||
| 1974 | return state; | ||
| 1975 | } | ||
| 1976 | |||
| 1977 | const { [environmentId]: _removed, ...environmentStateById } = state.environmentStateById; | ||
| 1978 | return { | ||
| 1979 | ...state, | ||
| 1980 | activeEnvironmentId: | ||
| 1981 | state.activeEnvironmentId === environmentId ? null : state.activeEnvironmentId, | ||
| 1982 | environmentStateById, | ||
| 1983 | }; | ||
| 1984 | } | ||
| 1985 | |||
| 1986 | export function setThreadBranch( | ||
| 1987 | state: AppState, | ||
| 1988 | threadRef: ScopedThreadRef, | ||
| 1989 | branch: string | null, | ||
| 1990 | worktreePath: string | null, | ||
| 1991 | ): AppState { | ||
| 1992 | const nextEnvironmentState = updateThreadState( | ||
| 1993 | getStoredEnvironmentState(state, threadRef.environmentId), | ||
| 1994 | threadRef.threadId, | ||
| 1995 | (thread) => { | ||
| 1996 | if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; | ||
| 1997 | const cwdChanged = thread.worktreePath !== worktreePath; | ||
| 1998 | return { | ||
| 1999 | ...thread, | ||
| 2000 | branch, | ||
| 2001 | worktreePath, | ||
| 2002 | ...(cwdChanged ? { session: null } : {}), | ||
| 2003 | }; | ||
| 2004 | }, | ||
| 2005 | ); | ||
| 2006 | return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); | ||
| 2007 | } | ||
| 2008 | |||
| 2009 | interface AppStore extends AppState { | ||
| 2010 | setActiveEnvironmentId: (environmentId: EnvironmentId) => void; | ||
| 2011 | removeEnvironmentState: (environmentId: EnvironmentId) => void; | ||
| 2012 | syncServerShellSnapshot: ( | ||
| 2013 | snapshot: OrchestrationShellSnapshot, | ||
| 2014 | environmentId: EnvironmentId, | ||
| 2015 | ) => void; | ||
| 2016 | syncServerThreadDetail: (thread: OrchestrationThread, environmentId: EnvironmentId) => void; | ||
| 2017 | applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; | ||
| 2018 | applyOrchestrationEvents: ( | ||
| 2019 | events: ReadonlyArray<OrchestrationEvent>, | ||
| 2020 | environmentId: EnvironmentId, | ||
| 2021 | ) => void; | ||
| 2022 | applyShellEvent: (event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) => void; | ||
| 2023 | setError: (threadId: ThreadId, error: string | null) => void; | ||
| 2024 | setThreadBranch: ( | ||
| 2025 | threadRef: ScopedThreadRef, | ||
| 2026 | branch: string | null, | ||
| 2027 | worktreePath: string | null, | ||
| 2028 | ) => void; | ||
| 2029 | } | ||
| 2030 | |||
| 2031 | export const useStore = create<AppStore>((set) => ({ | ||
| 2032 | ...initialState, | ||
| 2033 | setActiveEnvironmentId: (environmentId) => | ||
| 2034 | set((state) => setActiveEnvironmentId(state, environmentId)), | ||
| 2035 | removeEnvironmentState: (environmentId) => | ||
| 2036 | set((state) => removeEnvironmentState(state, environmentId)), | ||
| 2037 | syncServerShellSnapshot: (snapshot, environmentId) => | ||
| 2038 | set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), | ||
| 2039 | syncServerThreadDetail: (thread, environmentId) => | ||
| 2040 | set((state) => syncServerThreadDetail(state, thread, environmentId)), | ||
| 2041 | applyOrchestrationEvent: (event, environmentId) => | ||
| 2042 | set((state) => applyOrchestrationEvent(state, event, environmentId)), | ||
| 2043 | applyOrchestrationEvents: (events, environmentId) => | ||
| 2044 | set((state) => applyOrchestrationEvents(state, events, environmentId)), | ||
| 2045 | applyShellEvent: (event, environmentId) => | ||
| 2046 | set((state) => applyShellEvent(state, event, environmentId)), | ||
| 2047 | setError: (threadId, error) => set((state) => setError(state, threadId, error)), | ||
| 2048 | setThreadBranch: (threadRef, branch, worktreePath) => | ||
| 2049 | set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), | ||
| 2050 | })); | ||
apps/web/src/storeSelectors.ts deleted-68| ... | @@ -1,68 +0,0 @@ | ||
| 1 | import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; | ||
| 3 | import { type Project, type Thread } from "./types"; | ||
| 4 | import { getThreadFromEnvironmentState } from "./threadDerivation"; | ||
| 5 | |||
| 6 | export function createProjectSelectorByRef( | ||
| 7 | ref: ScopedProjectRef | null | undefined, | ||
| 8 | ): (state: AppState) => Project | undefined { | ||
| 9 | return (state) => | ||
| 10 | ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined; | ||
| 11 | } | ||
| 12 | |||
| 13 | function createScopedThreadSelector( | ||
| 14 | resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, | ||
| 15 | ): (state: AppState) => Thread | undefined { | ||
| 16 | let previousEnvironmentState: EnvironmentState | undefined; | ||
| 17 | let previousThreadId: ThreadId | undefined; | ||
| 18 | let previousThread: Thread | undefined; | ||
| 19 | |||
| 20 | return (state) => { | ||
| 21 | const ref = resolveRef(state); | ||
| 22 | if (!ref) { | ||
| 23 | return undefined; | ||
| 24 | } | ||
| 25 | |||
| 26 | const environmentState = selectEnvironmentState(state, ref.environmentId); | ||
| 27 | if ( | ||
| 28 | previousThread && | ||
| 29 | previousEnvironmentState === environmentState && | ||
| 30 | previousThreadId === ref.threadId | ||
| 31 | ) { | ||
| 32 | return previousThread; | ||
| 33 | } | ||
| 34 | |||
| 35 | previousEnvironmentState = environmentState; | ||
| 36 | previousThreadId = ref.threadId; | ||
| 37 | previousThread = getThreadFromEnvironmentState(environmentState, ref.threadId); | ||
| 38 | return previousThread; | ||
| 39 | }; | ||
| 40 | } | ||
| 41 | |||
| 42 | export function createThreadSelectorByRef( | ||
| 43 | ref: ScopedThreadRef | null | undefined, | ||
| 44 | ): (state: AppState) => Thread | undefined { | ||
| 45 | return createScopedThreadSelector(() => ref); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function createThreadSelectorAcrossEnvironments( | ||
| 49 | threadId: ThreadId | null | undefined, | ||
| 50 | ): (state: AppState) => Thread | undefined { | ||
| 51 | return createScopedThreadSelector((state) => { | ||
| 52 | if (!threadId) { | ||
| 53 | return undefined; | ||
| 54 | } | ||
| 55 | |||
| 56 | for (const [environmentId, environmentState] of Object.entries( | ||
| 57 | state.environmentStateById, | ||
| 58 | ) as Array<[ScopedThreadRef["environmentId"], EnvironmentState]>) { | ||
| 59 | if (environmentState.threadShellById[threadId]) { | ||
| 60 | return { | ||
| 61 | environmentId, | ||
| 62 | threadId, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | return undefined; | ||
| 67 | }); | ||
| 68 | } | ||
apps/web/src/terminalSessionState.ts deleted-77| ... | @@ -1,77 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, | ||
| 4 | EMPTY_TERMINAL_ID_LIST_ATOM, | ||
| 5 | EMPTY_TERMINAL_SESSION_ATOM, | ||
| 6 | createTerminalSessionManager, | ||
| 7 | getKnownTerminalSessionTarget, | ||
| 8 | getKnownTerminalSessionListFilter, | ||
| 9 | knownTerminalSessionsAtom, | ||
| 10 | runningTerminalIdsAtom, | ||
| 11 | terminalSessionStateAtom, | ||
| 12 | type KnownTerminalSession, | ||
| 13 | type TerminalSessionTarget, | ||
| 14 | type TerminalSessionState, | ||
| 15 | } from "@t3tools/client-runtime"; | ||
| 16 | import type { | ||
| 17 | EnvironmentId, | ||
| 18 | TerminalAttachInput, | ||
| 19 | TerminalSessionSnapshot, | ||
| 20 | ThreadId, | ||
| 21 | } from "@t3tools/contracts"; | ||
| 22 | |||
| 23 | import { appAtomRegistry } from "./rpc/atomRegistry"; | ||
| 24 | |||
| 25 | export const terminalSessionManager = createTerminalSessionManager({ | ||
| 26 | getRegistry: () => appAtomRegistry, | ||
| 27 | }); | ||
| 28 | |||
| 29 | export function subscribeTerminalMetadata(input: { | ||
| 30 | readonly environmentId: EnvironmentId; | ||
| 31 | readonly client: Parameters<typeof terminalSessionManager.subscribeMetadata>[0]["client"]; | ||
| 32 | }) { | ||
| 33 | return terminalSessionManager.subscribeMetadata(input); | ||
| 34 | } | ||
| 35 | |||
| 36 | export function attachTerminalSession(input: { | ||
| 37 | readonly environmentId: EnvironmentId; | ||
| 38 | readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"]; | ||
| 39 | readonly terminal: TerminalAttachInput; | ||
| 40 | readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; | ||
| 41 | readonly onEvent?: Parameters<typeof terminalSessionManager.attach>[0]["onEvent"]; | ||
| 42 | }) { | ||
| 43 | return terminalSessionManager.attach({ | ||
| 44 | environmentId: input.environmentId, | ||
| 45 | client: input.client, | ||
| 46 | terminal: input.terminal, | ||
| 47 | ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), | ||
| 48 | ...(input.onEvent ? { onEvent: input.onEvent } : {}), | ||
| 49 | }); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { | ||
| 53 | const target = getKnownTerminalSessionTarget(input); | ||
| 54 | return useAtomValue( | ||
| 55 | target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | export function useKnownTerminalSessions(input: { | ||
| 60 | readonly environmentId: EnvironmentId | null; | ||
| 61 | readonly threadId: ThreadId | null; | ||
| 62 | }): ReadonlyArray<KnownTerminalSession> { | ||
| 63 | const filter = getKnownTerminalSessionListFilter(input); | ||
| 64 | return useAtomValue( | ||
| 65 | filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | export function useThreadRunningTerminalIds(input: { | ||
| 70 | readonly environmentId: EnvironmentId | null; | ||
| 71 | readonly threadId: ThreadId | null; | ||
| 72 | }): ReadonlyArray<string> { | ||
| 73 | const filter = getKnownTerminalSessionListFilter(input); | ||
| 74 | return useAtomValue( | ||
| 75 | filter !== null ? runningTerminalIdsAtom(filter) : EMPTY_TERMINAL_ID_LIST_ATOM, | ||
| 76 | ); | ||
| 77 | } | ||
apps/web/src/terminalUiStateStore.test.ts+25-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; | 1 | import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 2 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| ... | @@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => { | ... | @@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => { |
| 18 | useTerminalUiStateStore.persist.clearStorage(); | 18 | useTerminalUiStateStore.persist.clearStorage(); |
| 19 | useTerminalUiStateStore.setState({ | 19 | useTerminalUiStateStore.setState({ |
| 20 | terminalUiStateByThreadKey: {}, | 20 | terminalUiStateByThreadKey: {}, |
| 21 | suppressedTerminalIdsByThreadKey: {}, | ||
| 21 | }); | 22 | }); |
| 22 | }); | 23 | }); |
| 23 | 24 | ||
| ... | @@ -261,6 +262,29 @@ describe("terminalUiStateStore actions", () => { | ... | @@ -261,6 +262,29 @@ describe("terminalUiStateStore actions", () => { |
| 261 | ]); | 262 | ]); |
| 262 | }); | 263 | }); |
| 263 | 264 | ||
| 265 | it("does not import a closed panel terminal from stale metadata", () => { | ||
| 266 | const store = useTerminalUiStateStore.getState(); | ||
| 267 | store.newTerminal(THREAD_REF, "term-2"); | ||
| 268 | store.closeTerminal(THREAD_REF, "term-1"); | ||
| 269 | |||
| 270 | store.reconcileTerminalIds(THREAD_REF, ["term-1", "term-2"]); | ||
| 271 | |||
| 272 | expect( | ||
| 273 | selectThreadTerminalUiState( | ||
| 274 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 275 | THREAD_REF, | ||
| 276 | ).terminalIds, | ||
| 277 | ).toEqual(["term-2"]); | ||
| 278 | |||
| 279 | store.newTerminal(THREAD_REF, "term-1"); | ||
| 280 | expect( | ||
| 281 | selectThreadTerminalUiState( | ||
| 282 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 283 | THREAD_REF, | ||
| 284 | ).terminalIds, | ||
| 285 | ).toEqual(["term-2", "term-1"]); | ||
| 286 | }); | ||
| 287 | |||
| 264 | it("is a no-op when clearing terminal UI state for a thread with no state", () => { | 288 | it("is a no-op when clearing terminal UI state for a thread with no state", () => { |
| 265 | const store = useTerminalUiStateStore.getState(); | 289 | const store = useTerminalUiStateStore.getState(); |
| 266 | const before = useTerminalUiStateStore.getState(); | 290 | const before = useTerminalUiStateStore.getState(); |
apps/web/src/terminalUiStateStore.ts+166-43| ... | @@ -5,7 +5,7 @@ | ... | @@ -5,7 +5,7 @@ |
| 5 | * API constrained to store actions/selectors. | 5 | * API constrained to store actions/selectors. |
| 6 | */ | 6 | */ |
| 7 | 7 | ||
| 8 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; | 8 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 9 | import { type ScopedThreadRef } from "@t3tools/contracts"; | 9 | import { type ScopedThreadRef } from "@t3tools/contracts"; |
| 10 | import { create } from "zustand"; | 10 | import { create } from "zustand"; |
| 11 | import { createJSONStorage, persist } from "zustand/middleware"; | 11 | import { createJSONStorage, persist } from "zustand/middleware"; |
| ... | @@ -519,8 +519,51 @@ function updateTerminalUiStateByThreadKey( | ... | @@ -519,8 +519,51 @@ function updateTerminalUiStateByThreadKey( |
| 519 | }; | 519 | }; |
| 520 | } | 520 | } |
| 521 | 521 | ||
| 522 | function updateSuppressedTerminalId( | ||
| 523 | suppressedTerminalIdsByThreadKey: Record<string, string[]>, | ||
| 524 | threadRef: ScopedThreadRef, | ||
| 525 | terminalId: string, | ||
| 526 | suppressed: boolean, | ||
| 527 | ): Record<string, string[]> { | ||
| 528 | const normalizedTerminalId = terminalId.trim(); | ||
| 529 | if (normalizedTerminalId.length === 0) { | ||
| 530 | return suppressedTerminalIdsByThreadKey; | ||
| 531 | } | ||
| 532 | const threadKey = terminalThreadKey(threadRef); | ||
| 533 | const currentIds = suppressedTerminalIdsByThreadKey[threadKey] ?? []; | ||
| 534 | const currentlySuppressed = currentIds.includes(normalizedTerminalId); | ||
| 535 | if (currentlySuppressed === suppressed) { | ||
| 536 | return suppressedTerminalIdsByThreadKey; | ||
| 537 | } | ||
| 538 | if (suppressed) { | ||
| 539 | return { | ||
| 540 | ...suppressedTerminalIdsByThreadKey, | ||
| 541 | [threadKey]: [...currentIds, normalizedTerminalId], | ||
| 542 | }; | ||
| 543 | } | ||
| 544 | |||
| 545 | const remainingIds = currentIds.filter((id) => id !== normalizedTerminalId); | ||
| 546 | if (remainingIds.length > 0) { | ||
| 547 | return { | ||
| 548 | ...suppressedTerminalIdsByThreadKey, | ||
| 549 | [threadKey]: remainingIds, | ||
| 550 | }; | ||
| 551 | } | ||
| 552 | return removeRecordEntry(suppressedTerminalIdsByThreadKey, threadKey); | ||
| 553 | } | ||
| 554 | |||
| 555 | function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<string, T> { | ||
| 556 | if (record[key] === undefined) { | ||
| 557 | return record; | ||
| 558 | } | ||
| 559 | const { [key]: _removed, ...remaining } = record; | ||
| 560 | return remaining; | ||
| 561 | } | ||
| 562 | |||
| 522 | interface TerminalUiStateStoreState { | 563 | interface TerminalUiStateStoreState { |
| 523 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>; | 564 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>; |
| 565 | /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ | ||
| 566 | suppressedTerminalIdsByThreadKey: Record<string, string[]>; | ||
| 524 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; | 567 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; |
| 525 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; | 568 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; |
| 526 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | 569 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| ... | @@ -541,106 +584,186 @@ interface TerminalUiStateStoreState { | ... | @@ -541,106 +584,186 @@ interface TerminalUiStateStoreState { |
| 541 | 584 | ||
| 542 | export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | 585 | export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 543 | persist( | 586 | persist( |
| 544 | (set) => { | 587 | (set, get) => { |
| 545 | const updateTerminal = ( | 588 | const updateTerminal = ( |
| 546 | threadRef: ScopedThreadRef, | 589 | threadRef: ScopedThreadRef, |
| 547 | updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState, | 590 | updater: ( |
| 591 | state: ThreadTerminalUiState, | ||
| 592 | suppressedTerminalIds: readonly string[], | ||
| 593 | ) => ThreadTerminalUiState, | ||
| 594 | suppression?: { terminalId: string; suppressed: boolean }, | ||
| 548 | ) => { | 595 | ) => { |
| 549 | set((state) => { | 596 | set((state) => { |
| 597 | const threadKey = terminalThreadKey(threadRef); | ||
| 598 | const suppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] ?? []; | ||
| 550 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( | 599 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( |
| 551 | state.terminalUiStateByThreadKey, | 600 | state.terminalUiStateByThreadKey, |
| 552 | threadRef, | 601 | threadRef, |
| 553 | updater, | 602 | (terminalState) => updater(terminalState, suppressedTerminalIds), |
| 554 | ); | 603 | ); |
| 555 | if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) { | 604 | const nextSuppressedTerminalIdsByThreadKey = suppression |
| 605 | ? updateSuppressedTerminalId( | ||
| 606 | state.suppressedTerminalIdsByThreadKey, | ||
| 607 | threadRef, | ||
| 608 | suppression.terminalId, | ||
| 609 | suppression.suppressed, | ||
| 610 | ) | ||
| 611 | : state.suppressedTerminalIdsByThreadKey; | ||
| 612 | if ( | ||
| 613 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && | ||
| 614 | nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey | ||
| 615 | ) { | ||
| 556 | return state; | 616 | return state; |
| 557 | } | 617 | } |
| 558 | return { | 618 | return { |
| 559 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, | 619 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 620 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, | ||
| 560 | }; | 621 | }; |
| 561 | }); | 622 | }); |
| 562 | }; | 623 | }; |
| 563 | 624 | ||
| 564 | return { | 625 | return { |
| 565 | terminalUiStateByThreadKey: {}, | 626 | terminalUiStateByThreadKey: {}, |
| 566 | setTerminalOpen: (threadRef, open) => | 627 | suppressedTerminalIdsByThreadKey: {}, |
| 567 | updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)), | 628 | setTerminalOpen: (threadRef, open) => { |
| 629 | const terminalState = selectThreadTerminalUiState( | ||
| 630 | get().terminalUiStateByThreadKey, | ||
| 631 | threadRef, | ||
| 632 | ); | ||
| 633 | updateTerminal( | ||
| 634 | threadRef, | ||
| 635 | (state) => setThreadTerminalOpen(state, open), | ||
| 636 | open && terminalState.terminalIds.length === 0 | ||
| 637 | ? { terminalId: DEFAULT_THREAD_TERMINAL_ID, suppressed: false } | ||
| 638 | : undefined, | ||
| 639 | ); | ||
| 640 | }, | ||
| 568 | setTerminalHeight: (threadRef, height) => | 641 | setTerminalHeight: (threadRef, height) => |
| 569 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), | 642 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), |
| 570 | splitTerminal: (threadRef, terminalId) => | 643 | splitTerminal: (threadRef, terminalId) => |
| 571 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), | 644 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId), { |
| 645 | terminalId, | ||
| 646 | suppressed: false, | ||
| 647 | }), | ||
| 572 | splitTerminalVertical: (threadRef, terminalId) => | 648 | splitTerminalVertical: (threadRef, terminalId) => |
| 573 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical")), | 649 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical"), { |
| 650 | terminalId, | ||
| 651 | suppressed: false, | ||
| 652 | }), | ||
| 574 | newTerminal: (threadRef, terminalId) => | 653 | newTerminal: (threadRef, terminalId) => |
| 575 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), | 654 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId), { |
| 576 | ensureTerminal: (threadRef, terminalId, options) => | 655 | terminalId, |
| 577 | updateTerminal(threadRef, (state) => { | 656 | suppressed: false, |
| 578 | let nextState = state; | ||
| 579 | if (!state.terminalIds.includes(terminalId)) { | ||
| 580 | nextState = newThreadTerminal(nextState, terminalId); | ||
| 581 | } | ||
| 582 | if (options?.active === false) { | ||
| 583 | nextState = { | ||
| 584 | ...nextState, | ||
| 585 | activeTerminalId: state.activeTerminalId, | ||
| 586 | activeTerminalGroupId: state.activeTerminalGroupId, | ||
| 587 | }; | ||
| 588 | } | ||
| 589 | if (options?.active ?? true) { | ||
| 590 | nextState = setThreadActiveTerminal(nextState, terminalId); | ||
| 591 | } | ||
| 592 | if (options?.open) { | ||
| 593 | nextState = setThreadTerminalOpen(nextState, true); | ||
| 594 | } | ||
| 595 | return normalizeThreadTerminalUiState(nextState); | ||
| 596 | }), | 657 | }), |
| 658 | ensureTerminal: (threadRef, terminalId, options) => | ||
| 659 | updateTerminal( | ||
| 660 | threadRef, | ||
| 661 | (state) => { | ||
| 662 | let nextState = state; | ||
| 663 | if (!state.terminalIds.includes(terminalId)) { | ||
| 664 | nextState = newThreadTerminal(nextState, terminalId); | ||
| 665 | } | ||
| 666 | if (options?.active === false) { | ||
| 667 | nextState = { | ||
| 668 | ...nextState, | ||
| 669 | activeTerminalId: state.activeTerminalId, | ||
| 670 | activeTerminalGroupId: state.activeTerminalGroupId, | ||
| 671 | }; | ||
| 672 | } | ||
| 673 | if (options?.active ?? true) { | ||
| 674 | nextState = setThreadActiveTerminal(nextState, terminalId); | ||
| 675 | } | ||
| 676 | if (options?.open) { | ||
| 677 | nextState = setThreadTerminalOpen(nextState, true); | ||
| 678 | } | ||
| 679 | return normalizeThreadTerminalUiState(nextState); | ||
| 680 | }, | ||
| 681 | { terminalId, suppressed: false }, | ||
| 682 | ), | ||
| 597 | setActiveTerminal: (threadRef, terminalId) => | 683 | setActiveTerminal: (threadRef, terminalId) => |
| 598 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), | 684 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), |
| 599 | closeTerminal: (threadRef, terminalId) => | 685 | closeTerminal: (threadRef, terminalId) => |
| 600 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), | 686 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { |
| 687 | terminalId, | ||
| 688 | suppressed: true, | ||
| 689 | }), | ||
| 601 | reconcileTerminalIds: (threadRef, nextIds) => | 690 | reconcileTerminalIds: (threadRef, nextIds) => |
| 602 | updateTerminal(threadRef, (state) => reconcileThreadTerminalSessionIds(state, nextIds)), | 691 | updateTerminal(threadRef, (state, suppressedTerminalIds) => { |
| 692 | if (suppressedTerminalIds.length === 0) { | ||
| 693 | return reconcileThreadTerminalSessionIds(state, nextIds); | ||
| 694 | } | ||
| 695 | const suppressedIds = new Set(suppressedTerminalIds); | ||
| 696 | return reconcileThreadTerminalSessionIds( | ||
| 697 | state, | ||
| 698 | nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), | ||
| 699 | ); | ||
| 700 | }), | ||
| 603 | clearTerminalUiState: (threadRef) => | 701 | clearTerminalUiState: (threadRef) => |
| 604 | set((state) => { | 702 | set((state) => { |
| 703 | const threadKey = terminalThreadKey(threadRef); | ||
| 605 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( | 704 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( |
| 606 | state.terminalUiStateByThreadKey, | 705 | state.terminalUiStateByThreadKey, |
| 607 | threadRef, | 706 | threadRef, |
| 608 | () => createDefaultThreadTerminalUiState(), | 707 | () => createDefaultThreadTerminalUiState(), |
| 609 | ); | 708 | ); |
| 610 | if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) { | 709 | const hadSuppressedTerminalIds = |
| 710 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; | ||
| 711 | if ( | ||
| 712 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && | ||
| 713 | !hadSuppressedTerminalIds | ||
| 714 | ) { | ||
| 611 | return state; | 715 | return state; |
| 612 | } | 716 | } |
| 613 | return { | 717 | return { |
| 614 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, | 718 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 719 | suppressedTerminalIdsByThreadKey: removeRecordEntry( | ||
| 720 | state.suppressedTerminalIdsByThreadKey, | ||
| 721 | threadKey, | ||
| 722 | ), | ||
| 615 | }; | 723 | }; |
| 616 | }), | 724 | }), |
| 617 | removeTerminalUiState: (threadRef) => | 725 | removeTerminalUiState: (threadRef) => |
| 618 | set((state) => { | 726 | set((state) => { |
| 619 | const threadKey = terminalThreadKey(threadRef); | 727 | const threadKey = terminalThreadKey(threadRef); |
| 620 | const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; | 728 | const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; |
| 621 | if (!hadTerminalUiState) { | 729 | const hadSuppressedTerminalIds = |
| 730 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; | ||
| 731 | if (!hadTerminalUiState && !hadSuppressedTerminalIds) { | ||
| 622 | return state; | 732 | return state; |
| 623 | } | 733 | } |
| 624 | const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey }; | ||
| 625 | delete nextTerminalUiStateByThreadKey[threadKey]; | ||
| 626 | return { | 734 | return { |
| 627 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, | 735 | terminalUiStateByThreadKey: removeRecordEntry( |
| 736 | state.terminalUiStateByThreadKey, | ||
| 737 | threadKey, | ||
| 738 | ), | ||
| 739 | suppressedTerminalIdsByThreadKey: removeRecordEntry( | ||
| 740 | state.suppressedTerminalIdsByThreadKey, | ||
| 741 | threadKey, | ||
| 742 | ), | ||
| 628 | }; | 743 | }; |
| 629 | }), | 744 | }), |
| 630 | removeOrphanedTerminalUiStates: (activeThreadKeys) => | 745 | removeOrphanedTerminalUiStates: (activeThreadKeys) => |
| 631 | set((state) => { | 746 | set((state) => { |
| 632 | const orphanedIds = Object.keys(state.terminalUiStateByThreadKey).filter( | 747 | const orphanedIds = new Set( |
| 633 | (key) => !activeThreadKeys.has(key), | 748 | [ |
| 749 | ...Object.keys(state.terminalUiStateByThreadKey), | ||
| 750 | ...Object.keys(state.suppressedTerminalIdsByThreadKey), | ||
| 751 | ].filter((key) => !activeThreadKeys.has(key)), | ||
| 634 | ); | 752 | ); |
| 635 | if (orphanedIds.length === 0) { | 753 | if (orphanedIds.size === 0) { |
| 636 | return state; | 754 | return state; |
| 637 | } | 755 | } |
| 638 | const next = { ...state.terminalUiStateByThreadKey }; | 756 | const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey }; |
| 757 | const nextSuppressedTerminalIdsByThreadKey = { | ||
| 758 | ...state.suppressedTerminalIdsByThreadKey, | ||
| 759 | }; | ||
| 639 | for (const id of orphanedIds) { | 760 | for (const id of orphanedIds) { |
| 640 | delete next[id]; | 761 | delete nextTerminalUiStateByThreadKey[id]; |
| 762 | delete nextSuppressedTerminalIdsByThreadKey[id]; | ||
| 641 | } | 763 | } |
| 642 | return { | 764 | return { |
| 643 | terminalUiStateByThreadKey: next, | 765 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 766 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, | ||
| 644 | }; | 767 | }; |
| 645 | }), | 768 | }), |
| 646 | }; | 769 | }; |
apps/web/src/threadDerivation.ts deleted-152| ... | @@ -1,152 +0,0 @@ | ||
| 1 | import type { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; | ||
| 2 | import type { EnvironmentState } from "./store"; | ||
| 3 | import type { | ||
| 4 | ChatMessage, | ||
| 5 | ProposedPlan, | ||
| 6 | Thread, | ||
| 7 | ThreadSession, | ||
| 8 | ThreadShell, | ||
| 9 | ThreadTurnState, | ||
| 10 | TurnDiffSummary, | ||
| 11 | } from "./types"; | ||
| 12 | |||
| 13 | const EMPTY_MESSAGES: ChatMessage[] = []; | ||
| 14 | const EMPTY_ACTIVITIES: Thread["activities"] = []; | ||
| 15 | const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; | ||
| 16 | const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; | ||
| 17 | const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {}; | ||
| 18 | const EMPTY_ACTIVITY_MAP: Record<string, Thread["activities"][number]> = {}; | ||
| 19 | const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {}; | ||
| 20 | const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {}; | ||
| 21 | |||
| 22 | const collectedByIdsCache = new WeakMap<readonly string[], WeakMap<object, readonly unknown[]>>(); | ||
| 23 | const threadCache = new WeakMap< | ||
| 24 | ThreadShell, | ||
| 25 | { | ||
| 26 | session: ThreadSession | null; | ||
| 27 | turnState: ThreadTurnState | undefined; | ||
| 28 | messages: Thread["messages"]; | ||
| 29 | activities: Thread["activities"]; | ||
| 30 | proposedPlans: Thread["proposedPlans"]; | ||
| 31 | turnDiffSummaries: Thread["turnDiffSummaries"]; | ||
| 32 | thread: Thread; | ||
| 33 | } | ||
| 34 | >(); | ||
| 35 | |||
| 36 | function collectByIds<TKey extends string, TValue>( | ||
| 37 | ids: readonly TKey[] | undefined, | ||
| 38 | byId: Record<TKey, TValue> | undefined, | ||
| 39 | emptyValue: TValue[], | ||
| 40 | ): TValue[] { | ||
| 41 | if (!ids || ids.length === 0 || !byId) { | ||
| 42 | return emptyValue; | ||
| 43 | } | ||
| 44 | |||
| 45 | const cachedByRecord = collectedByIdsCache.get(ids); | ||
| 46 | const cached = cachedByRecord?.get(byId); | ||
| 47 | if (cached) { | ||
| 48 | return cached as TValue[]; | ||
| 49 | } | ||
| 50 | |||
| 51 | const nextValues = ids.flatMap((id) => { | ||
| 52 | const value = byId[id]; | ||
| 53 | return value ? [value] : []; | ||
| 54 | }); | ||
| 55 | const nextCachedByRecord = cachedByRecord ?? new WeakMap<object, readonly unknown[]>(); | ||
| 56 | nextCachedByRecord.set(byId, nextValues); | ||
| 57 | if (!cachedByRecord) { | ||
| 58 | collectedByIdsCache.set(ids, nextCachedByRecord); | ||
| 59 | } | ||
| 60 | return nextValues; | ||
| 61 | } | ||
| 62 | |||
| 63 | function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): Thread["messages"] { | ||
| 64 | return collectByIds( | ||
| 65 | state.messageIdsByThreadId[threadId], | ||
| 66 | state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP, | ||
| 67 | EMPTY_MESSAGES, | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | function selectThreadActivities(state: EnvironmentState, threadId: ThreadId): Thread["activities"] { | ||
| 72 | return collectByIds( | ||
| 73 | state.activityIdsByThreadId[threadId], | ||
| 74 | state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP, | ||
| 75 | EMPTY_ACTIVITIES, | ||
| 76 | ); | ||
| 77 | } | ||
| 78 | |||
| 79 | function selectThreadProposedPlans( | ||
| 80 | state: EnvironmentState, | ||
| 81 | threadId: ThreadId, | ||
| 82 | ): Thread["proposedPlans"] { | ||
| 83 | return collectByIds( | ||
| 84 | state.proposedPlanIdsByThreadId[threadId], | ||
| 85 | state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP, | ||
| 86 | EMPTY_PROPOSED_PLANS, | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | function selectThreadTurnDiffSummaries( | ||
| 91 | state: EnvironmentState, | ||
| 92 | threadId: ThreadId, | ||
| 93 | ): Thread["turnDiffSummaries"] { | ||
| 94 | return collectByIds( | ||
| 95 | state.turnDiffIdsByThreadId[threadId], | ||
| 96 | state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP, | ||
| 97 | EMPTY_TURN_DIFF_SUMMARIES, | ||
| 98 | ); | ||
| 99 | } | ||
| 100 | |||
| 101 | export function getThreadFromEnvironmentState( | ||
| 102 | state: EnvironmentState, | ||
| 103 | threadId: ThreadId, | ||
| 104 | ): Thread | undefined { | ||
| 105 | const shell = state.threadShellById[threadId]; | ||
| 106 | if (!shell) { | ||
| 107 | return undefined; | ||
| 108 | } | ||
| 109 | |||
| 110 | const session = state.threadSessionById[threadId] ?? null; | ||
| 111 | const turnState = state.threadTurnStateById[threadId]; | ||
| 112 | const messages = selectThreadMessages(state, threadId); | ||
| 113 | const activities = selectThreadActivities(state, threadId); | ||
| 114 | const proposedPlans = selectThreadProposedPlans(state, threadId); | ||
| 115 | const turnDiffSummaries = selectThreadTurnDiffSummaries(state, threadId); | ||
| 116 | const cached = threadCache.get(shell); | ||
| 117 | |||
| 118 | if ( | ||
| 119 | cached && | ||
| 120 | cached.session === session && | ||
| 121 | cached.turnState === turnState && | ||
| 122 | cached.messages === messages && | ||
| 123 | cached.activities === activities && | ||
| 124 | cached.proposedPlans === proposedPlans && | ||
| 125 | cached.turnDiffSummaries === turnDiffSummaries | ||
| 126 | ) { | ||
| 127 | return cached.thread; | ||
| 128 | } | ||
| 129 | |||
| 130 | const thread: Thread = { | ||
| 131 | ...shell, | ||
| 132 | session, | ||
| 133 | latestTurn: turnState?.latestTurn ?? null, | ||
| 134 | pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, | ||
| 135 | messages, | ||
| 136 | activities, | ||
| 137 | proposedPlans, | ||
| 138 | turnDiffSummaries, | ||
| 139 | }; | ||
| 140 | |||
| 141 | threadCache.set(shell, { | ||
| 142 | session, | ||
| 143 | turnState, | ||
| 144 | messages, | ||
| 145 | activities, | ||
| 146 | proposedPlans, | ||
| 147 | turnDiffSummaries, | ||
| 148 | thread, | ||
| 149 | }); | ||
| 150 | |||
| 151 | return thread; | ||
| 152 | } | ||
apps/web/src/threadRoutes.test.ts+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 2 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { ThreadId } from "@t3tools/contracts"; | 3 | import { ThreadId } from "@t3tools/contracts"; |
| 4 | import { DraftId } from "./composerDraftStore"; | 4 | import { DraftId } from "./composerDraftStore"; |
| 5 | 5 |
apps/web/src/threadRoutes.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 3 | import type { DraftId } from "./composerDraftStore"; | 3 | import type { DraftId } from "./composerDraftStore"; |
| 4 | 4 |
apps/web/src/types.ts+23-137| ... | @@ -1,22 +1,20 @@ | ... | @@ -1,22 +1,20 @@ |
| 1 | import type { | 1 | import type { |
| 2 | EnvironmentId, | 2 | ChatImageAttachment as ContractChatImageAttachment, |
| 3 | ModelSelection, | 3 | OrchestrationCheckpointFile, |
| 4 | OrchestrationCheckpointSummary, | ||
| 4 | OrchestrationLatestTurn, | 5 | OrchestrationLatestTurn, |
| 5 | OrchestrationProposedPlanId, | 6 | OrchestrationMessage, |
| 6 | RepositoryIdentity, | 7 | OrchestrationProposedPlan, |
| 7 | OrchestrationSessionStatus, | 8 | OrchestrationSession, |
| 8 | OrchestrationThreadActivity, | ||
| 9 | ProjectScript as ContractProjectScript, | 9 | ProjectScript as ContractProjectScript, |
| 10 | ThreadId, | ||
| 11 | ProjectId, | ||
| 12 | TurnId, | ||
| 13 | MessageId, | ||
| 14 | ProviderDriverKind, | ||
| 15 | ProviderInstanceId, | ||
| 16 | CheckpointRef, | ||
| 17 | ProviderInteractionMode, | 10 | ProviderInteractionMode, |
| 18 | RuntimeMode, | 11 | RuntimeMode, |
| 19 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import type { | ||
| 14 | EnvironmentProject, | ||
| 15 | EnvironmentThread, | ||
| 16 | EnvironmentThreadShell, | ||
| 17 | } from "@t3tools/client-runtime/state/shell"; | ||
| 20 | 18 | ||
| 21 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | 19 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 22 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | 20 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| ... | @@ -33,139 +31,27 @@ export interface ThreadTerminalGroup { | ... | @@ -33,139 +31,27 @@ export interface ThreadTerminalGroup { |
| 33 | splitDirection?: "horizontal" | "vertical"; | 31 | splitDirection?: "horizontal" | "vertical"; |
| 34 | } | 32 | } |
| 35 | 33 | ||
| 36 | export interface ChatImageAttachment { | 34 | export interface ChatImageAttachment extends ContractChatImageAttachment { |
| 37 | type: "image"; | 35 | readonly previewUrl?: string; |
| 38 | id: string; | ||
| 39 | name: string; | ||
| 40 | mimeType: string; | ||
| 41 | sizeBytes: number; | ||
| 42 | previewUrl?: string; | ||
| 43 | } | 36 | } |
| 44 | 37 | ||
| 45 | export type ChatAttachment = ChatImageAttachment; | 38 | export type ChatAttachment = ChatImageAttachment; |
| 46 | 39 | ||
| 47 | export interface ChatMessage { | 40 | export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> { |
| 48 | id: MessageId; | 41 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; |
| 49 | role: "user" | "assistant" | "system"; | ||
| 50 | text: string; | ||
| 51 | attachments?: ChatAttachment[]; | ||
| 52 | turnId?: TurnId | null; | ||
| 53 | createdAt: string; | ||
| 54 | completedAt?: string | undefined; | ||
| 55 | streaming: boolean; | ||
| 56 | } | 42 | } |
| 57 | 43 | ||
| 58 | export interface ProposedPlan { | 44 | export type ProposedPlan = OrchestrationProposedPlan; |
| 59 | id: OrchestrationProposedPlanId; | 45 | export type TurnDiffFileChange = OrchestrationCheckpointFile; |
| 60 | turnId: TurnId | null; | 46 | export type TurnDiffSummary = OrchestrationCheckpointSummary; |
| 61 | planMarkdown: string; | ||
| 62 | implementedAt: string | null; | ||
| 63 | implementationThreadId: ThreadId | null; | ||
| 64 | createdAt: string; | ||
| 65 | updatedAt: string; | ||
| 66 | } | ||
| 67 | 47 | ||
| 68 | export interface TurnDiffFileChange { | 48 | export type Project = EnvironmentProject; |
| 69 | path: string; | 49 | export type Thread = EnvironmentThread; |
| 70 | kind?: string | undefined; | 50 | export type ThreadShell = EnvironmentThreadShell; |
| 71 | additions?: number | undefined; | ||
| 72 | deletions?: number | undefined; | ||
| 73 | } | ||
| 74 | |||
| 75 | export interface TurnDiffSummary { | ||
| 76 | turnId: TurnId; | ||
| 77 | completedAt: string; | ||
| 78 | status?: string | undefined; | ||
| 79 | files: TurnDiffFileChange[]; | ||
| 80 | checkpointRef?: CheckpointRef | undefined; | ||
| 81 | assistantMessageId?: MessageId | undefined; | ||
| 82 | checkpointTurnCount?: number | undefined; | ||
| 83 | } | ||
| 84 | |||
| 85 | export interface Project { | ||
| 86 | id: ProjectId; | ||
| 87 | environmentId: EnvironmentId; | ||
| 88 | name: string; | ||
| 89 | cwd: string; | ||
| 90 | repositoryIdentity?: RepositoryIdentity | null; | ||
| 91 | defaultModelSelection: ModelSelection | null; | ||
| 92 | createdAt?: string | undefined; | ||
| 93 | updatedAt?: string | undefined; | ||
| 94 | scripts: ProjectScript[]; | ||
| 95 | } | ||
| 96 | |||
| 97 | export interface Thread { | ||
| 98 | id: ThreadId; | ||
| 99 | environmentId: EnvironmentId; | ||
| 100 | codexThreadId: string | null; | ||
| 101 | projectId: ProjectId; | ||
| 102 | title: string; | ||
| 103 | modelSelection: ModelSelection; | ||
| 104 | runtimeMode: RuntimeMode; | ||
| 105 | interactionMode: ProviderInteractionMode; | ||
| 106 | session: ThreadSession | null; | ||
| 107 | messages: ChatMessage[]; | ||
| 108 | proposedPlans: ProposedPlan[]; | ||
| 109 | error: string | null; | ||
| 110 | createdAt: string; | ||
| 111 | archivedAt: string | null; | ||
| 112 | updatedAt?: string | undefined; | ||
| 113 | latestTurn: OrchestrationLatestTurn | null; | ||
| 114 | pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; | ||
| 115 | branch: string | null; | ||
| 116 | worktreePath: string | null; | ||
| 117 | turnDiffSummaries: TurnDiffSummary[]; | ||
| 118 | activities: OrchestrationThreadActivity[]; | ||
| 119 | } | ||
| 120 | |||
| 121 | export interface ThreadShell { | ||
| 122 | id: ThreadId; | ||
| 123 | environmentId: EnvironmentId; | ||
| 124 | codexThreadId: string | null; | ||
| 125 | projectId: ProjectId; | ||
| 126 | title: string; | ||
| 127 | modelSelection: ModelSelection; | ||
| 128 | runtimeMode: RuntimeMode; | ||
| 129 | interactionMode: ProviderInteractionMode; | ||
| 130 | error: string | null; | ||
| 131 | createdAt: string; | ||
| 132 | archivedAt: string | null; | ||
| 133 | updatedAt?: string | undefined; | ||
| 134 | branch: string | null; | ||
| 135 | worktreePath: string | null; | ||
| 136 | } | ||
| 137 | 51 | ||
| 138 | export interface ThreadTurnState { | 52 | export interface ThreadTurnState { |
| 139 | latestTurn: OrchestrationLatestTurn | null; | 53 | latestTurn: OrchestrationLatestTurn | null; |
| 140 | pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; | ||
| 141 | } | 54 | } |
| 142 | 55 | ||
| 143 | export interface SidebarThreadSummary { | 56 | export type SidebarThreadSummary = EnvironmentThreadShell; |
| 144 | id: ThreadId; | 57 | export type ThreadSession = OrchestrationSession; |
| 145 | environmentId: EnvironmentId; | ||
| 146 | projectId: ProjectId; | ||
| 147 | title: string; | ||
| 148 | interactionMode: ProviderInteractionMode; | ||
| 149 | session: ThreadSession | null; | ||
| 150 | createdAt: string; | ||
| 151 | archivedAt: string | null; | ||
| 152 | updatedAt?: string | undefined; | ||
| 153 | latestTurn: OrchestrationLatestTurn | null; | ||
| 154 | branch: string | null; | ||
| 155 | worktreePath: string | null; | ||
| 156 | latestUserMessageAt: string | null; | ||
| 157 | hasPendingApprovals: boolean; | ||
| 158 | hasPendingUserInput: boolean; | ||
| 159 | hasActionableProposedPlan: boolean; | ||
| 160 | } | ||
| 161 | |||
| 162 | export interface ThreadSession { | ||
| 163 | provider: ProviderDriverKind; | ||
| 164 | providerInstanceId?: ProviderInstanceId | undefined; | ||
| 165 | status: SessionPhase | "error" | "closed"; | ||
| 166 | activeTurnId?: TurnId | undefined; | ||
| 167 | createdAt: string; | ||
| 168 | updatedAt: string; | ||
| 169 | lastError?: string; | ||
| 170 | orchestrationStatus: OrchestrationSessionStatus; | ||
| 171 | } |
apps/web/src/uiStateStore.test.ts+154-455| ... | @@ -2,19 +2,18 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; | ... | @@ -2,19 +2,18 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | clearThreadUi, | 5 | legacyProjectCwdPreferenceKey, |
| 6 | hydratePersistedProjectState, | ||
| 7 | markThreadVisited, | ||
| 8 | markThreadUnread, | 6 | markThreadUnread, |
| 7 | markThreadVisited, | ||
| 8 | parsePersistedState, | ||
| 9 | PERSISTED_STATE_KEY, | 9 | PERSISTED_STATE_KEY, |
| 10 | type PersistedUiState, | 10 | type PersistedUiState, |
| 11 | persistState, | 11 | persistState, |
| 12 | reorderProjects, | 12 | reorderProjects, |
| 13 | resolveProjectExpanded, | ||
| 13 | setDefaultAdvertisedEndpointKey, | 14 | setDefaultAdvertisedEndpointKey, |
| 14 | setProjectExpanded, | 15 | setProjectExpanded, |
| 15 | setThreadChangedFilesExpanded, | 16 | setThreadChangedFilesExpanded, |
| 16 | syncProjects, | ||
| 17 | syncThreads, | ||
| 18 | type UiState, | 17 | type UiState, |
| 19 | } from "./uiStateStore"; | 18 | } from "./uiStateStore"; |
| 20 | 19 | ||
| ... | @@ -30,418 +29,187 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState { | ... | @@ -30,418 +29,187 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState { |
| 30 | } | 29 | } |
| 31 | 30 | ||
| 32 | describe("uiStateStore pure functions", () => { | 31 | describe("uiStateStore pure functions", () => { |
| 33 | it("markThreadVisited stores the provided server timestamp", () => { | 32 | it("stores server timestamps without moving visit state backwards", () => { |
| 34 | const threadId = ThreadId.make("thread-1"); | 33 | const threadId = ThreadId.make("thread-1"); |
| 35 | const initialState = makeUiState(); | 34 | const initialState = makeUiState(); |
| 35 | const visited = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z"); | ||
| 36 | 36 | ||
| 37 | const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z"); | 37 | expect(visited.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z"); |
| 38 | 38 | expect(markThreadVisited(visited, threadId, "2026-02-25T12:30:00.000Z")).toBe(visited); | |
| 39 | expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z"); | 39 | expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited); |
| 40 | }); | ||
| 41 | |||
| 42 | it("markThreadVisited does not move visit state backwards under clock skew", () => { | ||
| 43 | const threadId = ThreadId.make("thread-1"); | ||
| 44 | const initialState = makeUiState({ | ||
| 45 | threadLastVisitedAtById: { | ||
| 46 | [threadId]: "2026-02-25T12:30:00.700Z", | ||
| 47 | }, | ||
| 48 | }); | ||
| 49 | |||
| 50 | const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.000Z"); | ||
| 51 | |||
| 52 | expect(next).toBe(initialState); | ||
| 53 | }); | 40 | }); |
| 54 | 41 | ||
| 55 | it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { | 42 | it("marks a completed thread unread using the server completion timestamp", () => { |
| 56 | const threadId = ThreadId.make("thread-1"); | 43 | const threadId = ThreadId.make("thread-1"); |
| 57 | const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; | ||
| 58 | const initialState = makeUiState({ | 44 | const initialState = makeUiState({ |
| 59 | threadLastVisitedAtById: { | 45 | threadLastVisitedAtById: { |
| 60 | [threadId]: "2026-02-25T12:35:00.000Z", | 46 | [threadId]: "2026-02-25T12:35:00.000Z", |
| 61 | }, | 47 | }, |
| 62 | }); | 48 | }); |
| 63 | 49 | ||
| 64 | const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt); | 50 | const next = markThreadUnread(initialState, threadId, "2026-02-25T12:30:00.000Z"); |
| 65 | 51 | ||
| 66 | expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); | 52 | expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); |
| 53 | expect(markThreadUnread(next, threadId, null)).toBe(next); | ||
| 67 | }); | 54 | }); |
| 68 | 55 | ||
| 69 | it("markThreadUnread does not change a thread without a completed turn", () => { | 56 | it("resolves project expansion from logical, physical, and legacy preference keys", () => { |
| 70 | const threadId = ThreadId.make("thread-1"); | 57 | const physicalKey = "environment:/repo/project"; |
| 71 | const initialState = makeUiState({ | 58 | const legacyKey = legacyProjectCwdPreferenceKey("/repo/project"); |
| 72 | threadLastVisitedAtById: { | ||
| 73 | [threadId]: "2026-02-25T12:35:00.000Z", | ||
| 74 | }, | ||
| 75 | }); | ||
| 76 | 59 | ||
| 77 | const next = markThreadUnread(initialState, threadId, null); | 60 | expect(resolveProjectExpanded({ logical: false, [physicalKey]: true }, ["logical"])).toBe( |
| 78 | 61 | false, | |
| 79 | expect(next).toBe(initialState); | 62 | ); |
| 63 | expect(resolveProjectExpanded({ [physicalKey]: false }, ["new-logical", physicalKey])).toBe( | ||
| 64 | false, | ||
| 65 | ); | ||
| 66 | expect(resolveProjectExpanded({ [legacyKey]: false }, ["new-logical", legacyKey])).toBe(false); | ||
| 67 | expect(resolveProjectExpanded({}, ["new-logical"])).toBe(true); | ||
| 80 | }); | 68 | }); |
| 81 | 69 | ||
| 82 | it("reorderProjects moves a project to a target index", () => { | 70 | it("sets expansion for every stable key belonging to a logical project", () => { |
| 83 | const project1 = ProjectId.make("project-1"); | 71 | const initialState = makeUiState(); |
| 84 | const project2 = ProjectId.make("project-2"); | 72 | const keys = ["logical", "environment-a:/repo", "environment-b:/repo"]; |
| 85 | const project3 = ProjectId.make("project-3"); | ||
| 86 | const initialState = makeUiState({ | ||
| 87 | projectOrder: [project1, project2, project3], | ||
| 88 | }); | ||
| 89 | 73 | ||
| 90 | const next = reorderProjects(initialState, [project1], [project3]); | 74 | const next = setProjectExpanded(initialState, keys, false); |
| 91 | 75 | ||
| 92 | expect(next.projectOrder).toEqual([project2, project3, project1]); | 76 | expect(next.projectExpandedById).toEqual({ |
| 77 | logical: false, | ||
| 78 | "environment-a:/repo": false, | ||
| 79 | "environment-b:/repo": false, | ||
| 80 | }); | ||
| 81 | expect(setProjectExpanded(next, keys, false)).toBe(next); | ||
| 93 | }); | 82 | }); |
| 94 | 83 | ||
| 95 | it("reorderProjects is a no-op when dragged key is not in projectOrder", () => { | 84 | it("reorders from the current atom-derived project order", () => { |
| 96 | const project1 = ProjectId.make("project-1"); | 85 | const project1 = ProjectId.make("project-1"); |
| 97 | const project2 = ProjectId.make("project-2"); | 86 | const project2 = ProjectId.make("project-2"); |
| 98 | const initialState = makeUiState({ | 87 | const project3 = ProjectId.make("project-3"); |
| 99 | projectOrder: [project1, project2], | 88 | const currentOrder = [project1, project2, project3]; |
| 100 | }); | ||
| 101 | |||
| 102 | const next = reorderProjects(initialState, [ProjectId.make("missing")], [project2]); | ||
| 103 | |||
| 104 | expect(next).toBe(initialState); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("setDefaultAdvertisedEndpointKey stores endpoint preference by stable key", () => { | ||
| 108 | const initialState = makeUiState(); | ||
| 109 | 89 | ||
| 110 | const next = setDefaultAdvertisedEndpointKey(initialState, "desktop-core:lan:http"); | 90 | const next = reorderProjects(makeUiState(), currentOrder, [project1], [project3]); |
| 111 | 91 | ||
| 112 | expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); | 92 | expect(next.projectOrder).toEqual([project2, project3, project1]); |
| 113 | expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next); | ||
| 114 | expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({ | ||
| 115 | defaultAdvertisedEndpointKey: null, | ||
| 116 | }); | ||
| 117 | }); | 93 | }); |
| 118 | 94 | ||
| 119 | it("reorderProjects moves all member keys of a multi-member group together", () => { | 95 | it("moves grouped project members together", () => { |
| 120 | const keyALocal = "env-local:proj-a"; | 96 | const keyALocal = "env-local:proj-a"; |
| 121 | const keyARemote = "env-remote:proj-a"; | 97 | const keyARemote = "env-remote:proj-a"; |
| 122 | const keyB = "env-local:proj-b"; | 98 | const keyB = "env-local:proj-b"; |
| 123 | const keyC = "env-local:proj-c"; | 99 | const keyC = "env-local:proj-c"; |
| 124 | const initialState = makeUiState({ | 100 | const currentOrder = [keyALocal, keyARemote, keyB, keyC]; |
| 125 | projectOrder: [keyALocal, keyARemote, keyB, keyC], | ||
| 126 | }); | ||
| 127 | |||
| 128 | const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); | ||
| 129 | |||
| 130 | expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); | ||
| 131 | }); | ||
| 132 | |||
| 133 | it("reorderProjects handles member keys scattered across projectOrder", () => { | ||
| 134 | const keyALocal = "env-local:proj-a"; | ||
| 135 | const keyB = "env-local:proj-b"; | ||
| 136 | const keyARemote = "env-remote:proj-a"; | ||
| 137 | const keyC = "env-local:proj-c"; | ||
| 138 | const initialState = makeUiState({ | ||
| 139 | projectOrder: [keyALocal, keyB, keyARemote, keyC], | ||
| 140 | }); | ||
| 141 | 101 | ||
| 142 | const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); | 102 | const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]); |
| 143 | 103 | ||
| 144 | expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); | 104 | expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); |
| 145 | }); | 105 | }); |
| 146 | 106 | ||
| 147 | it("reorderProjects places group after target when dragged from before a non-last target", () => { | 107 | it("does not reorder missing or identical groups", () => { |
| 148 | const keyALocal = "env-local:proj-a"; | 108 | const currentOrder = ["env-local:proj-a", "env-local:proj-b"]; |
| 149 | const keyARemote = "env-remote:proj-a"; | 109 | const state = makeUiState(); |
| 150 | const keyB = "env-local:proj-b"; | ||
| 151 | const keyC = "env-local:proj-c"; | ||
| 152 | const keyD = "env-local:proj-d"; | ||
| 153 | const initialState = makeUiState({ | ||
| 154 | projectOrder: [keyALocal, keyARemote, keyB, keyC, keyD], | ||
| 155 | }); | ||
| 156 | |||
| 157 | const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); | ||
| 158 | 110 | ||
| 159 | expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote, keyD]); | 111 | expect(reorderProjects(state, currentOrder, ["env-local:missing"], ["env-local:proj-b"])).toBe( |
| 112 | state, | ||
| 113 | ); | ||
| 114 | expect(reorderProjects(state, currentOrder, ["env-local:proj-a"], ["env-local:proj-a"])).toBe( | ||
| 115 | state, | ||
| 116 | ); | ||
| 160 | }); | 117 | }); |
| 161 | 118 | ||
| 162 | it("reorderProjects places group before target when dragged from after", () => { | 119 | it("stores only collapsed changed-file turns", () => { |
| 163 | const keyB = "env-local:proj-b"; | 120 | const threadId = ThreadId.make("thread-1"); |
| 164 | const keyC = "env-local:proj-c"; | 121 | const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); |
| 165 | const keyALocal = "env-local:proj-a"; | ||
| 166 | const keyARemote = "env-remote:proj-a"; | ||
| 167 | const initialState = makeUiState({ | ||
| 168 | projectOrder: [keyB, keyC, keyALocal, keyARemote], | ||
| 169 | }); | ||
| 170 | |||
| 171 | const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyB]); | ||
| 172 | |||
| 173 | expect(next.projectOrder).toEqual([keyALocal, keyARemote, keyB, keyC]); | ||
| 174 | }); | ||
| 175 | 122 | ||
| 176 | it("reorderProjects with multi-member target inserts after first target occurrence", () => { | 123 | expect(collapsed.threadChangedFilesExpandedById).toEqual({ |
| 177 | const keyALocal = "env-local:proj-a"; | 124 | [threadId]: { |
| 178 | const keyARemote = "env-remote:proj-a"; | 125 | "turn-1": false, |
| 179 | const keyBLocal = "env-local:proj-b"; | 126 | }, |
| 180 | const keyBRemote = "env-remote:proj-b"; | ||
| 181 | const initialState = makeUiState({ | ||
| 182 | projectOrder: [keyALocal, keyARemote, keyBLocal, keyBRemote], | ||
| 183 | }); | 127 | }); |
| 184 | 128 | expect( | |
| 185 | const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyBLocal, keyBRemote]); | 129 | setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true) |
| 186 | 130 | .threadChangedFilesExpandedById, | |
| 187 | // Target members may become non-contiguous; this is fine because the | 131 | ).toEqual({}); |
| 188 | // sidebar groups by logical key using first-occurrence positioning. | ||
| 189 | expect(next.projectOrder).toEqual([keyBLocal, keyALocal, keyARemote, keyBRemote]); | ||
| 190 | }); | 132 | }); |
| 191 | 133 | ||
| 192 | it("reorderProjects is a no-op when dragged group equals target group", () => { | 134 | it("stores the endpoint preference by stable key", () => { |
| 193 | const key1 = "env-local:proj-a"; | 135 | const next = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http"); |
| 194 | const key2 = "env-remote:proj-a"; | ||
| 195 | const initialState = makeUiState({ | ||
| 196 | projectOrder: [key1, key2, "env-local:proj-b"], | ||
| 197 | }); | ||
| 198 | |||
| 199 | const next = reorderProjects(initialState, [key1, key2], [key1, key2]); | ||
| 200 | 136 | ||
| 201 | expect(next).toBe(initialState); | 137 | expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); |
| 202 | }); | 138 | expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next); |
| 203 | 139 | expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({ | |
| 204 | it("reorderProjects is a no-op when dragged keys are not in projectOrder", () => { | 140 | defaultAdvertisedEndpointKey: null, |
| 205 | const initialState = makeUiState({ | ||
| 206 | projectOrder: ["env-local:proj-a", "env-local:proj-b"], | ||
| 207 | }); | 141 | }); |
| 208 | |||
| 209 | const next = reorderProjects(initialState, ["env-local:missing"], ["env-local:proj-b"]); | ||
| 210 | |||
| 211 | expect(next).toBe(initialState); | ||
| 212 | }); | 142 | }); |
| 143 | }); | ||
| 213 | 144 | ||
| 214 | it("syncProjects preserves current project order during snapshot recovery", () => { | 145 | describe("parsePersistedState", () => { |
| 215 | const project1 = ProjectId.make("project-1"); | 146 | it("hydrates raw UI-owned state without server entities", () => { |
| 216 | const project2 = ProjectId.make("project-2"); | 147 | const parsed = parsePersistedState({ |
| 217 | const project3 = ProjectId.make("project-3"); | ||
| 218 | const initialState = makeUiState({ | ||
| 219 | projectExpandedById: { | 148 | projectExpandedById: { |
| 220 | [project1]: true, | 149 | logical: false, |
| 221 | [project2]: false, | 150 | invalid: "no" as unknown as boolean, |
| 222 | }, | 151 | }, |
| 223 | projectOrder: [project2, project1], | 152 | projectOrder: ["physical-b", "", "physical-a", "physical-b"], |
| 224 | }); | ||
| 225 | |||
| 226 | const next = syncProjects(initialState, [ | ||
| 227 | { key: project1, logicalKey: project1, cwd: "/tmp/project-1" }, | ||
| 228 | { key: project2, logicalKey: project2, cwd: "/tmp/project-2" }, | ||
| 229 | { key: project3, logicalKey: project3, cwd: "/tmp/project-3" }, | ||
| 230 | ]); | ||
| 231 | |||
| 232 | expect(next.projectOrder).toEqual([project2, project1, project3]); | ||
| 233 | expect(next.projectExpandedById[project2]).toBe(false); | ||
| 234 | }); | ||
| 235 | |||
| 236 | it("syncProjects preserves manual order across project id churn at the same cwd", () => { | ||
| 237 | // Under the current design, physical key and logical key are both | ||
| 238 | // cwd-derived, so an internal project-id change doesn't alter the store | ||
| 239 | // keys. This test locks in that stability: re-syncing the same cwds keeps | ||
| 240 | // manual order and collapse state. | ||
| 241 | const keyProject1 = "env-local:/tmp/project-1"; | ||
| 242 | const keyProject2 = "env-local:/tmp/project-2"; | ||
| 243 | const initialState = syncProjects( | ||
| 244 | makeUiState({ | ||
| 245 | projectExpandedById: { | ||
| 246 | [keyProject1]: true, | ||
| 247 | [keyProject2]: false, | ||
| 248 | }, | ||
| 249 | projectOrder: [keyProject2, keyProject1], | ||
| 250 | }), | ||
| 251 | [ | ||
| 252 | { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, | ||
| 253 | { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, | ||
| 254 | ], | ||
| 255 | ); | ||
| 256 | |||
| 257 | const next = syncProjects(initialState, [ | ||
| 258 | { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, | ||
| 259 | { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, | ||
| 260 | ]); | ||
| 261 | |||
| 262 | expect(next.projectOrder).toEqual([keyProject2, keyProject1]); | ||
| 263 | expect(next.projectExpandedById[keyProject2]).toBe(false); | ||
| 264 | }); | ||
| 265 | |||
| 266 | it("syncProjects returns a new state when only project cwd changes", () => { | ||
| 267 | const project1 = ProjectId.make("project-1"); | ||
| 268 | const initialState = syncProjects( | ||
| 269 | makeUiState({ | ||
| 270 | projectExpandedById: { | ||
| 271 | [project1]: false, | ||
| 272 | }, | ||
| 273 | projectOrder: [project1], | ||
| 274 | }), | ||
| 275 | [{ key: project1, logicalKey: project1, cwd: "/tmp/project-1" }], | ||
| 276 | ); | ||
| 277 | |||
| 278 | const next = syncProjects(initialState, [ | ||
| 279 | { key: project1, logicalKey: project1, cwd: "/tmp/project-1-renamed" }, | ||
| 280 | ]); | ||
| 281 | |||
| 282 | expect(next).not.toBe(initialState); | ||
| 283 | expect(next.projectOrder).toEqual([project1]); | ||
| 284 | expect(next.projectExpandedById[project1]).toBe(false); | ||
| 285 | }); | ||
| 286 | |||
| 287 | it("syncProjects keys projectExpandedById by the logical key, not the physical key", () => { | ||
| 288 | // In repository grouping mode, multiple physical projects (different | ||
| 289 | // environments or different repo-relative paths) collapse into one | ||
| 290 | // logical group. The group's expand state must be keyed by the logical | ||
| 291 | // key so clicks on the grouped row toggle the shared state, and so the | ||
| 292 | // state survives subsequent syncProjects calls (which rebuild the map | ||
| 293 | // from incoming inputs). | ||
| 294 | const physicalLocal = "env-local:/repo/project"; | ||
| 295 | const physicalRemote = "env-remote:/repo/project"; | ||
| 296 | const logicalKey = "repo-canonical-key"; | ||
| 297 | |||
| 298 | const initial = syncProjects(makeUiState(), [ | ||
| 299 | { key: physicalLocal, logicalKey, cwd: "/repo/project" }, | ||
| 300 | { key: physicalRemote, logicalKey, cwd: "/repo/project" }, | ||
| 301 | ]); | ||
| 302 | |||
| 303 | expect(initial.projectExpandedById).toEqual({ [logicalKey]: true }); | ||
| 304 | |||
| 305 | const afterCollapse = { ...initial, projectExpandedById: { [logicalKey]: false } }; | ||
| 306 | const next = syncProjects(afterCollapse, [ | ||
| 307 | { key: physicalLocal, logicalKey, cwd: "/repo/project" }, | ||
| 308 | { key: physicalRemote, logicalKey, cwd: "/repo/project" }, | ||
| 309 | ]); | ||
| 310 | |||
| 311 | expect(next.projectExpandedById[logicalKey]).toBe(false); | ||
| 312 | }); | ||
| 313 | |||
| 314 | it("syncProjects preserves expand state when a project's logical key changes", () => { | ||
| 315 | // Example: late-arriving repo metadata flips grouping identity from the | ||
| 316 | // physical key to a canonical repository key. The row did not actually | ||
| 317 | // change, so the user's collapse choice must carry over. | ||
| 318 | const physicalKey = "env-local:/repo/project"; | ||
| 319 | const previousLogicalKey = physicalKey; | ||
| 320 | const nextLogicalKey = "repo-canonical-key"; | ||
| 321 | |||
| 322 | const initial = syncProjects(makeUiState(), [ | ||
| 323 | { key: physicalKey, logicalKey: previousLogicalKey, cwd: "/repo/project" }, | ||
| 324 | ]); | ||
| 325 | |||
| 326 | expect(initial.projectExpandedById[previousLogicalKey]).toBe(true); | ||
| 327 | |||
| 328 | const afterCollapse = { | ||
| 329 | ...initial, | ||
| 330 | projectExpandedById: { [previousLogicalKey]: false }, | ||
| 331 | }; | ||
| 332 | const next = syncProjects(afterCollapse, [ | ||
| 333 | { key: physicalKey, logicalKey: nextLogicalKey, cwd: "/repo/project" }, | ||
| 334 | ]); | ||
| 335 | |||
| 336 | expect(next.projectExpandedById[nextLogicalKey]).toBe(false); | ||
| 337 | }); | ||
| 338 | |||
| 339 | it("syncThreads prunes missing thread UI state", () => { | ||
| 340 | const thread1 = ThreadId.make("thread-1"); | ||
| 341 | const thread2 = ThreadId.make("thread-2"); | ||
| 342 | const initialState = makeUiState({ | ||
| 343 | threadLastVisitedAtById: { | 153 | threadLastVisitedAtById: { |
| 344 | [thread1]: "2026-02-25T12:35:00.000Z", | 154 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 345 | [thread2]: "2026-02-25T12:36:00.000Z", | 155 | invalid: "not-a-date", |
| 346 | }, | 156 | }, |
| 157 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", | ||
| 347 | threadChangedFilesExpandedById: { | 158 | threadChangedFilesExpandedById: { |
| 348 | [thread1]: { | 159 | "environment:thread-1": { |
| 349 | "turn-1": false, | 160 | "turn-1": false, |
| 161 | "turn-2": true, | ||
| 350 | }, | 162 | }, |
| 351 | [thread2]: { | ||
| 352 | "turn-2": false, | ||
| 353 | }, | ||
| 354 | }, | ||
| 355 | }); | ||
| 356 | |||
| 357 | const next = syncThreads(initialState, [{ key: thread1 }]); | ||
| 358 | |||
| 359 | expect(next.threadLastVisitedAtById).toEqual({ | ||
| 360 | [thread1]: "2026-02-25T12:35:00.000Z", | ||
| 361 | }); | ||
| 362 | expect(next.threadChangedFilesExpandedById).toEqual({ | ||
| 363 | [thread1]: { | ||
| 364 | "turn-1": false, | ||
| 365 | }, | ||
| 366 | }); | ||
| 367 | }); | ||
| 368 | |||
| 369 | it("syncThreads seeds visit state for unseen snapshot threads", () => { | ||
| 370 | const thread1 = ThreadId.make("thread-1"); | ||
| 371 | const initialState = makeUiState(); | ||
| 372 | |||
| 373 | const next = syncThreads(initialState, [ | ||
| 374 | { | ||
| 375 | key: thread1, | ||
| 376 | seedVisitedAt: "2026-02-25T12:35:00.000Z", | ||
| 377 | }, | 163 | }, |
| 378 | ]); | ||
| 379 | |||
| 380 | expect(next.threadLastVisitedAtById).toEqual({ | ||
| 381 | [thread1]: "2026-02-25T12:35:00.000Z", | ||
| 382 | }); | 164 | }); |
| 383 | }); | ||
| 384 | 165 | ||
| 385 | it("setProjectExpanded updates expansion without touching order", () => { | 166 | expect(parsed).toEqual({ |
| 386 | const project1 = ProjectId.make("project-1"); | ||
| 387 | const initialState = makeUiState({ | ||
| 388 | projectExpandedById: { | 167 | projectExpandedById: { |
| 389 | [project1]: true, | 168 | logical: false, |
| 390 | }, | 169 | }, |
| 391 | projectOrder: [project1], | 170 | projectOrder: ["physical-b", "physical-a"], |
| 392 | }); | ||
| 393 | |||
| 394 | const next = setProjectExpanded(initialState, project1, false); | ||
| 395 | |||
| 396 | expect(next.projectExpandedById[project1]).toBe(false); | ||
| 397 | expect(next.projectOrder).toEqual([project1]); | ||
| 398 | }); | ||
| 399 | |||
| 400 | it("clearThreadUi removes visit state for deleted threads", () => { | ||
| 401 | const thread1 = ThreadId.make("thread-1"); | ||
| 402 | const initialState = makeUiState({ | ||
| 403 | threadLastVisitedAtById: { | 171 | threadLastVisitedAtById: { |
| 404 | [thread1]: "2026-02-25T12:35:00.000Z", | 172 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 405 | }, | 173 | }, |
| 174 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", | ||
| 406 | threadChangedFilesExpandedById: { | 175 | threadChangedFilesExpandedById: { |
| 407 | [thread1]: { | 176 | "environment:thread-1": { |
| 408 | "turn-1": false, | 177 | "turn-1": false, |
| 409 | }, | 178 | }, |
| 410 | }, | 179 | }, |
| 411 | }); | 180 | }); |
| 412 | |||
| 413 | const next = clearThreadUi(initialState, thread1); | ||
| 414 | |||
| 415 | expect(next.threadLastVisitedAtById).toEqual({}); | ||
| 416 | expect(next.threadChangedFilesExpandedById).toEqual({}); | ||
| 417 | }); | 181 | }); |
| 418 | 182 | ||
| 419 | it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { | 183 | it("migrates legacy CWD project preferences into local alias keys", () => { |
| 420 | const thread1 = ThreadId.make("thread-1"); | 184 | const parsed = parsePersistedState({ |
| 421 | const initialState = makeUiState(); | 185 | collapsedProjectCwds: ["/repo/b"], |
| 422 | 186 | expandedProjectCwds: ["/repo/a"], | |
| 423 | const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false); | 187 | projectOrderCwds: ["/repo/b", "/repo/a"], |
| 424 | |||
| 425 | expect(next.threadChangedFilesExpandedById).toEqual({ | ||
| 426 | [thread1]: { | ||
| 427 | "turn-1": false, | ||
| 428 | }, | ||
| 429 | }); | 188 | }); |
| 189 | const projectAKey = legacyProjectCwdPreferenceKey("/repo/a"); | ||
| 190 | const projectBKey = legacyProjectCwdPreferenceKey("/repo/b"); | ||
| 191 | |||
| 192 | expect(parsed.projectOrder).toEqual([projectBKey, projectAKey]); | ||
| 193 | expect(resolveProjectExpanded(parsed.projectExpandedById, [projectAKey])).toBe(true); | ||
| 194 | expect(resolveProjectExpanded(parsed.projectExpandedById, [projectBKey])).toBe(false); | ||
| 195 | expect(resolveProjectExpanded(parsed.projectExpandedById, ["unknown"])).toBe(true); | ||
| 430 | }); | 196 | }); |
| 431 | 197 | ||
| 432 | it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { | 198 | it("preserves legacy expanded-only semantics for one-way migration", () => { |
| 433 | const thread1 = ThreadId.make("thread-1"); | 199 | const parsed = parsePersistedState({ |
| 434 | const initialState = makeUiState({ | 200 | expandedProjectCwds: ["/repo/a"], |
| 435 | threadChangedFilesExpandedById: { | ||
| 436 | [thread1]: { | ||
| 437 | "turn-1": false, | ||
| 438 | }, | ||
| 439 | }, | ||
| 440 | }); | 201 | }); |
| 441 | 202 | ||
| 442 | const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true); | 203 | expect( |
| 443 | 204 | resolveProjectExpanded(parsed.projectExpandedById, [ | |
| 444 | expect(next.threadChangedFilesExpandedById).toEqual({}); | 205 | legacyProjectCwdPreferenceKey("/repo/a"), |
| 206 | ]), | ||
| 207 | ).toBe(true); | ||
| 208 | expect( | ||
| 209 | resolveProjectExpanded(parsed.projectExpandedById, [ | ||
| 210 | legacyProjectCwdPreferenceKey("/repo/b"), | ||
| 211 | ]), | ||
| 212 | ).toBe(false); | ||
| 445 | }); | 213 | }); |
| 446 | }); | 214 | }); |
| 447 | 215 | ||
| ... | @@ -465,146 +233,77 @@ function createLocalStorageStub(): Storage { | ... | @@ -465,146 +233,77 @@ function createLocalStorageStub(): Storage { |
| 465 | }; | 233 | }; |
| 466 | } | 234 | } |
| 467 | 235 | ||
| 468 | describe("uiStateStore persistence round-trip", () => { | 236 | describe("uiStateStore persistence", () => { |
| 469 | let localStorageStub: Storage; | 237 | let localStorageStub: Storage; |
| 470 | 238 | ||
| 471 | beforeEach(() => { | 239 | beforeEach(() => { |
| 472 | localStorageStub = createLocalStorageStub(); | 240 | localStorageStub = createLocalStorageStub(); |
| 473 | vi.stubGlobal("window", { localStorage: localStorageStub }); | 241 | vi.stubGlobal("window", { localStorage: localStorageStub }); |
| 474 | vi.stubGlobal("localStorage", localStorageStub); | 242 | vi.stubGlobal("localStorage", localStorageStub); |
| 475 | // Reset module-level persistence state so tests don't bleed into each other. | ||
| 476 | hydratePersistedProjectState({ collapsedProjectCwds: [], expandedProjectCwds: [] }); | ||
| 477 | }); | 243 | }); |
| 478 | 244 | ||
| 479 | afterEach(() => { | 245 | afterEach(() => { |
| 480 | vi.unstubAllGlobals(); | 246 | vi.unstubAllGlobals(); |
| 481 | }); | 247 | }); |
| 482 | 248 | ||
| 483 | it("preserves all-collapsed project state across restart", () => { | 249 | it("persists raw UI preferences including thread visit markers", () => { |
| 484 | // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so | 250 | const state = makeUiState({ |
| 485 | // an empty array on rehydrate was indistinguishable from a fresh install | 251 | projectExpandedById: { |
| 486 | // and the syncProjects fallback re-expanded every row. | 252 | logical: false, |
| 487 | const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; | 253 | }, |
| 488 | const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; | 254 | projectOrder: ["physical-b", "physical-a"], |
| 489 | 255 | threadLastVisitedAtById: { | |
| 490 | let state = syncProjects(makeUiState(), [projectA, projectB]); | 256 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 491 | state = setProjectExpanded(state, projectA.key, false); | 257 | }, |
| 492 | state = setProjectExpanded(state, projectB.key, false); | 258 | threadChangedFilesExpandedById: { |
| 493 | persistState(state); | 259 | "environment:thread-1": { |
| 494 | 260 | "turn-1": false, | |
| 495 | const persisted = JSON.parse( | 261 | "turn-2": true, |
| 496 | localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", | 262 | }, |
| 497 | ) as PersistedUiState; | 263 | }, |
| 498 | hydratePersistedProjectState(persisted); | 264 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 499 | const rehydrated = syncProjects(makeUiState(), [projectA, projectB]); | ||
| 500 | |||
| 501 | expect(rehydrated.projectExpandedById).toEqual({ | ||
| 502 | [projectA.key]: false, | ||
| 503 | [projectB.key]: false, | ||
| 504 | }); | 265 | }); |
| 505 | }); | ||
| 506 | |||
| 507 | it("respects mixed expand state on rehydrate and defaults new projects to expanded", () => { | ||
| 508 | const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; | ||
| 509 | const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; | ||
| 510 | const projectC = { key: "kC", logicalKey: "kC", cwd: "/projC" }; | ||
| 511 | 266 | ||
| 512 | let state = syncProjects(makeUiState(), [projectA, projectB]); | ||
| 513 | state = setProjectExpanded(state, projectB.key, false); | ||
| 514 | persistState(state); | 267 | persistState(state); |
| 515 | 268 | ||
| 516 | const persisted = JSON.parse( | 269 | const persisted = JSON.parse( |
| 517 | localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", | 270 | localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", |
| 518 | ) as PersistedUiState; | 271 | ) as PersistedUiState; |
| 519 | hydratePersistedProjectState(persisted); | 272 | expect(persisted).toEqual({ |
| 520 | const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); | 273 | projectExpandedById: { |
| 521 | 274 | logical: false, | |
| 522 | expect(rehydrated.projectExpandedById).toEqual({ | 275 | }, |
| 523 | [projectA.key]: true, | 276 | projectOrder: ["physical-b", "physical-a"], |
| 524 | [projectB.key]: false, | 277 | threadLastVisitedAtById: { |
| 525 | [projectC.key]: true, | 278 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 526 | }); | 279 | }, |
| 527 | }); | 280 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 528 | 281 | threadChangedFilesExpandedById: { | |
| 529 | it("preserves legacy not-in-expanded-list = collapsed for one upgrade session", () => { | 282 | "environment:thread-1": { |
| 530 | // Pre-fix shape only stored expandedProjectCwds. Absence of | 283 | "turn-1": false, |
| 531 | // collapsedProjectCwds opts the session into the legacy fallback so | 284 | }, |
| 532 | // upgrade users do not see previously collapsed rows pop open. | 285 | }, |
| 533 | hydratePersistedProjectState({ | ||
| 534 | expandedProjectCwds: ["/projA"], | ||
| 535 | }); | 286 | }); |
| 536 | 287 | expect(parsePersistedState(persisted)).toEqual({ | |
| 537 | const rehydrated = syncProjects(makeUiState(), [ | 288 | ...state, |
| 538 | { key: "kA", logicalKey: "kA", cwd: "/projA" }, | 289 | threadChangedFilesExpandedById: { |
| 539 | { key: "kB", logicalKey: "kB", cwd: "/projB" }, | 290 | "environment:thread-1": { |
| 540 | ]); | 291 | "turn-1": false, |
| 541 | 292 | }, | |
| 542 | expect(rehydrated.projectExpandedById).toEqual({ | 293 | }, |
| 543 | kA: true, | ||
| 544 | kB: false, | ||
| 545 | }); | 294 | }); |
| 546 | }); | 295 | }); |
| 547 | 296 | ||
| 548 | it("preserves manual project order across restart", () => { | 297 | it("drops the temporary expanded-only migration fallback when rewriting state", () => { |
| 549 | const projectA = { key: "kOrderA", logicalKey: "kOrderA", cwd: "/order-projA" }; | 298 | const migrated = parsePersistedState({ |
| 550 | const projectB = { key: "kOrderB", logicalKey: "kOrderB", cwd: "/order-projB" }; | 299 | expandedProjectCwds: ["/repo/a"], |
| 551 | const projectC = { key: "kOrderC", logicalKey: "kOrderC", cwd: "/order-projC" }; | 300 | }); |
| 552 | |||
| 553 | let state = syncProjects(makeUiState(), [projectA, projectB, projectC]); | ||
| 554 | state = reorderProjects(state, [projectC.key], [projectA.key]); | ||
| 555 | expect(state.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); | ||
| 556 | persistState(state); | ||
| 557 | |||
| 558 | const persisted = JSON.parse( | ||
| 559 | localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", | ||
| 560 | ) as PersistedUiState; | ||
| 561 | expect(persisted.projectOrderCwds).toEqual([projectC.cwd, projectA.cwd, projectB.cwd]); | ||
| 562 | |||
| 563 | hydratePersistedProjectState(persisted); | ||
| 564 | // Fresh state (empty projectOrder) so syncProjects derives order from | ||
| 565 | // persistedProjectOrderCwds rather than the in-memory projectOrder branch. | ||
| 566 | const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); | ||
| 567 | |||
| 568 | expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); | ||
| 569 | }); | ||
| 570 | |||
| 571 | it("persists the default advertised endpoint preference", () => { | ||
| 572 | const state = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http"); | ||
| 573 | |||
| 574 | persistState(state); | ||
| 575 | |||
| 576 | const persisted = JSON.parse( | ||
| 577 | localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", | ||
| 578 | ) as PersistedUiState; | ||
| 579 | expect(persisted.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); | ||
| 580 | }); | ||
| 581 | 301 | ||
| 582 | it("preserves expand state across restart when project's logical key changes", () => { | 302 | persistState(migrated); |
| 583 | // After restart, in-memory previousExpandedById is empty, so the | ||
| 584 | // previousLogicalKey-to-state bridge in syncProjects cannot help. The | ||
| 585 | // persisted-cwd fallback is the only mechanism that can carry collapse | ||
| 586 | // state across a restart that also flips a project into a new logical | ||
| 587 | // group (e.g. late-arriving repo metadata). This locks in that path. | ||
| 588 | const physicalKey = "env-local:/lk-restart-proj"; | ||
| 589 | const previousLogicalKey = physicalKey; | ||
| 590 | const cwd = "/lk-restart-proj"; | ||
| 591 | |||
| 592 | let state = syncProjects(makeUiState(), [ | ||
| 593 | { key: physicalKey, logicalKey: previousLogicalKey, cwd }, | ||
| 594 | ]); | ||
| 595 | state = setProjectExpanded(state, previousLogicalKey, false); | ||
| 596 | persistState(state); | ||
| 597 | 303 | ||
| 598 | const persisted = JSON.parse( | 304 | const persisted = JSON.parse( |
| 599 | localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", | 305 | localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", |
| 600 | ) as PersistedUiState; | 306 | ) as PersistedUiState; |
| 601 | hydratePersistedProjectState(persisted); | 307 | expect(resolveProjectExpanded(persisted.projectExpandedById ?? {}, ["unknown"])).toBe(true); |
| 602 | |||
| 603 | const nextLogicalKey = "lk-restart-canonical"; | ||
| 604 | const rehydrated = syncProjects(makeUiState(), [ | ||
| 605 | { key: physicalKey, logicalKey: nextLogicalKey, cwd }, | ||
| 606 | ]); | ||
| 607 | |||
| 608 | expect(rehydrated.projectExpandedById[nextLogicalKey]).toBe(false); | ||
| 609 | }); | 308 | }); |
| 610 | }); | 309 | }); |
apps/web/src/uiStateStore.ts+136-363| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { Debouncer } from "@tanstack/react-pacer"; | 1 | import { Debouncer } from "@tanstack/react-pacer"; |
| 2 | import { create } from "zustand"; | 2 | import { create } from "zustand"; |
| 3 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; | ||
| 3 | 4 | ||
| 4 | export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; | 5 | export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; |
| 5 | const LEGACY_PERSISTED_STATE_KEYS = [ | 6 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| ... | @@ -16,6 +17,9 @@ const LEGACY_PERSISTED_STATE_KEYS = [ | ... | @@ -16,6 +17,9 @@ const LEGACY_PERSISTED_STATE_KEYS = [ |
| 16 | ] as const; | 17 | ] as const; |
| 17 | 18 | ||
| 18 | export interface PersistedUiState { | 19 | export interface PersistedUiState { |
| 20 | projectExpandedById?: Record<string, boolean>; | ||
| 21 | projectOrder?: string[]; | ||
| 22 | threadLastVisitedAtById?: Record<string, string>; | ||
| 19 | collapsedProjectCwds?: string[]; | 23 | collapsedProjectCwds?: string[]; |
| 20 | expandedProjectCwds?: string[]; | 24 | expandedProjectCwds?: string[]; |
| 21 | projectOrderCwds?: string[]; | 25 | projectOrderCwds?: string[]; |
| ... | @@ -39,19 +43,6 @@ export interface UiEndpointState { | ... | @@ -39,19 +43,6 @@ export interface UiEndpointState { |
| 39 | 43 | ||
| 40 | export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} | 44 | export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} |
| 41 | 45 | ||
| 42 | export interface SyncProjectInput { | ||
| 43 | /** Physical project key (env + cwd). Used for manual sort order. */ | ||
| 44 | key: string; | ||
| 45 | /** Logical group key. Used for expand/collapse state. */ | ||
| 46 | logicalKey: string; | ||
| 47 | cwd: string; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface SyncThreadInput { | ||
| 51 | key: string; | ||
| 52 | seedVisitedAt?: string | undefined; | ||
| 53 | } | ||
| 54 | |||
| 55 | const initialState: UiState = { | 46 | const initialState: UiState = { |
| 56 | projectExpandedById: {}, | 47 | projectExpandedById: {}, |
| 57 | projectOrder: [], | 48 | projectOrder: [], |
| ... | @@ -60,20 +51,90 @@ const initialState: UiState = { | ... | @@ -60,20 +51,90 @@ const initialState: UiState = { |
| 60 | defaultAdvertisedEndpointKey: null, | 51 | defaultAdvertisedEndpointKey: null, |
| 61 | }; | 52 | }; |
| 62 | 53 | ||
| 63 | const persistedCollapsedProjectCwds = new Set<string>(); | 54 | const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; |
| 64 | const persistedExpandedProjectCwds = new Set<string>(); | 55 | const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default"; |
| 65 | const persistedProjectOrderCwds: string[] = []; | ||
| 66 | const persistedProjectOrderCwdSet = new Set<string>(); | ||
| 67 | // Pre-fix persisted shape only listed expanded cwds, so anything not listed | ||
| 68 | // was treated as collapsed. Track whether the loaded blob carried the new | ||
| 69 | // `collapsedProjectCwds` field so we can preserve that legacy semantic for | ||
| 70 | // one session after upgrade, until persistState rewrites in the new shape. | ||
| 71 | let persistedProjectStateUsesLegacyShape = false; | ||
| 72 | const currentProjectCwdById = new Map<string, string>(); | ||
| 73 | const currentProjectCwdsByLogicalKey = new Map<string, string[]>(); | ||
| 74 | const currentLogicalKeyByPhysicalKey = new Map<string, string>(); | ||
| 75 | let legacyKeysCleanedUp = false; | 56 | let legacyKeysCleanedUp = false; |
| 76 | 57 | ||
| 58 | export function legacyProjectCwdPreferenceKey(cwd: string): string { | ||
| 59 | return `${LEGACY_PROJECT_CWD_PREFERENCE_PREFIX}${normalizeProjectPathForComparison(cwd)}`; | ||
| 60 | } | ||
| 61 | |||
| 62 | function sanitizeStringArray(value: unknown): string[] { | ||
| 63 | if (!Array.isArray(value)) { | ||
| 64 | return []; | ||
| 65 | } | ||
| 66 | return [ | ||
| 67 | ...new Set( | ||
| 68 | value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0), | ||
| 69 | ), | ||
| 70 | ]; | ||
| 71 | } | ||
| 72 | |||
| 73 | function sanitizeBooleanRecord(value: unknown): Record<string, boolean> { | ||
| 74 | if (!value || typeof value !== "object") { | ||
| 75 | return {}; | ||
| 76 | } | ||
| 77 | return Object.fromEntries( | ||
| 78 | Object.entries(value).filter( | ||
| 79 | (entry): entry is [string, boolean] => entry[0].length > 0 && typeof entry[1] === "boolean", | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | |||
| 84 | function sanitizeTimestampRecord(value: unknown): Record<string, string> { | ||
| 85 | if (!value || typeof value !== "object") { | ||
| 86 | return {}; | ||
| 87 | } | ||
| 88 | return Object.fromEntries( | ||
| 89 | Object.entries(value).filter( | ||
| 90 | (entry): entry is [string, string] => | ||
| 91 | entry[0].length > 0 && | ||
| 92 | typeof entry[1] === "string" && | ||
| 93 | entry[1].length > 0 && | ||
| 94 | Number.isFinite(Date.parse(entry[1])), | ||
| 95 | ), | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | |||
| 99 | export function parsePersistedState(parsed: PersistedUiState): UiState { | ||
| 100 | const projectExpandedById = | ||
| 101 | parsed.projectExpandedById === undefined | ||
| 102 | ? (() => { | ||
| 103 | const migrated: Record<string, boolean> = {}; | ||
| 104 | const collapsedProjectCwds = sanitizeStringArray(parsed.collapsedProjectCwds); | ||
| 105 | const expandedProjectCwds = sanitizeStringArray(parsed.expandedProjectCwds); | ||
| 106 | for (const cwd of collapsedProjectCwds) { | ||
| 107 | migrated[legacyProjectCwdPreferenceKey(cwd)] = false; | ||
| 108 | } | ||
| 109 | for (const cwd of expandedProjectCwds) { | ||
| 110 | migrated[legacyProjectCwdPreferenceKey(cwd)] = true; | ||
| 111 | } | ||
| 112 | if (!Array.isArray(parsed.collapsedProjectCwds) && expandedProjectCwds.length > 0) { | ||
| 113 | migrated[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] = false; | ||
| 114 | } | ||
| 115 | return migrated; | ||
| 116 | })() | ||
| 117 | : sanitizeBooleanRecord(parsed.projectExpandedById); | ||
| 118 | const projectOrder = | ||
| 119 | parsed.projectOrder === undefined | ||
| 120 | ? sanitizeStringArray(parsed.projectOrderCwds).map(legacyProjectCwdPreferenceKey) | ||
| 121 | : sanitizeStringArray(parsed.projectOrder); | ||
| 122 | |||
| 123 | return { | ||
| 124 | projectExpandedById, | ||
| 125 | projectOrder, | ||
| 126 | threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), | ||
| 127 | threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( | ||
| 128 | parsed.threadChangedFilesExpandedById, | ||
| 129 | ), | ||
| 130 | defaultAdvertisedEndpointKey: | ||
| 131 | typeof parsed.defaultAdvertisedEndpointKey === "string" && | ||
| 132 | parsed.defaultAdvertisedEndpointKey.length > 0 | ||
| 133 | ? parsed.defaultAdvertisedEndpointKey | ||
| 134 | : null, | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | |||
| 77 | function readPersistedState(): UiState { | 138 | function readPersistedState(): UiState { |
| 78 | if (typeof window === "undefined") { | 139 | if (typeof window === "undefined") { |
| 79 | return initialState; | 140 | return initialState; |
| ... | @@ -86,24 +147,11 @@ function readPersistedState(): UiState { | ... | @@ -86,24 +147,11 @@ function readPersistedState(): UiState { |
| 86 | if (!legacyRaw) { | 147 | if (!legacyRaw) { |
| 87 | continue; | 148 | continue; |
| 88 | } | 149 | } |
| 89 | hydratePersistedProjectState(JSON.parse(legacyRaw) as PersistedUiState); | 150 | return parsePersistedState(JSON.parse(legacyRaw) as PersistedUiState); |
| 90 | return initialState; | ||
| 91 | } | 151 | } |
| 92 | return initialState; | 152 | return initialState; |
| 93 | } | 153 | } |
| 94 | const parsed = JSON.parse(raw) as PersistedUiState; | 154 | return parsePersistedState(JSON.parse(raw) as PersistedUiState); |
| 95 | hydratePersistedProjectState(parsed); | ||
| 96 | return { | ||
| 97 | ...initialState, | ||
| 98 | defaultAdvertisedEndpointKey: | ||
| 99 | typeof parsed.defaultAdvertisedEndpointKey === "string" && | ||
| 100 | parsed.defaultAdvertisedEndpointKey.length > 0 | ||
| 101 | ? parsed.defaultAdvertisedEndpointKey | ||
| 102 | : null, | ||
| 103 | threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( | ||
| 104 | parsed.threadChangedFilesExpandedById, | ||
| 105 | ), | ||
| 106 | }; | ||
| 107 | } catch { | 155 | } catch { |
| 108 | return initialState; | 156 | return initialState; |
| 109 | } | 157 | } |
| ... | @@ -137,48 +185,16 @@ function sanitizePersistedThreadChangedFilesExpanded( | ... | @@ -137,48 +185,16 @@ function sanitizePersistedThreadChangedFilesExpanded( |
| 137 | return nextState; | 185 | return nextState; |
| 138 | } | 186 | } |
| 139 | 187 | ||
| 140 | export function hydratePersistedProjectState(parsed: PersistedUiState): void { | ||
| 141 | persistedCollapsedProjectCwds.clear(); | ||
| 142 | persistedExpandedProjectCwds.clear(); | ||
| 143 | persistedProjectOrderCwds.length = 0; | ||
| 144 | persistedProjectOrderCwdSet.clear(); | ||
| 145 | persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds); | ||
| 146 | for (const cwd of parsed.collapsedProjectCwds ?? []) { | ||
| 147 | if (typeof cwd === "string" && cwd.length > 0) { | ||
| 148 | persistedCollapsedProjectCwds.add(cwd); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | for (const cwd of parsed.expandedProjectCwds ?? []) { | ||
| 152 | if (typeof cwd === "string" && cwd.length > 0) { | ||
| 153 | persistedExpandedProjectCwds.add(cwd); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | for (const cwd of parsed.projectOrderCwds ?? []) { | ||
| 157 | if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwdSet.has(cwd)) { | ||
| 158 | persistedProjectOrderCwdSet.add(cwd); | ||
| 159 | persistedProjectOrderCwds.push(cwd); | ||
| 160 | } | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | export function persistState(state: UiState): void { | 188 | export function persistState(state: UiState): void { |
| 165 | if (typeof window === "undefined") { | 189 | if (typeof window === "undefined") { |
| 166 | return; | 190 | return; |
| 167 | } | 191 | } |
| 168 | try { | 192 | try { |
| 169 | // Persist collapsed cwds explicitly so an empty/missing field unambiguously | 193 | const projectExpandedById = Object.fromEntries( |
| 170 | // means "first install" rather than "user collapsed everything"; without | 194 | Object.entries(state.projectExpandedById).filter( |
| 171 | // this, the syncProjects fallback would re-expand all rows on next launch. | 195 | ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, |
| 172 | const collapsedProjectCwds = Object.entries(state.projectExpandedById) | 196 | ), |
| 173 | .filter(([, expanded]) => !expanded) | 197 | ); |
| 174 | .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); | ||
| 175 | const expandedProjectCwds = Object.entries(state.projectExpandedById) | ||
| 176 | .filter(([, expanded]) => expanded) | ||
| 177 | .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); | ||
| 178 | const projectOrderCwds = state.projectOrder.flatMap((projectId) => { | ||
| 179 | const cwd = currentProjectCwdById.get(projectId); | ||
| 180 | return cwd ? [cwd] : []; | ||
| 181 | }); | ||
| 182 | const threadChangedFilesExpandedById = Object.fromEntries( | 198 | const threadChangedFilesExpandedById = Object.fromEntries( |
| 183 | Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { | 199 | Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { |
| 184 | const nextTurns = Object.fromEntries( | 200 | const nextTurns = Object.fromEntries( |
| ... | @@ -190,9 +206,9 @@ export function persistState(state: UiState): void { | ... | @@ -190,9 +206,9 @@ export function persistState(state: UiState): void { |
| 190 | window.localStorage.setItem( | 206 | window.localStorage.setItem( |
| 191 | PERSISTED_STATE_KEY, | 207 | PERSISTED_STATE_KEY, |
| 192 | JSON.stringify({ | 208 | JSON.stringify({ |
| 193 | collapsedProjectCwds, | 209 | projectExpandedById, |
| 194 | expandedProjectCwds, | 210 | projectOrder: state.projectOrder, |
| 195 | projectOrderCwds, | 211 | threadLastVisitedAtById: state.threadLastVisitedAtById, |
| 196 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, | 212 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, |
| 197 | threadChangedFilesExpandedById, | 213 | threadChangedFilesExpandedById, |
| 198 | } satisfies PersistedUiState), | 214 | } satisfies PersistedUiState), |
| ... | @@ -210,242 +226,11 @@ export function persistState(state: UiState): void { | ... | @@ -210,242 +226,11 @@ export function persistState(state: UiState): void { |
| 210 | 226 | ||
| 211 | const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); | 227 | const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); |
| 212 | 228 | ||
| 213 | function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boolean { | 229 | export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState { |
| 214 | const leftEntries = Object.entries(left); | 230 | const visitedAtMs = Date.parse(visitedAt); |
| 215 | const rightEntries = Object.entries(right); | 231 | if (!Number.isFinite(visitedAtMs)) { |
| 216 | if (leftEntries.length !== rightEntries.length) { | ||
| 217 | return false; | ||
| 218 | } | ||
| 219 | for (const [key, value] of leftEntries) { | ||
| 220 | if (right[key] !== value) { | ||
| 221 | return false; | ||
| 222 | } | ||
| 223 | } | ||
| 224 | return true; | ||
| 225 | } | ||
| 226 | |||
| 227 | function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean { | ||
| 228 | return ( | ||
| 229 | left.length === right.length && left.every((projectId, index) => projectId === right[index]) | ||
| 230 | ); | ||
| 231 | } | ||
| 232 | |||
| 233 | function nestedBooleanRecordsEqual( | ||
| 234 | left: Record<string, Record<string, boolean>>, | ||
| 235 | right: Record<string, Record<string, boolean>>, | ||
| 236 | ): boolean { | ||
| 237 | const leftEntries = Object.entries(left); | ||
| 238 | const rightEntries = Object.entries(right); | ||
| 239 | if (leftEntries.length !== rightEntries.length) { | ||
| 240 | return false; | ||
| 241 | } | ||
| 242 | for (const [key, value] of leftEntries) { | ||
| 243 | if (!(key in right) || !recordsEqual(value, right[key]!)) { | ||
| 244 | return false; | ||
| 245 | } | ||
| 246 | } | ||
| 247 | return true; | ||
| 248 | } | ||
| 249 | |||
| 250 | export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { | ||
| 251 | const previousProjectCwdById = new Map(currentProjectCwdById); | ||
| 252 | const previousLogicalKeyByPhysicalKey = new Map(currentLogicalKeyByPhysicalKey); | ||
| 253 | currentProjectCwdById.clear(); | ||
| 254 | currentLogicalKeyByPhysicalKey.clear(); | ||
| 255 | for (const project of projects) { | ||
| 256 | currentProjectCwdById.set(project.key, project.cwd); | ||
| 257 | currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey); | ||
| 258 | } | ||
| 259 | currentProjectCwdsByLogicalKey.clear(); | ||
| 260 | const currentProjectCwdSetsByLogicalKey = new Map<string, Set<string>>(); | ||
| 261 | for (const project of projects) { | ||
| 262 | const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey); | ||
| 263 | if (cwds) { | ||
| 264 | let cwdSet = currentProjectCwdSetsByLogicalKey.get(project.logicalKey); | ||
| 265 | if (!cwdSet) { | ||
| 266 | cwdSet = new Set(cwds); | ||
| 267 | currentProjectCwdSetsByLogicalKey.set(project.logicalKey, cwdSet); | ||
| 268 | } | ||
| 269 | if (!cwdSet.has(project.cwd)) { | ||
| 270 | cwdSet.add(project.cwd); | ||
| 271 | cwds.push(project.cwd); | ||
| 272 | } | ||
| 273 | } else { | ||
| 274 | currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]); | ||
| 275 | currentProjectCwdSetsByLogicalKey.set(project.logicalKey, new Set([project.cwd])); | ||
| 276 | } | ||
| 277 | } | ||
| 278 | // Build reverse map: for each new logical key, which previous logical keys | ||
| 279 | // did its member projects live under? Lets us preserve expand state when a | ||
| 280 | // project's logical key changes (e.g. late-arriving repo metadata flips the | ||
| 281 | // group identity). | ||
| 282 | const previousLogicalKeysByNewLogicalKey = new Map<string, Set<string>>(); | ||
| 283 | for (const project of projects) { | ||
| 284 | const previousLogicalKey = previousLogicalKeyByPhysicalKey.get(project.key); | ||
| 285 | if (!previousLogicalKey || previousLogicalKey === project.logicalKey) { | ||
| 286 | continue; | ||
| 287 | } | ||
| 288 | const set = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); | ||
| 289 | if (set) { | ||
| 290 | set.add(previousLogicalKey); | ||
| 291 | } else { | ||
| 292 | previousLogicalKeysByNewLogicalKey.set(project.logicalKey, new Set([previousLogicalKey])); | ||
| 293 | } | ||
| 294 | } | ||
| 295 | const cwdMappingChanged = | ||
| 296 | previousProjectCwdById.size !== currentProjectCwdById.size || | ||
| 297 | projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); | ||
| 298 | |||
| 299 | const nextExpandedById: Record<string, boolean> = {}; | ||
| 300 | const previousExpandedById = state.projectExpandedById; | ||
| 301 | const persistedOrderByCwd = new Map( | ||
| 302 | persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), | ||
| 303 | ); | ||
| 304 | const mappedProjects = projects.map((project, index) => { | ||
| 305 | if (!(project.logicalKey in nextExpandedById)) { | ||
| 306 | const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd]; | ||
| 307 | const fallbackFromPreviousLogicalKey = (() => { | ||
| 308 | const previousKeys = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); | ||
| 309 | if (!previousKeys) { | ||
| 310 | return undefined; | ||
| 311 | } | ||
| 312 | for (const previousKey of previousKeys) { | ||
| 313 | if (previousKey in previousExpandedById) { | ||
| 314 | return previousExpandedById[previousKey]; | ||
| 315 | } | ||
| 316 | } | ||
| 317 | return undefined; | ||
| 318 | })(); | ||
| 319 | const fallbackFromPersistedShape = (() => { | ||
| 320 | if (groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd))) { | ||
| 321 | return true; | ||
| 322 | } | ||
| 323 | if (groupCwds.some((cwd) => persistedCollapsedProjectCwds.has(cwd))) { | ||
| 324 | return false; | ||
| 325 | } | ||
| 326 | if (persistedProjectStateUsesLegacyShape && persistedExpandedProjectCwds.size > 0) { | ||
| 327 | return false; | ||
| 328 | } | ||
| 329 | return true; | ||
| 330 | })(); | ||
| 331 | const expanded = | ||
| 332 | previousExpandedById[project.logicalKey] ?? | ||
| 333 | fallbackFromPreviousLogicalKey ?? | ||
| 334 | fallbackFromPersistedShape; | ||
| 335 | nextExpandedById[project.logicalKey] = expanded; | ||
| 336 | } | ||
| 337 | return { | ||
| 338 | id: project.key, | ||
| 339 | cwd: project.cwd, | ||
| 340 | incomingIndex: index, | ||
| 341 | }; | ||
| 342 | }); | ||
| 343 | |||
| 344 | const nextProjectOrder = | ||
| 345 | state.projectOrder.length > 0 | ||
| 346 | ? (() => { | ||
| 347 | const currentProjectIds = new Set(mappedProjects.map((project) => project.id)); | ||
| 348 | const nextProjectIdByCwd = new Map( | ||
| 349 | mappedProjects.map((project) => [project.cwd, project.id] as const), | ||
| 350 | ); | ||
| 351 | const usedProjectIds = new Set<string>(); | ||
| 352 | const orderedProjectIds: string[] = []; | ||
| 353 | |||
| 354 | for (const projectId of state.projectOrder) { | ||
| 355 | const matchedProjectId = | ||
| 356 | (currentProjectIds.has(projectId) ? projectId : undefined) ?? | ||
| 357 | (() => { | ||
| 358 | const previousCwd = previousProjectCwdById.get(projectId); | ||
| 359 | return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined; | ||
| 360 | })(); | ||
| 361 | if (!matchedProjectId || usedProjectIds.has(matchedProjectId)) { | ||
| 362 | continue; | ||
| 363 | } | ||
| 364 | usedProjectIds.add(matchedProjectId); | ||
| 365 | orderedProjectIds.push(matchedProjectId); | ||
| 366 | } | ||
| 367 | |||
| 368 | for (const project of mappedProjects) { | ||
| 369 | if (usedProjectIds.has(project.id)) { | ||
| 370 | continue; | ||
| 371 | } | ||
| 372 | orderedProjectIds.push(project.id); | ||
| 373 | } | ||
| 374 | |||
| 375 | return orderedProjectIds; | ||
| 376 | })() | ||
| 377 | : mappedProjects | ||
| 378 | .map((project) => ({ | ||
| 379 | id: project.id, | ||
| 380 | incomingIndex: project.incomingIndex, | ||
| 381 | orderIndex: | ||
| 382 | persistedOrderByCwd.get(project.cwd) ?? | ||
| 383 | persistedProjectOrderCwds.length + project.incomingIndex, | ||
| 384 | })) | ||
| 385 | .toSorted((left, right) => { | ||
| 386 | const byOrder = left.orderIndex - right.orderIndex; | ||
| 387 | if (byOrder !== 0) { | ||
| 388 | return byOrder; | ||
| 389 | } | ||
| 390 | return left.incomingIndex - right.incomingIndex; | ||
| 391 | }) | ||
| 392 | .map((project) => project.id); | ||
| 393 | |||
| 394 | if ( | ||
| 395 | recordsEqual(state.projectExpandedById, nextExpandedById) && | ||
| 396 | projectOrdersEqual(state.projectOrder, nextProjectOrder) && | ||
| 397 | !cwdMappingChanged | ||
| 398 | ) { | ||
| 399 | return state; | 232 | return state; |
| 400 | } | 233 | } |
| 401 | |||
| 402 | return { | ||
| 403 | ...state, | ||
| 404 | projectExpandedById: nextExpandedById, | ||
| 405 | projectOrder: nextProjectOrder, | ||
| 406 | }; | ||
| 407 | } | ||
| 408 | |||
| 409 | export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { | ||
| 410 | const retainedThreadIds = new Set(threads.map((thread) => thread.key)); | ||
| 411 | const nextThreadLastVisitedAtById = Object.fromEntries( | ||
| 412 | Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => | ||
| 413 | retainedThreadIds.has(threadId), | ||
| 414 | ), | ||
| 415 | ); | ||
| 416 | for (const thread of threads) { | ||
| 417 | if ( | ||
| 418 | nextThreadLastVisitedAtById[thread.key] === undefined && | ||
| 419 | thread.seedVisitedAt !== undefined && | ||
| 420 | thread.seedVisitedAt.length > 0 | ||
| 421 | ) { | ||
| 422 | nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; | ||
| 423 | } | ||
| 424 | } | ||
| 425 | const nextThreadChangedFilesExpandedById = Object.fromEntries( | ||
| 426 | Object.entries(state.threadChangedFilesExpandedById).filter(([threadId]) => | ||
| 427 | retainedThreadIds.has(threadId), | ||
| 428 | ), | ||
| 429 | ); | ||
| 430 | if ( | ||
| 431 | recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && | ||
| 432 | nestedBooleanRecordsEqual( | ||
| 433 | state.threadChangedFilesExpandedById, | ||
| 434 | nextThreadChangedFilesExpandedById, | ||
| 435 | ) | ||
| 436 | ) { | ||
| 437 | return state; | ||
| 438 | } | ||
| 439 | return { | ||
| 440 | ...state, | ||
| 441 | threadLastVisitedAtById: nextThreadLastVisitedAtById, | ||
| 442 | threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, | ||
| 443 | }; | ||
| 444 | } | ||
| 445 | |||
| 446 | export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState { | ||
| 447 | const at = visitedAt ?? new Date().toISOString(); | ||
| 448 | const visitedAtMs = Date.parse(at); | ||
| 449 | const previousVisitedAt = state.threadLastVisitedAtById[threadId]; | 234 | const previousVisitedAt = state.threadLastVisitedAtById[threadId]; |
| 450 | const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN; | 235 | const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN; |
| 451 | if ( | 236 | if ( |
| ... | @@ -459,7 +244,7 @@ export function markThreadVisited(state: UiState, threadId: string, visitedAt?: | ... | @@ -459,7 +244,7 @@ export function markThreadVisited(state: UiState, threadId: string, visitedAt?: |
| 459 | ...state, | 244 | ...state, |
| 460 | threadLastVisitedAtById: { | 245 | threadLastVisitedAtById: { |
| 461 | ...state.threadLastVisitedAtById, | 246 | ...state.threadLastVisitedAtById, |
| 462 | [threadId]: at, | 247 | [threadId]: visitedAt, |
| 463 | }, | 248 | }, |
| 464 | }; | 249 | }; |
| 465 | } | 250 | } |
| ... | @@ -489,23 +274,6 @@ export function markThreadUnread( | ... | @@ -489,23 +274,6 @@ export function markThreadUnread( |
| 489 | }; | 274 | }; |
| 490 | } | 275 | } |
| 491 | 276 | ||
| 492 | export function clearThreadUi(state: UiState, threadId: string): UiState { | ||
| 493 | const hasVisitedState = threadId in state.threadLastVisitedAtById; | ||
| 494 | const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById; | ||
| 495 | if (!hasVisitedState && !hasChangedFilesState) { | ||
| 496 | return state; | ||
| 497 | } | ||
| 498 | const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; | ||
| 499 | const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; | ||
| 500 | delete nextThreadLastVisitedAtById[threadId]; | ||
| 501 | delete nextThreadChangedFilesExpandedById[threadId]; | ||
| 502 | return { | ||
| 503 | ...state, | ||
| 504 | threadLastVisitedAtById: nextThreadLastVisitedAtById, | ||
| 505 | threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, | ||
| 506 | }; | ||
| 507 | } | ||
| 508 | |||
| 509 | export function setThreadChangedFilesExpanded( | 277 | export function setThreadChangedFilesExpanded( |
| 510 | state: UiState, | 278 | state: UiState, |
| 511 | threadId: string, | 279 | threadId: string, |
| ... | @@ -566,32 +334,42 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu | ... | @@ -566,32 +334,42 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu |
| 566 | }; | 334 | }; |
| 567 | } | 335 | } |
| 568 | 336 | ||
| 569 | export function toggleProject(state: UiState, projectId: string): UiState { | 337 | export function resolveProjectExpanded( |
| 570 | const expanded = state.projectExpandedById[projectId] ?? true; | 338 | projectExpandedById: Readonly<Record<string, boolean>>, |
| 571 | return { | 339 | preferenceKeys: readonly string[], |
| 572 | ...state, | 340 | ): boolean { |
| 573 | projectExpandedById: { | 341 | for (const key of preferenceKeys) { |
| 574 | ...state.projectExpandedById, | 342 | const expanded = projectExpandedById[key]; |
| 575 | [projectId]: !expanded, | 343 | if (expanded !== undefined) { |
| 576 | }, | 344 | return expanded; |
| 577 | }; | 345 | } |
| 346 | } | ||
| 347 | return projectExpandedById[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] ?? true; | ||
| 578 | } | 348 | } |
| 579 | 349 | ||
| 580 | export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState { | 350 | export function setProjectExpanded( |
| 581 | if ((state.projectExpandedById[projectId] ?? true) === expanded) { | 351 | state: UiState, |
| 352 | projectIds: string | readonly string[], | ||
| 353 | expanded: boolean, | ||
| 354 | ): UiState { | ||
| 355 | const ids = typeof projectIds === "string" ? [projectIds] : projectIds; | ||
| 356 | const nextEntries = ids.filter((projectId) => state.projectExpandedById[projectId] !== expanded); | ||
| 357 | if (nextEntries.length === 0) { | ||
| 582 | return state; | 358 | return state; |
| 583 | } | 359 | } |
| 360 | const projectExpandedById = { ...state.projectExpandedById }; | ||
| 361 | for (const projectId of nextEntries) { | ||
| 362 | projectExpandedById[projectId] = expanded; | ||
| 363 | } | ||
| 584 | return { | 364 | return { |
| 585 | ...state, | 365 | ...state, |
| 586 | projectExpandedById: { | 366 | projectExpandedById, |
| 587 | ...state.projectExpandedById, | ||
| 588 | [projectId]: expanded, | ||
| 589 | }, | ||
| 590 | }; | 367 | }; |
| 591 | } | 368 | } |
| 592 | 369 | ||
| 593 | export function reorderProjects( | 370 | export function reorderProjects( |
| 594 | state: UiState, | 371 | state: UiState, |
| 372 | currentProjectOrder: readonly string[], | ||
| 595 | draggedProjectIds: readonly string[], | 373 | draggedProjectIds: readonly string[], |
| 596 | targetProjectIds: readonly string[], | 374 | targetProjectIds: readonly string[], |
| 597 | ): UiState { | 375 | ): UiState { |
| ... | @@ -604,12 +382,12 @@ export function reorderProjects( | ... | @@ -604,12 +382,12 @@ export function reorderProjects( |
| 604 | return state; | 382 | return state; |
| 605 | } | 383 | } |
| 606 | 384 | ||
| 607 | const originalTargetIndex = state.projectOrder.findIndex((id) => targetSet.has(id)); | 385 | const originalTargetIndex = currentProjectOrder.findIndex((id) => targetSet.has(id)); |
| 608 | if (originalTargetIndex < 0) { | 386 | if (originalTargetIndex < 0) { |
| 609 | return state; | 387 | return state; |
| 610 | } | 388 | } |
| 611 | 389 | ||
| 612 | const projectOrder = [...state.projectOrder]; | 390 | const projectOrder = [...currentProjectOrder]; |
| 613 | 391 | ||
| 614 | const removed: string[] = []; | 392 | const removed: string[] = []; |
| 615 | let draggedBeforeTarget = 0; | 393 | let draggedBeforeTarget = 0; |
| ... | @@ -634,16 +412,13 @@ export function reorderProjects( | ... | @@ -634,16 +412,13 @@ export function reorderProjects( |
| 634 | } | 412 | } |
| 635 | 413 | ||
| 636 | interface UiStateStore extends UiState { | 414 | interface UiStateStore extends UiState { |
| 637 | syncProjects: (projects: readonly SyncProjectInput[]) => void; | 415 | markThreadVisited: (threadId: string, visitedAt: string) => void; |
| 638 | syncThreads: (threads: readonly SyncThreadInput[]) => void; | ||
| 639 | markThreadVisited: (threadId: string, visitedAt?: string) => void; | ||
| 640 | markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; | 416 | markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; |
| 641 | clearThreadUi: (threadId: string) => void; | ||
| 642 | setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; | 417 | setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; |
| 643 | setDefaultAdvertisedEndpointKey: (key: string | null) => void; | 418 | setDefaultAdvertisedEndpointKey: (key: string | null) => void; |
| 644 | toggleProject: (projectId: string) => void; | 419 | setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; |
| 645 | setProjectExpanded: (projectId: string, expanded: boolean) => void; | ||
| 646 | reorderProjects: ( | 420 | reorderProjects: ( |
| 421 | currentProjectOrder: readonly string[], | ||
| 647 | draggedProjectIds: readonly string[], | 422 | draggedProjectIds: readonly string[], |
| 648 | targetProjectIds: readonly string[], | 423 | targetProjectIds: readonly string[], |
| 649 | ) => void; | 424 | ) => void; |
| ... | @@ -651,22 +426,20 @@ interface UiStateStore extends UiState { | ... | @@ -651,22 +426,20 @@ interface UiStateStore extends UiState { |
| 651 | 426 | ||
| 652 | export const useUiStateStore = create<UiStateStore>((set) => ({ | 427 | export const useUiStateStore = create<UiStateStore>((set) => ({ |
| 653 | ...readPersistedState(), | 428 | ...readPersistedState(), |
| 654 | syncProjects: (projects) => set((state) => syncProjects(state, projects)), | ||
| 655 | syncThreads: (threads) => set((state) => syncThreads(state, threads)), | ||
| 656 | markThreadVisited: (threadId, visitedAt) => | 429 | markThreadVisited: (threadId, visitedAt) => |
| 657 | set((state) => markThreadVisited(state, threadId, visitedAt)), | 430 | set((state) => markThreadVisited(state, threadId, visitedAt)), |
| 658 | markThreadUnread: (threadId, latestTurnCompletedAt) => | 431 | markThreadUnread: (threadId, latestTurnCompletedAt) => |
| 659 | set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), | 432 | set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), |
| 660 | clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), | ||
| 661 | setThreadChangedFilesExpanded: (threadId, turnId, expanded) => | 433 | setThreadChangedFilesExpanded: (threadId, turnId, expanded) => |
| 662 | set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), | 434 | set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), |
| 663 | setDefaultAdvertisedEndpointKey: (key) => | 435 | setDefaultAdvertisedEndpointKey: (key) => |
| 664 | set((state) => setDefaultAdvertisedEndpointKey(state, key)), | 436 | set((state) => setDefaultAdvertisedEndpointKey(state, key)), |
| 665 | toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), | 437 | setProjectExpanded: (projectIds, expanded) => |
| 666 | setProjectExpanded: (projectId, expanded) => | 438 | set((state) => setProjectExpanded(state, projectIds, expanded)), |
| 667 | set((state) => setProjectExpanded(state, projectId, expanded)), | 439 | reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => |
| 668 | reorderProjects: (draggedProjectIds, targetProjectIds) => | 440 | set((state) => |
| 669 | set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), | 441 | reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds), |
| 442 | ), | ||
| 670 | })); | 443 | })); |
| 671 | 444 | ||
| 672 | useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); | 445 | useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); |
apps/web/src/worktreeCleanup.test.ts+3-3| ... | @@ -10,7 +10,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -10,7 +10,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 10 | return { | 10 | return { |
| 11 | id: ThreadId.make("thread-1"), | 11 | id: ThreadId.make("thread-1"), |
| 12 | environmentId: localEnvironmentId, | 12 | environmentId: localEnvironmentId, |
| 13 | codexThreadId: null, | ||
| 14 | projectId: ProjectId.make("project-1"), | 13 | projectId: ProjectId.make("project-1"), |
| 15 | title: "Thread", | 14 | title: "Thread", |
| 16 | modelSelection: { | 15 | modelSelection: { |
| ... | @@ -21,12 +20,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -21,12 +20,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 21 | interactionMode: DEFAULT_INTERACTION_MODE, | 20 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 22 | session: null, | 21 | session: null, |
| 23 | messages: [], | 22 | messages: [], |
| 24 | turnDiffSummaries: [], | 23 | checkpoints: [], |
| 25 | activities: [], | 24 | activities: [], |
| 26 | proposedPlans: [], | 25 | proposedPlans: [], |
| 27 | error: null, | ||
| 28 | createdAt: "2026-02-13T00:00:00.000Z", | 26 | createdAt: "2026-02-13T00:00:00.000Z", |
| 27 | updatedAt: "2026-02-13T00:00:00.000Z", | ||
| 29 | archivedAt: null, | 28 | archivedAt: null, |
| 29 | deletedAt: null, | ||
| 30 | latestTurn: null, | 30 | latestTurn: null, |
| 31 | branch: null, | 31 | branch: null, |
| 32 | worktreePath: null, | 32 | worktreePath: null, |
apps/web/src/worktreeCleanup.ts+3-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { Thread } from "./types"; | 1 | import type { ThreadShell } from "./types"; |
| 2 | 2 | ||
| 3 | function normalizeWorktreePath(path: string | null): string | null { | 3 | function normalizeWorktreePath(path: string | null): string | null { |
| 4 | const trimmed = path?.trim(); | 4 | const trimmed = path?.trim(); |
| ... | @@ -9,8 +9,8 @@ function normalizeWorktreePath(path: string | null): string | null { | ... | @@ -9,8 +9,8 @@ function normalizeWorktreePath(path: string | null): string | null { |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | export function getOrphanedWorktreePathForThread( | 11 | export function getOrphanedWorktreePathForThread( |
| 12 | threads: readonly Thread[], | 12 | threads: ReadonlyArray<Pick<ThreadShell, "id" | "worktreePath">>, |
| 13 | threadId: Thread["id"], | 13 | threadId: ThreadShell["id"], |
| 14 | ): string | null { | 14 | ): string | null { |
| 15 | const targetThread = threads.find((thread) => thread.id === threadId); | 15 | const targetThread = threads.find((thread) => thread.id === threadId); |
| 16 | if (!targetThread) { | 16 | if (!targetThread) { |
apps/web/test/authHttpHandlers.ts deleted-103| ... | @@ -1,103 +0,0 @@ | ||
| 1 | import { | ||
| 2 | AuthSessionId, | ||
| 3 | EnvironmentAuthenticatedAuth, | ||
| 4 | EnvironmentAuthenticatedPrincipal, | ||
| 5 | EnvironmentAuthHttpApi, | ||
| 6 | EnvironmentId, | ||
| 7 | EnvironmentMetadataHttpApi, | ||
| 8 | type AuthEnvironmentScope, | ||
| 9 | type ExecutionEnvironmentDescriptor, | ||
| 10 | type ServerAuthDescriptor, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import * as DateTime from "effect/DateTime"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import { HttpRouter, HttpServer } from "effect/unstable/http"; | ||
| 16 | import * as HttpApi from "effect/unstable/httpapi/HttpApi"; | ||
| 17 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ||
| 18 | import { http } from "msw"; | ||
| 19 | |||
| 20 | const BrowserEnvironmentHttpApi = HttpApi.make("browserEnvironment") | ||
| 21 | .add(EnvironmentMetadataHttpApi) | ||
| 22 | .add(EnvironmentAuthHttpApi); | ||
| 23 | |||
| 24 | const TEST_SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-05-01T12:00:00.000Z"); | ||
| 25 | const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = { | ||
| 26 | environmentId: EnvironmentId.make("environment-local"), | ||
| 27 | label: "Local environment", | ||
| 28 | platform: { | ||
| 29 | os: "darwin", | ||
| 30 | arch: "arm64", | ||
| 31 | }, | ||
| 32 | serverVersion: "0.0.0-test", | ||
| 33 | capabilities: { | ||
| 34 | repositoryIdentity: true, | ||
| 35 | }, | ||
| 36 | }; | ||
| 37 | |||
| 38 | const unexpectedEndpoint = (endpoint: string) => | ||
| 39 | Effect.die(new Error(`Unexpected browser environment HTTP endpoint: ${endpoint}`)); | ||
| 40 | |||
| 41 | export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { | ||
| 42 | const authenticatedAuthLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) => | ||
| 43 | httpEffect.pipe( | ||
| 44 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { | ||
| 45 | sessionId: AuthSessionId.make("browser-session"), | ||
| 46 | subject: "browser-client", | ||
| 47 | method: "browser-session-cookie", | ||
| 48 | scopes: new Set<AuthEnvironmentScope>(), | ||
| 49 | expiresAt: TEST_SESSION_EXPIRES_AT, | ||
| 50 | }), | ||
| 51 | ), | ||
| 52 | ); | ||
| 53 | const metadataLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "metadata", (handlers) => | ||
| 54 | handlers.handle("descriptor", () => Effect.succeed(TEST_ENVIRONMENT_DESCRIPTOR)), | ||
| 55 | ); | ||
| 56 | const authLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "auth", (handlers) => | ||
| 57 | handlers | ||
| 58 | .handle("session", () => | ||
| 59 | Effect.succeed({ | ||
| 60 | authenticated: true, | ||
| 61 | auth: getAuthDescriptor(), | ||
| 62 | sessionMethod: "browser-session-cookie", | ||
| 63 | expiresAt: TEST_SESSION_EXPIRES_AT, | ||
| 64 | }), | ||
| 65 | ) | ||
| 66 | .handle("browserSession", () => | ||
| 67 | Effect.succeed({ | ||
| 68 | authenticated: true, | ||
| 69 | scopes: [ | ||
| 70 | "orchestration:read", | ||
| 71 | "orchestration:operate", | ||
| 72 | "terminal:operate", | ||
| 73 | "review:write", | ||
| 74 | "relay:read", | ||
| 75 | ], | ||
| 76 | sessionMethod: "browser-session-cookie", | ||
| 77 | expiresAt: TEST_SESSION_EXPIRES_AT, | ||
| 78 | }), | ||
| 79 | ) | ||
| 80 | .handle("token", () => unexpectedEndpoint("auth.token")) | ||
| 81 | .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) | ||
| 82 | .handle("pairingCredential", () => unexpectedEndpoint("auth.pairingCredential")) | ||
| 83 | .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks")) | ||
| 84 | .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink")) | ||
| 85 | .handle("clients", () => unexpectedEndpoint("auth.clients")) | ||
| 86 | .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient")) | ||
| 87 | .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")), | ||
| 88 | ).pipe(Layer.provide(authenticatedAuthLayer)); | ||
| 89 | const { handler } = HttpRouter.toWebHandler( | ||
| 90 | HttpApiBuilder.layer(BrowserEnvironmentHttpApi).pipe( | ||
| 91 | Layer.provide(metadataLayer), | ||
| 92 | Layer.provide(authLayer), | ||
| 93 | Layer.provide(authenticatedAuthLayer), | ||
| 94 | Layer.provide(HttpServer.layerServices), | ||
| 95 | ), | ||
| 96 | { disableLogger: true }, | ||
| 97 | ); | ||
| 98 | |||
| 99 | return [ | ||
| 100 | http.all("*/.well-known/t3/environment", ({ request }) => handler(request)), | ||
| 101 | http.all("*/api/auth/*", ({ request }) => handler(request)), | ||
| 102 | ] as const; | ||
| 103 | } | ||
apps/web/test/wsRpcHarness.ts deleted-185| ... | @@ -1,185 +0,0 @@ | ||
| 1 | import { ORCHESTRATION_WS_METHODS, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as PubSub from "effect/PubSub"; | ||
| 5 | import * as Scope from "effect/Scope"; | ||
| 6 | import * as Stream from "effect/Stream"; | ||
| 7 | import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; | ||
| 8 | |||
| 9 | type RpcServerInstance = RpcServer.RpcServer<any>; | ||
| 10 | |||
| 11 | type BrowserWsClient = { | ||
| 12 | send: (data: string) => void; | ||
| 13 | }; | ||
| 14 | |||
| 15 | export type NormalizedWsRpcRequestBody = { | ||
| 16 | _tag: string; | ||
| 17 | [key: string]: unknown; | ||
| 18 | }; | ||
| 19 | |||
| 20 | type UnaryResolverResult = unknown | Promise<unknown>; | ||
| 21 | |||
| 22 | interface BrowserWsRpcHarnessOptions { | ||
| 23 | readonly resolveUnary?: (request: NormalizedWsRpcRequestBody) => UnaryResolverResult; | ||
| 24 | readonly getInitialStreamValues?: ( | ||
| 25 | request: NormalizedWsRpcRequestBody, | ||
| 26 | ) => ReadonlyArray<unknown> | undefined; | ||
| 27 | } | ||
| 28 | |||
| 29 | const STREAM_METHODS = new Set<string>([ | ||
| 30 | ORCHESTRATION_WS_METHODS.subscribeShell, | ||
| 31 | ORCHESTRATION_WS_METHODS.subscribeThread, | ||
| 32 | WS_METHODS.gitRunStackedAction, | ||
| 33 | WS_METHODS.terminalAttach, | ||
| 34 | WS_METHODS.subscribeVcsStatus, | ||
| 35 | WS_METHODS.subscribeTerminalEvents, | ||
| 36 | WS_METHODS.subscribeTerminalMetadata, | ||
| 37 | WS_METHODS.subscribeServerConfig, | ||
| 38 | WS_METHODS.subscribeServerLifecycle, | ||
| 39 | WS_METHODS.subscribeAuthAccess, | ||
| 40 | ]); | ||
| 41 | |||
| 42 | const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys()); | ||
| 43 | |||
| 44 | function normalizeRequest(tag: string, payload: unknown): NormalizedWsRpcRequestBody { | ||
| 45 | if (payload && typeof payload === "object" && !Array.isArray(payload)) { | ||
| 46 | return { | ||
| 47 | _tag: tag, | ||
| 48 | ...(payload as Record<string, unknown>), | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | return { _tag: tag, payload }; | ||
| 52 | } | ||
| 53 | |||
| 54 | function asEffect(result: UnaryResolverResult): Effect.Effect<unknown> { | ||
| 55 | if (result instanceof Promise) { | ||
| 56 | return Effect.promise(() => result); | ||
| 57 | } | ||
| 58 | return Effect.succeed(result); | ||
| 59 | } | ||
| 60 | |||
| 61 | export class BrowserWsRpcHarness { | ||
| 62 | readonly requests: Array<NormalizedWsRpcRequestBody> = []; | ||
| 63 | |||
| 64 | private readonly parser = RpcSerialization.json.makeUnsafe(); | ||
| 65 | private client: BrowserWsClient | null = null; | ||
| 66 | private scope: Scope.Closeable | null = null; | ||
| 67 | private serverReady: Promise<RpcServerInstance> | null = null; | ||
| 68 | private resolveUnary: NonNullable<BrowserWsRpcHarnessOptions["resolveUnary"]> = () => ({}); | ||
| 69 | private getInitialStreamValues: NonNullable< | ||
| 70 | BrowserWsRpcHarnessOptions["getInitialStreamValues"] | ||
| 71 | > = () => []; | ||
| 72 | private streamPubSubs = new Map<string, PubSub.PubSub<unknown>>(); | ||
| 73 | |||
| 74 | async reset(options?: BrowserWsRpcHarnessOptions): Promise<void> { | ||
| 75 | await this.disconnect(); | ||
| 76 | this.requests.length = 0; | ||
| 77 | this.resolveUnary = options?.resolveUnary ?? (() => ({})); | ||
| 78 | this.getInitialStreamValues = options?.getInitialStreamValues ?? (() => []); | ||
| 79 | this.initializeStreamPubSubs(); | ||
| 80 | } | ||
| 81 | |||
| 82 | connect(client: BrowserWsClient): void { | ||
| 83 | if (this.scope) { | ||
| 84 | void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); | ||
| 85 | } | ||
| 86 | if (this.streamPubSubs.size === 0) { | ||
| 87 | this.initializeStreamPubSubs(); | ||
| 88 | } | ||
| 89 | this.client = client; | ||
| 90 | this.scope = Effect.runSync(Scope.make()); | ||
| 91 | this.serverReady = Effect.runPromise( | ||
| 92 | Scope.provide(this.scope)( | ||
| 93 | RpcServer.makeNoSerialization(WsRpcGroup, this.makeServerOptions()), | ||
| 94 | ).pipe(Effect.provide(this.makeLayer())), | ||
| 95 | ) as Promise<RpcServerInstance>; | ||
| 96 | } | ||
| 97 | |||
| 98 | async disconnect(): Promise<void> { | ||
| 99 | if (this.scope) { | ||
| 100 | await Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); | ||
| 101 | this.scope = null; | ||
| 102 | } | ||
| 103 | for (const pubsub of this.streamPubSubs.values()) { | ||
| 104 | Effect.runSync(PubSub.shutdown(pubsub)); | ||
| 105 | } | ||
| 106 | this.streamPubSubs.clear(); | ||
| 107 | this.serverReady = null; | ||
| 108 | this.client = null; | ||
| 109 | } | ||
| 110 | |||
| 111 | private initializeStreamPubSubs(): void { | ||
| 112 | this.streamPubSubs = new Map( | ||
| 113 | Array.from(STREAM_METHODS, (method) => [method, Effect.runSync(PubSub.unbounded<unknown>())]), | ||
| 114 | ); | ||
| 115 | } | ||
| 116 | |||
| 117 | async onMessage(rawData: string): Promise<void> { | ||
| 118 | const server = await this.serverReady; | ||
| 119 | if (!server) { | ||
| 120 | return; | ||
| 121 | } | ||
| 122 | const messages = this.parser.decode(rawData); | ||
| 123 | for (const message of messages) { | ||
| 124 | if (message && typeof message === "object" && "_tag" in message && message._tag === "Ping") { | ||
| 125 | const encoded = this.parser.encode(RpcMessage.constPong); | ||
| 126 | if (typeof encoded === "string") { | ||
| 127 | this.client?.send(encoded); | ||
| 128 | } | ||
| 129 | continue; | ||
| 130 | } | ||
| 131 | await Effect.runPromise(server.write(0, message as never)); | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | emitStreamValue(method: string, value: unknown): void { | ||
| 136 | const pubsub = this.streamPubSubs.get(method); | ||
| 137 | if (!pubsub) { | ||
| 138 | throw new Error(`No stream registered for ${method}`); | ||
| 139 | } | ||
| 140 | Effect.runSync(PubSub.publish(pubsub, value)); | ||
| 141 | } | ||
| 142 | |||
| 143 | private makeLayer() { | ||
| 144 | const handlers: Record<string, (payload: unknown) => unknown> = {}; | ||
| 145 | for (const method of ALL_RPC_METHODS) { | ||
| 146 | handlers[method] = STREAM_METHODS.has(method) | ||
| 147 | ? (payload) => this.handleStream(method, payload) | ||
| 148 | : (payload) => this.handleUnary(method, payload); | ||
| 149 | } | ||
| 150 | return WsRpcGroup.toLayer(handlers as never); | ||
| 151 | } | ||
| 152 | |||
| 153 | private makeServerOptions() { | ||
| 154 | return { | ||
| 155 | onFromServer: (response: unknown) => | ||
| 156 | Effect.sync(() => { | ||
| 157 | if (!this.client) { | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | const encoded = this.parser.encode(response); | ||
| 161 | if (typeof encoded === "string") { | ||
| 162 | this.client.send(encoded); | ||
| 163 | } | ||
| 164 | }), | ||
| 165 | }; | ||
| 166 | } | ||
| 167 | |||
| 168 | private handleUnary(method: string, payload: unknown) { | ||
| 169 | const request = normalizeRequest(method, payload); | ||
| 170 | this.requests.push(request); | ||
| 171 | return asEffect(this.resolveUnary(request)); | ||
| 172 | } | ||
| 173 | |||
| 174 | private handleStream(method: string, payload: unknown) { | ||
| 175 | const request = normalizeRequest(method, payload); | ||
| 176 | this.requests.push(request); | ||
| 177 | const pubsub = this.streamPubSubs.get(method); | ||
| 178 | if (!pubsub) { | ||
| 179 | throw new Error(`No stream registered for ${method}`); | ||
| 180 | } | ||
| 181 | return Stream.fromIterable(this.getInitialStreamValues(request) ?? []).pipe( | ||
| 182 | Stream.concat(Stream.fromPubSub(pubsub)), | ||
| 183 | ); | ||
| 184 | } | ||
| 185 | } | ||
apps/web/vite.config.ts+3-26| ... | @@ -2,7 +2,6 @@ import tailwindcss from "@tailwindcss/vite"; | ... | @@ -2,7 +2,6 @@ import tailwindcss from "@tailwindcss/vite"; |
| 2 | import react, { reactCompilerPreset } from "@vitejs/plugin-react"; | 2 | import react, { reactCompilerPreset } from "@vitejs/plugin-react"; |
| 3 | import babel from "@rolldown/plugin-babel"; | 3 | import babel from "@rolldown/plugin-babel"; |
| 4 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; | 4 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; |
| 5 | import { playwright } from "vite-plus/test/browser-playwright"; | ||
| 6 | import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config"; | 5 | import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config"; |
| 7 | import "vite-plus/test/config"; | 6 | import "vite-plus/test/config"; |
| 8 | import { defineConfig } from "vite-plus"; | 7 | import { defineConfig } from "vite-plus"; |
| ... | @@ -59,30 +58,6 @@ const unitTestProject = { | ... | @@ -59,30 +58,6 @@ const unitTestProject = { |
| 59 | }, | 58 | }, |
| 60 | } satisfies TestProjectInlineConfiguration; | 59 | } satisfies TestProjectInlineConfiguration; |
| 61 | 60 | ||
| 62 | const browserTestProject = { | ||
| 63 | extends: true, | ||
| 64 | server: { | ||
| 65 | // Browser tests need concurrent runs to claim the next available port. | ||
| 66 | strictPort: false, | ||
| 67 | }, | ||
| 68 | test: { | ||
| 69 | name: "browser", | ||
| 70 | include: ["src/components/**/*.browser.tsx"], | ||
| 71 | hookTimeout: 30_000, | ||
| 72 | testTimeout: 30_000, | ||
| 73 | browser: { | ||
| 74 | enabled: true, | ||
| 75 | provider: playwright() as never, | ||
| 76 | instances: [{ browser: "chromium" }], | ||
| 77 | headless: true, | ||
| 78 | api: { | ||
| 79 | strictPort: false, | ||
| 80 | }, | ||
| 81 | }, | ||
| 82 | fileParallelism: false, | ||
| 83 | }, | ||
| 84 | } satisfies TestProjectInlineConfiguration; | ||
| 85 | |||
| 86 | function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { | 61 | function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { |
| 87 | if (!wsUrl) { | 62 | if (!wsUrl) { |
| 88 | return undefined; | 63 | return undefined; |
| ... | @@ -123,6 +98,8 @@ export default defineConfig(() => { | ... | @@ -123,6 +98,8 @@ export default defineConfig(() => { |
| 123 | ], | 98 | ], |
| 124 | optimizeDeps: { | 99 | optimizeDeps: { |
| 125 | include: [ | 100 | include: [ |
| 101 | "@clerk/clerk-js", | ||
| 102 | "@clerk/react/internal", | ||
| 126 | "@pierre/diffs", | 103 | "@pierre/diffs", |
| 127 | "@pierre/diffs/editor", | 104 | "@pierre/diffs/editor", |
| 128 | "@pierre/diffs/react", | 105 | "@pierre/diffs/react", |
| ... | @@ -186,7 +163,7 @@ export default defineConfig(() => { | ... | @@ -186,7 +163,7 @@ export default defineConfig(() => { |
| 186 | sourcemap: buildSourcemap, | 163 | sourcemap: buildSourcemap, |
| 187 | }, | 164 | }, |
| 188 | test: { | 165 | test: { |
| 189 | projects: [defineProject(unitTestProject), defineProject(browserTestProject)], | 166 | projects: [defineProject(unitTestProject)], |
| 190 | }, | 167 | }, |
| 191 | }; | 168 | }; |
| 192 | }); | 169 | }); |
docs/architecture/connection-runtime.md created+137| ... | @@ -0,0 +1,137 @@ | ||
| 1 | # Connection Runtime | ||
| 2 | |||
| 3 | The connection runtime is shared by web and mobile. It owns connectivity, | ||
| 4 | authentication, retries, transport lifetime, cached environment data, and | ||
| 5 | environment-scoped operations. | ||
| 6 | |||
| 7 | Web and mobile mount this runtime once at the application root. There is no | ||
| 8 | legacy connection owner or supported mixed mode. | ||
| 9 | |||
| 10 | ## Ownership | ||
| 11 | |||
| 12 | Each registered environment has one scoped Effect `Context` containing focused | ||
| 13 | services: | ||
| 14 | |||
| 15 | - `EnvironmentSupervisor` owns desired state, retry scheduling, and the active | ||
| 16 | session scope. | ||
| 17 | - `ConnectionBroker` prepares credentials and endpoints for primary, bearer, | ||
| 18 | relay, and SSH targets. | ||
| 19 | - `RpcSessionFactory` performs one transport attempt. It does not retry. | ||
| 20 | - `EnvironmentRpc` exposes the active session without leaking the transport. | ||
| 21 | - `EnvironmentProjectCommands` and `EnvironmentThreadCommands` construct | ||
| 22 | orchestration commands, IDs, and timestamps. | ||
| 23 | - `EnvironmentShell` and `EnvironmentThreads` own live subscriptions and cached | ||
| 24 | snapshots. | ||
| 25 | |||
| 26 | `EnvironmentServicesFactory` assembles that context, and `EnvironmentRegistry` | ||
| 27 | owns its scope. There is no aggregate environment runtime facade. React | ||
| 28 | components do not create connections, transports, retry loops, or RPC clients. | ||
| 29 | |||
| 30 | ## Connection State | ||
| 31 | |||
| 32 | The supervisor is the only retry owner. | ||
| 33 | |||
| 34 | 1. A persisted or platform registration marks an environment as desired. | ||
| 35 | 2. If the device is offline, the supervisor releases the active session and | ||
| 36 | waits without consuming retry attempts. | ||
| 37 | 3. When online, the supervisor asks the broker for one prepared connection and | ||
| 38 | asks the session factory for one RPC session. | ||
| 39 | 4. Transient failures retry forever with exponential backoff capped at 16 | ||
| 40 | seconds. | ||
| 41 | 5. Connectivity changes, application activation, credential changes, and | ||
| 42 | explicit user retry interrupt the current wait and trigger a fresh attempt. | ||
| 43 | 6. Authentication or configuration failures remain blocked until an external | ||
| 44 | wakeup changes the relevant input. | ||
| 45 | 7. An involuntary session close keeps the registration and cache, then retries. | ||
| 46 | 8. Explicit removal closes the session and deletes the registration, | ||
| 47 | credentials, shell cache, and thread cache. | ||
| 48 | |||
| 49 | The UI derives `available`, `offline`, `connecting`, `reconnecting`, | ||
| 50 | `connected`, and `error` from supervisor state plus explicit data-sync state. | ||
| 51 | It does not infer connection health from cached data or the existence of a | ||
| 52 | transport object. An environment becomes `connected` after the socket opens and | ||
| 53 | the initial config RPC succeeds, proving that the server is responsive. Shell | ||
| 54 | and thread synchronization are independent data states. A healthy RPC | ||
| 55 | transport with a failed shell subscription is shown as connected with a | ||
| 56 | synchronization error, not as a reconnect that is not actually scheduled. | ||
| 57 | |||
| 58 | ## Data Boundary | ||
| 59 | |||
| 60 | Finite requests, durable subscriptions, and commands are separate APIs: | ||
| 61 | |||
| 62 | - Query atoms revalidate when the RPC generation changes. | ||
| 63 | - Subscription atoms switch to replacement sessions. | ||
| 64 | - Expected subscription failures update domain sync state and wait for a | ||
| 65 | replacement session; they do not take down a healthy transport. | ||
| 66 | - Mutations resolve the current environment runtime at execution time. | ||
| 67 | - Shell and thread snapshots are available while offline. | ||
| 68 | - A connected transport may have `empty`, `cached`, `synchronizing`, `live`, or | ||
| 69 | failed shell and thread data independently. | ||
| 70 | - Cached shell and thread projections are never allowed to overwrite newer live | ||
| 71 | data during a fast reconnect. | ||
| 72 | - Domain atom factories route effects through the environment registry and | ||
| 73 | resolve the current scoped service at execution time. | ||
| 74 | - Web and mobile own their Atom runtimes, React hooks, and feature composition. | ||
| 75 | |||
| 76 | The Promise bridge exists only at the React/Atom boundary. Runtime and business | ||
| 77 | logic remain Effect-native. | ||
| 78 | |||
| 79 | ## Platform Layers | ||
| 80 | |||
| 81 | Web and mobile provide: | ||
| 82 | |||
| 83 | - network status and network-change streams; | ||
| 84 | - application lifecycle wakeups; | ||
| 85 | - cloud session credentials; | ||
| 86 | - device identity; | ||
| 87 | - platform registrations; | ||
| 88 | - persistent catalog, credential, shell, and thread stores; | ||
| 89 | - HTTP, crypto, and telemetry layers. | ||
| 90 | |||
| 91 | Platform layers adapt operating-system capabilities. They do not implement | ||
| 92 | connection policy. | ||
| 93 | |||
| 94 | ## Source Boundaries | ||
| 95 | |||
| 96 | The public package subpaths mirror the runtime layers: | ||
| 97 | |||
| 98 | - `connection/core` contains state, catalog, retry policy, and connectivity. | ||
| 99 | - `connection/transport` contains brokerage, authorization, attempts, and RPC | ||
| 100 | sessions. | ||
| 101 | - `connection/platform` declares capabilities and persistence contracts. | ||
| 102 | - `connection/services` contains environment-scoped data services. | ||
| 103 | - `connection/application` assembles registries, discovery, and startup. | ||
| 104 | - `connection/atoms` adapts shared services to application-owned Atom runtimes. | ||
| 105 | - `connection/presentation` contains pure UI projections. | ||
| 106 | |||
| 107 | Other reusable state lives in domain subpaths such as `shell`, `threads`, | ||
| 108 | `terminal`, and `vcs`. Applications must import explicit package subpaths; the | ||
| 109 | package intentionally has no root export. | ||
| 110 | |||
| 111 | ## Application Boundary | ||
| 112 | |||
| 113 | The application root mounts the shared connection application layer, creates | ||
| 114 | its own Atom runtime, and selects the domain atom factories required by that | ||
| 115 | platform. Web and mobile may expose different hooks and features without | ||
| 116 | changing connection ownership. | ||
| 117 | |||
| 118 | Application code must not construct `WsTransport`, RPC clients, retry loops, or | ||
| 119 | raw orchestration commands. Persistence paths belong to the platform | ||
| 120 | registration and cache stores, with explicit migration or invalidation policy. | ||
| 121 | |||
| 122 | ## Verification | ||
| 123 | |||
| 124 | Core state-machine tests use `@effect/vitest` and deterministic service layers. | ||
| 125 | Required coverage includes: | ||
| 126 | |||
| 127 | - offline startup and online wakeup; | ||
| 128 | - forever retry with the 16-second cap; | ||
| 129 | - explicit retry interrupting backoff; | ||
| 130 | - authentication wakeups; | ||
| 131 | - involuntary close and reconnect; | ||
| 132 | - explicit removal clearing all owned state; | ||
| 133 | - relay token reuse and refresh; | ||
| 134 | - progressive relay discovery; | ||
| 135 | - shell and thread cache hydration; | ||
| 136 | - durable subscriptions switching sessions; | ||
| 137 | - command metadata and idempotent queued-command metadata. | ||
infra/relay/README.md+1-1| ... | @@ -45,7 +45,7 @@ credential, or authorization behavior. | ... | @@ -45,7 +45,7 @@ credential, or authorization behavior. |
| 45 | Shared request and response schemas live in | 45 | Shared request and response schemas live in |
| 46 | [`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay | 46 | [`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay |
| 47 | calls live in | 47 | calls live in |
| 48 | [`packages/client-runtime/src/managedRelay.ts`](../../packages/client-runtime/src/managedRelay.ts). | 48 | [`packages/client-runtime/src/relay/managedRelay.ts`](../../packages/client-runtime/src/relay/managedRelay.ts). |
| 49 | 49 | ||
| 50 | ## Working Locally | 50 | ## Working Locally |
| 51 | 51 |
infra/relay/src/auth/RelayTokens.test.ts+5-3| ... | @@ -88,20 +88,22 @@ describe("RelayTokens", () => { | ... | @@ -88,20 +88,22 @@ describe("RelayTokens", () => { |
| 88 | proofKeyThumbprint: "proof-key-thumbprint", | 88 | proofKeyThumbprint: "proof-key-thumbprint", |
| 89 | jti: "access-token-1", | 89 | jti: "access-token-1", |
| 90 | issuedAtEpochSeconds: 100, | 90 | issuedAtEpochSeconds: 100, |
| 91 | expiresAtEpochSeconds: 200, | 91 | expiresAtEpochSeconds: 1_900, |
| 92 | clientId: "t3-mobile", | 92 | clientId: "t3-mobile", |
| 93 | scopes: ["environment:connect", "environment:status", "mobile:registration"], | 93 | scopes: ["environment:connect", "environment:status", "mobile:registration"], |
| 94 | }); | 94 | }); |
| 95 | 95 | ||
| 96 | expect( | 96 | expect( |
| 97 | yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 }), | 97 | yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 700 }), |
| 98 | ).toMatchObject({ | 98 | ).toMatchObject({ |
| 99 | sub: "user_123", | 99 | sub: "user_123", |
| 100 | cnf: { jkt: "proof-key-thumbprint" }, | 100 | cnf: { jkt: "proof-key-thumbprint" }, |
| 101 | client_id: "t3-mobile", | 101 | client_id: "t3-mobile", |
| 102 | scope: ["environment:connect", "environment:status", "mobile:registration"], | 102 | scope: ["environment:connect", "environment:status", "mobile:registration"], |
| 103 | }); | 103 | }); |
| 104 | expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 261 })).toBeNull(); | 104 | expect( |
| 105 | yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 1_961 }), | ||
| 106 | ).toBeNull(); | ||
| 105 | }).pipe(Effect.provide(layer)), | 107 | }).pipe(Effect.provide(layer)), |
| 106 | ); | 108 | ); |
| 107 | 109 |
infra/relay/src/auth/RelayTokens.ts+19| ... | @@ -26,6 +26,7 @@ import * as RelayConfiguration from "../Config.ts"; | ... | @@ -26,6 +26,7 @@ import * as RelayConfiguration from "../Config.ts"; |
| 26 | const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt"; | 26 | const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt"; |
| 27 | const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt"; | 27 | const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt"; |
| 28 | const LINK_CHALLENGE_KIND = "environment_link_challenge"; | 28 | const LINK_CHALLENGE_KIND = "environment_link_challenge"; |
| 29 | export const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; | ||
| 29 | 30 | ||
| 30 | const LinkChallengeClaims = Schema.Struct({ | 31 | const LinkChallengeClaims = Schema.Struct({ |
| 31 | kind: Schema.Literal(LINK_CHALLENGE_KIND), | 32 | kind: Schema.Literal(LINK_CHALLENGE_KIND), |
| ... | @@ -71,6 +72,17 @@ const allowedScopesByClientId: Record< | ... | @@ -71,6 +72,17 @@ const allowedScopesByClientId: Record< |
| 71 | [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), | 72 | [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), |
| 72 | }; | 73 | }; |
| 73 | 74 | ||
| 75 | function relayJwtVerificationFailureReason(error: RelayJwtError): string { | ||
| 76 | const cause = error.cause; | ||
| 77 | if (typeof cause === "object" && cause !== null && "code" in cause) { | ||
| 78 | const code = (cause as { readonly code?: unknown }).code; | ||
| 79 | if (typeof code === "string" && code.length > 0) { | ||
| 80 | return code; | ||
| 81 | } | ||
| 82 | } | ||
| 83 | return cause instanceof Error && cause.name ? cause.name : "unknown"; | ||
| 84 | } | ||
| 85 | |||
| 74 | function resolveDpopAccessTokenScopes(input: { | 86 | function resolveDpopAccessTokenScopes(input: { |
| 75 | readonly clientId: RelayPublicClientId; | 87 | readonly clientId: RelayPublicClientId; |
| 76 | readonly scope: string; | 88 | readonly scope: string; |
| ... | @@ -195,7 +207,14 @@ const make = Effect.gen(function* () { | ... | @@ -195,7 +207,14 @@ const make = Effect.gen(function* () { |
| 195 | issuer, | 207 | issuer, |
| 196 | audience: issuer, | 208 | audience: issuer, |
| 197 | nowEpochSeconds: input.nowEpochSeconds, | 209 | nowEpochSeconds: input.nowEpochSeconds, |
| 210 | maxTokenAge: RELAY_DPOP_ACCESS_TOKEN_TTL, | ||
| 198 | }).pipe( | 211 | }).pipe( |
| 212 | Effect.tapError((error) => | ||
| 213 | Effect.annotateCurrentSpan( | ||
| 214 | "relay.tokens.verification_failure", | ||
| 215 | relayJwtVerificationFailureReason(error), | ||
| 216 | ), | ||
| 217 | ), | ||
| 199 | Effect.flatMap(decodeDpopAccessTokenClaims), | 218 | Effect.flatMap(decodeDpopAccessTokenClaims), |
| 200 | Effect.map((claims): RelayDpopAccessTokenClaims | null => { | 219 | Effect.map((claims): RelayDpopAccessTokenClaims | null => { |
| 201 | const scopes = resolveDpopAccessTokenScopes({ | 220 | const scopes = resolveDpopAccessTokenScopes({ |
infra/relay/src/environments/EnvironmentConnector.test.ts+1| ... | @@ -535,6 +535,7 @@ describe("EnvironmentConnector", () => { | ... | @@ -535,6 +535,7 @@ describe("EnvironmentConnector", () => { |
| 535 | environmentId: "env-connector-test", | 535 | environmentId: "env-connector-test", |
| 536 | status: "offline", | 536 | status: "offline", |
| 537 | error: "Managed endpoint health request failed: Environment is unavailable.", | 537 | error: "Managed endpoint health request failed: Environment is unavailable.", |
| 538 | traceId: expect.any(String), | ||
| 538 | }); | 539 | }); |
| 539 | }).pipe(Effect.provide(connectorTestLayer(execute))); | 540 | }).pipe(Effect.provide(connectorTestLayer(execute))); |
| 540 | }); | 541 | }); |
infra/relay/src/environments/EnvironmentConnector.ts+44-2| ... | @@ -5,7 +5,7 @@ import { | ... | @@ -5,7 +5,7 @@ import { |
| 5 | EnvironmentHttpInternalServerError, | 5 | EnvironmentHttpInternalServerError, |
| 6 | EnvironmentHttpUnauthorizedError, | 6 | EnvironmentHttpUnauthorizedError, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; | 8 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; |
| 9 | import { | 9 | import { |
| 10 | RelayCloudEnvironmentHealthProofPayload, | 10 | RelayCloudEnvironmentHealthProofPayload, |
| 11 | RelayEnvironmentHealthResponse, | 11 | RelayEnvironmentHealthResponse, |
| ... | @@ -35,7 +35,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -35,7 +35,7 @@ import * as Layer from "effect/Layer"; |
| 35 | import * as Option from "effect/Option"; | 35 | import * as Option from "effect/Option"; |
| 36 | import * as Redacted from "effect/Redacted"; | 36 | import * as Redacted from "effect/Redacted"; |
| 37 | import * as Schema from "effect/Schema"; | 37 | import * as Schema from "effect/Schema"; |
| 38 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | 38 | import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; |
| 39 | 39 | ||
| 40 | import * as EnvironmentLinks from "./EnvironmentLinks.ts"; | 40 | import * as EnvironmentLinks from "./EnvironmentLinks.ts"; |
| 41 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; | 41 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; |
| ... | @@ -179,6 +179,24 @@ function environmentHealthRequestFailureMessage(cause: unknown): string { | ... | @@ -179,6 +179,24 @@ function environmentHealthRequestFailureMessage(cause: unknown): string { |
| 179 | : "Managed endpoint health request failed."; | 179 | : "Managed endpoint health request failed."; |
| 180 | } | 180 | } |
| 181 | 181 | ||
| 182 | function environmentHealthRequestFailureReason(cause: unknown): string { | ||
| 183 | if (isEnvironmentHealthError(cause)) { | ||
| 184 | return cause._tag; | ||
| 185 | } | ||
| 186 | if (HttpClientError.isHttpClientError(cause)) { | ||
| 187 | return cause.reason._tag; | ||
| 188 | } | ||
| 189 | if (Schema.isSchemaError(cause)) { | ||
| 190 | return "SchemaError"; | ||
| 191 | } | ||
| 192 | return cause instanceof Error && cause.name ? cause.name : "Unknown"; | ||
| 193 | } | ||
| 194 | |||
| 195 | const currentTraceId = Effect.currentSpan.pipe( | ||
| 196 | Effect.map((span) => span.traceId), | ||
| 197 | Effect.orElseSucceed(() => "unavailable"), | ||
| 198 | ); | ||
| 199 | |||
| 182 | const withoutRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>) => | 200 | const withoutRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 183 | effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); | 201 | effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); |
| 184 | 202 | ||
| ... | @@ -457,6 +475,7 @@ const make = Effect.gen(function* () { | ... | @@ -457,6 +475,7 @@ const make = Effect.gen(function* () { |
| 457 | ), | 475 | ), |
| 458 | ); | 476 | ); |
| 459 | const checkedAt = DateTime.formatIso(now); | 477 | const checkedAt = DateTime.formatIso(now); |
| 478 | const traceId = yield* currentTraceId; | ||
| 460 | const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); | 479 | const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); |
| 461 | const responseOption = yield* environmentClient.connect.health({ payload: { proof } }).pipe( | 480 | const responseOption = yield* environmentClient.connect.health({ payload: { proof } }).pipe( |
| 462 | withoutRedirects, | 481 | withoutRedirects, |
| ... | @@ -467,21 +486,44 @@ const make = Effect.gen(function* () { | ... | @@ -467,21 +486,44 @@ const make = Effect.gen(function* () { |
| 467 | Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), | 486 | Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), |
| 468 | ); | 487 | ); |
| 469 | if (Option.isNone(responseOption)) { | 488 | if (Option.isNone(responseOption)) { |
| 489 | yield* Effect.annotateCurrentSpan({ | ||
| 490 | "relay.environment_health.outcome": "timeout", | ||
| 491 | "relay.environment_health.trace_id": traceId, | ||
| 492 | }); | ||
| 493 | yield* Effect.logWarning("Managed endpoint health request timed out", { | ||
| 494 | environmentId: link.environmentId, | ||
| 495 | endpoint: endpoint.httpBaseUrl, | ||
| 496 | traceId, | ||
| 497 | }); | ||
| 470 | return { | 498 | return { |
| 471 | environmentId: link.environmentId, | 499 | environmentId: link.environmentId, |
| 472 | endpoint, | 500 | endpoint, |
| 473 | status: "offline" as const, | 501 | status: "offline" as const, |
| 474 | checkedAt, | 502 | checkedAt, |
| 475 | error: "Managed endpoint health request timed out.", | 503 | error: "Managed endpoint health request timed out.", |
| 504 | traceId, | ||
| 476 | }; | 505 | }; |
| 477 | } | 506 | } |
| 478 | if (responseOption.value._tag === "Failure") { | 507 | if (responseOption.value._tag === "Failure") { |
| 508 | const failureReason = environmentHealthRequestFailureReason(responseOption.value.cause); | ||
| 509 | yield* Effect.annotateCurrentSpan({ | ||
| 510 | "relay.environment_health.outcome": "failure", | ||
| 511 | "relay.environment_health.failure_reason": failureReason, | ||
| 512 | "relay.environment_health.trace_id": traceId, | ||
| 513 | }); | ||
| 514 | yield* Effect.logWarning("Managed endpoint health request failed", { | ||
| 515 | environmentId: link.environmentId, | ||
| 516 | endpoint: endpoint.httpBaseUrl, | ||
| 517 | failureReason, | ||
| 518 | traceId, | ||
| 519 | }); | ||
| 479 | return { | 520 | return { |
| 480 | environmentId: link.environmentId, | 521 | environmentId: link.environmentId, |
| 481 | endpoint, | 522 | endpoint, |
| 482 | status: "offline" as const, | 523 | status: "offline" as const, |
| 483 | checkedAt, | 524 | checkedAt, |
| 484 | error: environmentHealthRequestFailureMessage(responseOption.value.cause), | 525 | error: environmentHealthRequestFailureMessage(responseOption.value.cause), |
| 526 | traceId, | ||
| 485 | }; | 527 | }; |
| 486 | } | 528 | } |
| 487 | const decoded = responseOption.value.response; | 529 | const decoded = responseOption.value.response; |
infra/relay/src/http/Api.ts+2-3| ... | @@ -69,7 +69,6 @@ import { withSpanAttributes } from "../observability.ts"; | ... | @@ -69,7 +69,6 @@ import { withSpanAttributes } from "../observability.ts"; |
| 69 | import { RelayDb } from "../db.ts"; | 69 | import { RelayDb } from "../db.ts"; |
| 70 | 70 | ||
| 71 | const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; | 71 | const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; |
| 72 | const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; | ||
| 73 | const relayCorsAllowedHeaders = [ | 72 | const relayCorsAllowedHeaders = [ |
| 74 | "authorization", | 73 | "authorization", |
| 75 | "b3", | 74 | "b3", |
| ... | @@ -599,7 +598,7 @@ export const tokenApi = HttpApiBuilder.group( | ... | @@ -599,7 +598,7 @@ export const tokenApi = HttpApiBuilder.group( |
| 599 | Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), | 598 | Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), |
| 600 | ); | 599 | ); |
| 601 | const now = yield* DateTime.now; | 600 | const now = yield* DateTime.now; |
| 602 | const expiresAt = DateTime.addDuration(now, RELAY_DPOP_ACCESS_TOKEN_TTL); | 601 | const expiresAt = DateTime.addDuration(now, RelayTokens.RELAY_DPOP_ACCESS_TOKEN_TTL); |
| 603 | const jti = yield* crypto.randomUUIDv4.pipe( | 602 | const jti = yield* crypto.randomUUIDv4.pipe( |
| 604 | Effect.catch(() => relayInternalErrorResponse("internal_error")), | 603 | Effect.catch(() => relayInternalErrorResponse("internal_error")), |
| 605 | ); | 604 | ); |
| ... | @@ -617,7 +616,7 @@ export const tokenApi = HttpApiBuilder.group( | ... | @@ -617,7 +616,7 @@ export const tokenApi = HttpApiBuilder.group( |
| 617 | .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), | 616 | .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), |
| 618 | issued_token_type: RelayAccessTokenType, | 617 | issued_token_type: RelayAccessTokenType, |
| 619 | token_type: "DPoP" as const, | 618 | token_type: "DPoP" as const, |
| 620 | expires_in: Duration.toSeconds(RELAY_DPOP_ACCESS_TOKEN_TTL), | 619 | expires_in: Duration.toSeconds(RelayTokens.RELAY_DPOP_ACCESS_TOKEN_TTL), |
| 621 | scope: encodeOAuthScope(requestedScopes), | 620 | scope: encodeOAuthScope(requestedScopes), |
| 622 | }; | 621 | }; |
| 623 | }, mapRelayCommonApiErrors("invalid_dpop")), | 622 | }, mapRelayCommonApiErrors("invalid_dpop")), |
oxlint-plugin-t3code/rules/no-inline-schema-compile.ts+4| ... | @@ -13,22 +13,26 @@ const COMPILER_METHODS = new Set<keyof typeof Schema>([ | ... | @@ -13,22 +13,26 @@ const COMPILER_METHODS = new Set<keyof typeof Schema>([ |
| 13 | "decodeExit", | 13 | "decodeExit", |
| 14 | "decodeOption", | 14 | "decodeOption", |
| 15 | "decodePromise", | 15 | "decodePromise", |
| 16 | "decodeResult", | ||
| 16 | "decodeSync", | 17 | "decodeSync", |
| 17 | "decodeUnknownExit", | 18 | "decodeUnknownExit", |
| 18 | "decodeUnknownEffect", | 19 | "decodeUnknownEffect", |
| 19 | "decodeUnknownOption", | 20 | "decodeUnknownOption", |
| 20 | "decodeUnknownPromise", | 21 | "decodeUnknownPromise", |
| 22 | "decodeUnknownResult", | ||
| 21 | "decodeUnknownSync", | 23 | "decodeUnknownSync", |
| 22 | 24 | ||
| 23 | "encodeExit", | 25 | "encodeExit", |
| 24 | "encodeEffect", | 26 | "encodeEffect", |
| 25 | "encodeOption", | 27 | "encodeOption", |
| 26 | "encodePromise", | 28 | "encodePromise", |
| 29 | "encodeResult", | ||
| 27 | "encodeSync", | 30 | "encodeSync", |
| 28 | "encodeUnknownExit", | 31 | "encodeUnknownExit", |
| 29 | "encodeUnknownEffect", | 32 | "encodeUnknownEffect", |
| 30 | "encodeUnknownOption", | 33 | "encodeUnknownOption", |
| 31 | "encodeUnknownPromise", | 34 | "encodeUnknownPromise", |
| 35 | "encodeUnknownResult", | ||
| 32 | "encodeUnknownSync", | 36 | "encodeUnknownSync", |
| 33 | ]); | 37 | ]); |
| 34 | 38 |
oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts+1-1| ... | @@ -48,7 +48,7 @@ const LEGACY_BASELINE = new Map<string, number>([ | ... | @@ -48,7 +48,7 @@ const LEGACY_BASELINE = new Map<string, number>([ |
| 48 | ["apps/web/src/cloud/dpop.test.ts", 2], | 48 | ["apps/web/src/cloud/dpop.test.ts", 2], |
| 49 | ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1], | 49 | ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1], |
| 50 | ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7], | 50 | ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7], |
| 51 | ["packages/client-runtime/src/managedRelayState.test.ts", 1], | 51 | ["packages/client-runtime/src/relay/managedRelayState.test.ts", 1], |
| 52 | ["packages/client-runtime/src/wsTransport.test.ts", 2], | 52 | ["packages/client-runtime/src/wsTransport.test.ts", 2], |
| 53 | ]); | 53 | ]); |
| 54 | 54 |
packages/client-runtime/README.md created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | # Client Runtime | ||
| 2 | |||
| 3 | Shared client behavior for web and mobile. Public APIs are organized by package | ||
| 4 | subpath. The package intentionally has no root export. | ||
| 5 | |||
| 6 | ## Public subpaths | ||
| 7 | |||
| 8 | | Subpath | Responsibility | | ||
| 9 | | --------------------- | ---------------------------------------------------------------- | | ||
| 10 | | `authorization` | Bearer and DPoP authorization plus token persistence contracts | | ||
| 11 | | `connection` | Targets, catalog, supervision, retries, registry, and onboarding | | ||
| 12 | | `environment` | Environment identity, descriptors, endpoints, and scoped keys | | ||
| 13 | | `errors` | Shared client error inspection | | ||
| 14 | | `operations` | Multi-step application workflows | | ||
| 15 | | `operations/projects` | Multi-step project creation workflows | | ||
| 16 | | `platform` | Platform capability and persistence service contracts | | ||
| 17 | | `relay` | Managed relay API and environment discovery | | ||
| 18 | | `rpc` | HTTP/RPC clients, protocol, sessions, and subscriptions | | ||
| 19 | | `state/<domain>` | Focused shared state, retention, reducers, and Atom constructors | | ||
| 20 | |||
| 21 | ## Dependency direction | ||
| 22 | |||
| 23 | Platform applications provide `platform` services. `connection` composes those | ||
| 24 | capabilities with `authorization`, `relay`, and `rpc` to supervise environment | ||
| 25 | sessions. Independent `state` modules consume the connection registry and expose | ||
| 26 | focused state or Atom constructors to application-owned runtimes. | ||
| 27 | |||
| 28 | Applications should import the narrowest relevant subpath. There is no broad | ||
| 29 | `state` export: use domain paths such as `state/shell`, `state/threads`, | ||
| 30 | `state/terminal`, or `state/vcs`. Subpath indices and explicitly exported domain | ||
| 31 | files are public API boundaries; all other files remain implementation details. | ||
packages/client-runtime/package.json+119-8| ... | @@ -2,15 +2,126 @@ | ... | @@ -2,15 +2,126 @@ |
| 2 | "name": "@t3tools/client-runtime", | 2 | "name": "@t3tools/client-runtime", |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "main": "./src/index.ts", | ||
| 6 | "types": "./src/index.ts", | ||
| 7 | "exports": { | 5 | "exports": { |
| 8 | ".": { | 6 | "./connection": { |
| 9 | "types": "./src/index.ts", | 7 | "types": "./src/connection/index.ts", |
| 10 | "react-native": "./src/index.ts", | 8 | "default": "./src/connection/index.ts" |
| 11 | "import": "./src/index.ts", | 9 | }, |
| 12 | "require": "./src/index.ts", | 10 | "./authorization": { |
| 13 | "default": "./src/index.ts" | 11 | "types": "./src/authorization/index.ts", |
| 12 | "default": "./src/authorization/index.ts" | ||
| 13 | }, | ||
| 14 | "./environment": { | ||
| 15 | "types": "./src/environment/index.ts", | ||
| 16 | "default": "./src/environment/index.ts" | ||
| 17 | }, | ||
| 18 | "./errors": { | ||
| 19 | "types": "./src/errors/index.ts", | ||
| 20 | "default": "./src/errors/index.ts" | ||
| 21 | }, | ||
| 22 | "./rpc": { | ||
| 23 | "types": "./src/rpc/index.ts", | ||
| 24 | "default": "./src/rpc/index.ts" | ||
| 25 | }, | ||
| 26 | "./operations": { | ||
| 27 | "types": "./src/operations/index.ts", | ||
| 28 | "default": "./src/operations/index.ts" | ||
| 29 | }, | ||
| 30 | "./operations/projects": { | ||
| 31 | "types": "./src/operations/projects.ts", | ||
| 32 | "default": "./src/operations/projects.ts" | ||
| 33 | }, | ||
| 34 | "./platform": { | ||
| 35 | "types": "./src/platform/index.ts", | ||
| 36 | "default": "./src/platform/index.ts" | ||
| 37 | }, | ||
| 38 | "./relay": { | ||
| 39 | "types": "./src/relay/index.ts", | ||
| 40 | "default": "./src/relay/index.ts" | ||
| 41 | }, | ||
| 42 | "./state/auth": { | ||
| 43 | "types": "./src/state/auth.ts", | ||
| 44 | "default": "./src/state/auth.ts" | ||
| 45 | }, | ||
| 46 | "./state/assets": { | ||
| 47 | "types": "./src/state/assets.ts", | ||
| 48 | "default": "./src/state/assets.ts" | ||
| 49 | }, | ||
| 50 | "./state/connections": { | ||
| 51 | "types": "./src/state/connections.ts", | ||
| 52 | "default": "./src/state/connections.ts" | ||
| 53 | }, | ||
| 54 | "./state/entities": { | ||
| 55 | "types": "./src/state/entities.ts", | ||
| 56 | "default": "./src/state/entities.ts" | ||
| 57 | }, | ||
| 58 | "./state/filesystem": { | ||
| 59 | "types": "./src/state/filesystem.ts", | ||
| 60 | "default": "./src/state/filesystem.ts" | ||
| 61 | }, | ||
| 62 | "./state/git": { | ||
| 63 | "types": "./src/state/git.ts", | ||
| 64 | "default": "./src/state/git.ts" | ||
| 65 | }, | ||
| 66 | "./state/models": { | ||
| 67 | "types": "./src/state/models.ts", | ||
| 68 | "default": "./src/state/models.ts" | ||
| 69 | }, | ||
| 70 | "./state/orchestration": { | ||
| 71 | "types": "./src/state/orchestration.ts", | ||
| 72 | "default": "./src/state/orchestration.ts" | ||
| 73 | }, | ||
| 74 | "./state/presentation": { | ||
| 75 | "types": "./src/state/presentation.ts", | ||
| 76 | "default": "./src/state/presentation.ts" | ||
| 77 | }, | ||
| 78 | "./state/preview": { | ||
| 79 | "types": "./src/state/preview.ts", | ||
| 80 | "default": "./src/state/preview.ts" | ||
| 81 | }, | ||
| 82 | "./state/projects": { | ||
| 83 | "types": "./src/state/projects.ts", | ||
| 84 | "default": "./src/state/projects.ts" | ||
| 85 | }, | ||
| 86 | "./state/relay": { | ||
| 87 | "types": "./src/state/relayDiscovery.ts", | ||
| 88 | "default": "./src/state/relayDiscovery.ts" | ||
| 89 | }, | ||
| 90 | "./state/review": { | ||
| 91 | "types": "./src/state/review.ts", | ||
| 92 | "default": "./src/state/review.ts" | ||
| 93 | }, | ||
| 94 | "./state/runtime": { | ||
| 95 | "types": "./src/state/runtime.ts", | ||
| 96 | "default": "./src/state/runtime.ts" | ||
| 97 | }, | ||
| 98 | "./state/server": { | ||
| 99 | "types": "./src/state/server.ts", | ||
| 100 | "default": "./src/state/server.ts" | ||
| 101 | }, | ||
| 102 | "./state/session": { | ||
| 103 | "types": "./src/state/session.ts", | ||
| 104 | "default": "./src/state/session.ts" | ||
| 105 | }, | ||
| 106 | "./state/shell": { | ||
| 107 | "types": "./src/state/shell.ts", | ||
| 108 | "default": "./src/state/shell.ts" | ||
| 109 | }, | ||
| 110 | "./state/source-control": { | ||
| 111 | "types": "./src/state/sourceControl.ts", | ||
| 112 | "default": "./src/state/sourceControl.ts" | ||
| 113 | }, | ||
| 114 | "./state/terminal": { | ||
| 115 | "types": "./src/state/terminal.ts", | ||
| 116 | "default": "./src/state/terminal.ts" | ||
| 117 | }, | ||
| 118 | "./state/threads": { | ||
| 119 | "types": "./src/state/threads.ts", | ||
| 120 | "default": "./src/state/threads.ts" | ||
| 121 | }, | ||
| 122 | "./state/vcs": { | ||
| 123 | "types": "./src/state/vcs.ts", | ||
| 124 | "default": "./src/state/vcs.ts" | ||
| 14 | } | 125 | } |
| 15 | }, | 126 | }, |
| 16 | "scripts": { | 127 | "scripts": { |
packages/client-runtime/src/addProject.test.ts deleted-146| ... | @@ -1,146 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { | ||
| 3 | EnvironmentId, | ||
| 4 | ProjectId, | ||
| 5 | CommandId, | ||
| 6 | SourceControlDiscoveryResult, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | buildAddProjectRemoteSourceReadiness, | ||
| 12 | buildProjectCreateCommand, | ||
| 13 | findExistingAddProject, | ||
| 14 | getAddProjectInitialQuery, | ||
| 15 | resolveAddProjectPath, | ||
| 16 | sortAddProjectProviderSources, | ||
| 17 | } from "./addProject.ts"; | ||
| 18 | import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; | ||
| 19 | |||
| 20 | describe("add project shared logic", () => { | ||
| 21 | it("resolves initial browse paths from settings", () => { | ||
| 22 | expect(getAddProjectInitialQuery("")).toBe("~/"); | ||
| 23 | expect(getAddProjectInitialQuery("/work")).toBe("/work/"); | ||
| 24 | expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\"); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("rejects unsupported windows paths on non-windows environments", () => { | ||
| 28 | expect( | ||
| 29 | resolveAddProjectPath({ | ||
| 30 | rawPath: "C:\\repo", | ||
| 31 | platform: "MacIntel", | ||
| 32 | currentProjectCwd: null, | ||
| 33 | }), | ||
| 34 | ).toEqual({ | ||
| 35 | ok: false, | ||
| 36 | error: "Windows-style paths are only supported on Windows environments.", | ||
| 37 | }); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("resolves relative paths from the active project cwd", () => { | ||
| 41 | expect( | ||
| 42 | resolveAddProjectPath({ | ||
| 43 | rawPath: "../next", | ||
| 44 | platform: "Linux", | ||
| 45 | currentProjectCwd: "/work/current", | ||
| 46 | }), | ||
| 47 | ).toEqual({ ok: true, path: "/work/next" }); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("marks authenticated source control providers as ready", () => { | ||
| 51 | const discovery: SourceControlDiscoveryResult = { | ||
| 52 | versionControlSystems: [], | ||
| 53 | sourceControlProviders: [ | ||
| 54 | { | ||
| 55 | kind: "github", | ||
| 56 | label: "GitHub", | ||
| 57 | status: "available", | ||
| 58 | installHint: "Install gh", | ||
| 59 | version: Option.some("1.0.0"), | ||
| 60 | detail: Option.none(), | ||
| 61 | auth: { | ||
| 62 | status: "authenticated", | ||
| 63 | account: Option.some("octo"), | ||
| 64 | host: Option.some("github.com"), | ||
| 65 | detail: Option.none(), | ||
| 66 | }, | ||
| 67 | }, | ||
| 68 | { | ||
| 69 | kind: "gitlab", | ||
| 70 | label: "GitLab", | ||
| 71 | status: "available", | ||
| 72 | installHint: "Install glab", | ||
| 73 | version: Option.some("1.0.0"), | ||
| 74 | detail: Option.none(), | ||
| 75 | auth: { | ||
| 76 | status: "unauthenticated", | ||
| 77 | account: Option.none(), | ||
| 78 | host: Option.none(), | ||
| 79 | detail: Option.some("Run glab auth login"), | ||
| 80 | }, | ||
| 81 | }, | ||
| 82 | ], | ||
| 83 | }; | ||
| 84 | |||
| 85 | const readiness = buildAddProjectRemoteSourceReadiness(discovery); | ||
| 86 | expect(readiness.url.ready).toBe(true); | ||
| 87 | expect(readiness.github.ready).toBe(true); | ||
| 88 | expect(readiness.gitlab).toEqual({ ready: false, hint: "Run glab auth login" }); | ||
| 89 | expect(sortAddProjectProviderSources(readiness)[0]).toBe("github"); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("finds existing projects by normalized path in the target environment", () => { | ||
| 93 | const env = EnvironmentId.make("env"); | ||
| 94 | const other = EnvironmentId.make("other"); | ||
| 95 | const projects: EnvironmentScopedProjectShell[] = [ | ||
| 96 | { | ||
| 97 | environmentId: other, | ||
| 98 | id: ProjectId.make("same-path-other-env"), | ||
| 99 | title: "Other", | ||
| 100 | workspaceRoot: "/repo", | ||
| 101 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 102 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 103 | repositoryIdentity: null, | ||
| 104 | defaultModelSelection: null, | ||
| 105 | scripts: [], | ||
| 106 | }, | ||
| 107 | { | ||
| 108 | environmentId: env, | ||
| 109 | id: ProjectId.make("project"), | ||
| 110 | title: "Repo", | ||
| 111 | workspaceRoot: "/repo/", | ||
| 112 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 113 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 114 | repositoryIdentity: null, | ||
| 115 | defaultModelSelection: null, | ||
| 116 | scripts: [], | ||
| 117 | }, | ||
| 118 | ]; | ||
| 119 | |||
| 120 | expect(findExistingAddProject({ projects, environmentId: env, path: "/repo" })?.id).toBe( | ||
| 121 | "project", | ||
| 122 | ); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("builds the existing project.create command shape", () => { | ||
| 126 | expect( | ||
| 127 | buildProjectCreateCommand({ | ||
| 128 | commandId: CommandId.make("command"), | ||
| 129 | projectId: ProjectId.make("project"), | ||
| 130 | workspaceRoot: "/work/repo", | ||
| 131 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 132 | }), | ||
| 133 | ).toMatchObject({ | ||
| 134 | type: "project.create", | ||
| 135 | commandId: "command", | ||
| 136 | projectId: "project", | ||
| 137 | title: "repo", | ||
| 138 | workspaceRoot: "/work/repo", | ||
| 139 | createWorkspaceRootIfMissing: true, | ||
| 140 | defaultModelSelection: { | ||
| 141 | instanceId: "codex", | ||
| 142 | model: "gpt-5.4", | ||
| 143 | }, | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | }); | ||
packages/client-runtime/src/addProject.ts deleted-224| ... | @@ -1,224 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | CommandId, | ||
| 3 | EnvironmentId, | ||
| 4 | OrchestrationCommand, | ||
| 5 | ProjectId, | ||
| 6 | SourceControlDiscoveryResult, | ||
| 7 | SourceControlProviderKind, | ||
| 8 | SourceControlRepositoryInfo, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 11 | import * as Arr from "effect/Array"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Order from "effect/Order"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | ensureBrowseDirectoryPath, | ||
| 17 | findProjectByPath, | ||
| 18 | inferProjectTitleFromPath, | ||
| 19 | isExplicitRelativeProjectPath, | ||
| 20 | isUnsupportedWindowsProjectPath, | ||
| 21 | resolveProjectPathForDispatch, | ||
| 22 | } from "./projectPaths.ts"; | ||
| 23 | import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; | ||
| 24 | |||
| 25 | export type AddProjectRemoteProviderKind = Extract< | ||
| 26 | SourceControlProviderKind, | ||
| 27 | "github" | "gitlab" | "bitbucket" | "azure-devops" | ||
| 28 | >; | ||
| 29 | export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | ||
| 30 | |||
| 31 | export type AddProjectRemoteSourceReadiness = Record< | ||
| 32 | AddProjectRemoteSource, | ||
| 33 | { readonly ready: boolean; readonly hint: string | null } | ||
| 34 | >; | ||
| 35 | |||
| 36 | export type AddProjectCloneFlow = | ||
| 37 | | { | ||
| 38 | readonly step: "repository"; | ||
| 39 | readonly environmentId: EnvironmentId; | ||
| 40 | readonly source: AddProjectRemoteSource; | ||
| 41 | } | ||
| 42 | | { | ||
| 43 | readonly step: "confirm"; | ||
| 44 | readonly environmentId: EnvironmentId; | ||
| 45 | readonly source: AddProjectRemoteSource; | ||
| 46 | readonly repositoryInput: string; | ||
| 47 | readonly repository: SourceControlRepositoryInfo | null; | ||
| 48 | readonly remoteUrl: string; | ||
| 49 | }; | ||
| 50 | |||
| 51 | export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [ | ||
| 52 | "url", | ||
| 53 | "github", | ||
| 54 | "gitlab", | ||
| 55 | "bitbucket", | ||
| 56 | "azure-devops", | ||
| 57 | ]; | ||
| 58 | |||
| 59 | export const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [ | ||
| 60 | "github", | ||
| 61 | "gitlab", | ||
| 62 | "bitbucket", | ||
| 63 | "azure-devops", | ||
| 64 | ]; | ||
| 65 | |||
| 66 | export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string { | ||
| 67 | switch (source) { | ||
| 68 | case "github": | ||
| 69 | return "GitHub"; | ||
| 70 | case "gitlab": | ||
| 71 | return "GitLab"; | ||
| 72 | case "bitbucket": | ||
| 73 | return "Bitbucket"; | ||
| 74 | case "azure-devops": | ||
| 75 | return "Azure DevOps"; | ||
| 76 | case "url": | ||
| 77 | return "Git URL"; | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string { | ||
| 82 | switch (source) { | ||
| 83 | case "github": | ||
| 84 | return "owner/repo"; | ||
| 85 | case "gitlab": | ||
| 86 | return "group/project"; | ||
| 87 | case "bitbucket": | ||
| 88 | return "workspace/repository"; | ||
| 89 | case "azure-devops": | ||
| 90 | return "project/repository"; | ||
| 91 | case "url": | ||
| 92 | return "URL"; | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | export function addProjectRemoteSourceProvider( | ||
| 97 | source: AddProjectRemoteSource, | ||
| 98 | ): AddProjectRemoteProviderKind | null { | ||
| 99 | return source === "url" ? null : source; | ||
| 100 | } | ||
| 101 | |||
| 102 | export function sortAddProjectProviderSources( | ||
| 103 | readinessBySource: AddProjectRemoteSourceReadiness, | ||
| 104 | ): ReadonlyArray<AddProjectRemoteProviderKind> { | ||
| 105 | return Arr.sort( | ||
| 106 | ADD_PROJECT_REMOTE_PROVIDER_SOURCES, | ||
| 107 | Order.mapInput( | ||
| 108 | Order.Struct({ | ||
| 109 | ready: Order.flip(Order.Boolean), | ||
| 110 | label: Order.String, | ||
| 111 | }), | ||
| 112 | (source: AddProjectRemoteProviderKind) => ({ | ||
| 113 | ready: readinessBySource[source].ready, | ||
| 114 | label: addProjectRemoteSourceLabel(source), | ||
| 115 | }), | ||
| 116 | ), | ||
| 117 | ); | ||
| 118 | } | ||
| 119 | |||
| 120 | export function buildAddProjectRemoteSourceReadiness( | ||
| 121 | discovery: SourceControlDiscoveryResult | null, | ||
| 122 | ): AddProjectRemoteSourceReadiness { | ||
| 123 | const unavailable = { | ||
| 124 | ready: false, | ||
| 125 | hint: "Provider status unavailable. Open Source Control settings and rescan.", | ||
| 126 | } as const; | ||
| 127 | const readiness: AddProjectRemoteSourceReadiness = { | ||
| 128 | url: { ready: true, hint: null }, | ||
| 129 | github: unavailable, | ||
| 130 | gitlab: unavailable, | ||
| 131 | bitbucket: unavailable, | ||
| 132 | "azure-devops": unavailable, | ||
| 133 | }; | ||
| 134 | |||
| 135 | if (!discovery) { | ||
| 136 | return readiness; | ||
| 137 | } | ||
| 138 | |||
| 139 | const providerByKind = new Map( | ||
| 140 | discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), | ||
| 141 | ); | ||
| 142 | for (const source of ADD_PROJECT_REMOTE_SOURCES) { | ||
| 143 | const kind = addProjectRemoteSourceProvider(source); | ||
| 144 | if (!kind) continue; | ||
| 145 | const provider = providerByKind.get(kind); | ||
| 146 | if (!provider) { | ||
| 147 | readiness[source] = unavailable; | ||
| 148 | continue; | ||
| 149 | } | ||
| 150 | if (provider.status !== "available") { | ||
| 151 | readiness[source] = { ready: false, hint: provider.installHint }; | ||
| 152 | continue; | ||
| 153 | } | ||
| 154 | if (provider.auth.status === "unauthenticated") { | ||
| 155 | readiness[source] = { | ||
| 156 | ready: false, | ||
| 157 | hint: | ||
| 158 | Option.getOrNull(provider.auth.detail) ?? | ||
| 159 | `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`, | ||
| 160 | }; | ||
| 161 | continue; | ||
| 162 | } | ||
| 163 | readiness[source] = { ready: true, hint: null }; | ||
| 164 | } | ||
| 165 | return readiness; | ||
| 166 | } | ||
| 167 | |||
| 168 | export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string { | ||
| 169 | const trimmed = baseDirectory?.trim() ?? ""; | ||
| 170 | return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed); | ||
| 171 | } | ||
| 172 | |||
| 173 | export function resolveAddProjectPath(input: { | ||
| 174 | readonly rawPath: string; | ||
| 175 | readonly currentProjectCwd?: string | null; | ||
| 176 | readonly platform: string; | ||
| 177 | }): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly error: string } { | ||
| 178 | const rawPath = input.rawPath.trim(); | ||
| 179 | if (rawPath.length === 0) { | ||
| 180 | return { ok: false, error: "Enter a project path." }; | ||
| 181 | } | ||
| 182 | if (isUnsupportedWindowsProjectPath(rawPath, input.platform)) { | ||
| 183 | return { ok: false, error: "Windows-style paths are only supported on Windows environments." }; | ||
| 184 | } | ||
| 185 | if (isExplicitRelativeProjectPath(rawPath) && !input.currentProjectCwd) { | ||
| 186 | return { ok: false, error: "Relative paths require an active project in this environment." }; | ||
| 187 | } | ||
| 188 | const path = resolveProjectPathForDispatch(rawPath, input.currentProjectCwd); | ||
| 189 | return path.length === 0 ? { ok: false, error: "Enter a project path." } : { ok: true, path }; | ||
| 190 | } | ||
| 191 | |||
| 192 | export function findExistingAddProject(input: { | ||
| 193 | readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>; | ||
| 194 | readonly environmentId: EnvironmentId; | ||
| 195 | readonly path: string; | ||
| 196 | }): EnvironmentScopedProjectShell | null { | ||
| 197 | return ( | ||
| 198 | findProjectByPath( | ||
| 199 | input.projects.filter((project) => project.environmentId === input.environmentId), | ||
| 200 | input.path, | ||
| 201 | ) ?? null | ||
| 202 | ); | ||
| 203 | } | ||
| 204 | |||
| 205 | export function buildProjectCreateCommand(input: { | ||
| 206 | readonly commandId: CommandId; | ||
| 207 | readonly projectId: ProjectId; | ||
| 208 | readonly workspaceRoot: string; | ||
| 209 | readonly createdAt: string; | ||
| 210 | }): Extract<OrchestrationCommand, { type: "project.create" }> { | ||
| 211 | return { | ||
| 212 | type: "project.create", | ||
| 213 | commandId: input.commandId, | ||
| 214 | projectId: input.projectId, | ||
| 215 | title: inferProjectTitleFromPath(input.workspaceRoot), | ||
| 216 | workspaceRoot: input.workspaceRoot, | ||
| 217 | createWorkspaceRootIfMissing: true, | ||
| 218 | defaultModelSelection: { | ||
| 219 | instanceId: ProviderInstanceId.make("codex"), | ||
| 220 | model: DEFAULT_MODEL, | ||
| 221 | }, | ||
| 222 | createdAt: input.createdAt, | ||
| 223 | }; | ||
| 224 | } | ||
packages/client-runtime/src/advertisedEndpoint.test.ts deleted-61| ... | @@ -1,61 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | classifyHostedHttpsCompatibility, | ||
| 5 | createAdvertisedEndpoint, | ||
| 6 | deriveWsBaseUrl, | ||
| 7 | normalizeHttpBaseUrl, | ||
| 8 | } from "./advertisedEndpoint.ts"; | ||
| 9 | |||
| 10 | const coreProvider = { | ||
| 11 | id: "desktop-core", | ||
| 12 | label: "Desktop", | ||
| 13 | kind: "core", | ||
| 14 | isAddon: false, | ||
| 15 | } as const; | ||
| 16 | |||
| 17 | describe("advertised endpoint helpers", () => { | ||
| 18 | it("normalizes HTTP and WebSocket base URLs", () => { | ||
| 19 | expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe("https://example.com/"); | ||
| 20 | expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/"); | ||
| 21 | expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/"); | ||
| 22 | expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/"); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => { | ||
| 26 | expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe( | ||
| 27 | "mixed-content-blocked", | ||
| 28 | ); | ||
| 29 | expect(classifyHostedHttpsCompatibility("https://desktop.example.com", "compatible")).toBe( | ||
| 30 | "compatible", | ||
| 31 | ); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("creates provider-neutral endpoint records", () => { | ||
| 35 | expect( | ||
| 36 | createAdvertisedEndpoint({ | ||
| 37 | id: "lan:http://192.168.1.44:3773", | ||
| 38 | label: "LAN", | ||
| 39 | provider: coreProvider, | ||
| 40 | httpBaseUrl: "http://192.168.1.44:3773", | ||
| 41 | reachability: "lan", | ||
| 42 | source: "desktop-core", | ||
| 43 | isDefault: true, | ||
| 44 | }), | ||
| 45 | ).toEqual({ | ||
| 46 | id: "lan:http://192.168.1.44:3773", | ||
| 47 | label: "LAN", | ||
| 48 | provider: coreProvider, | ||
| 49 | httpBaseUrl: "http://192.168.1.44:3773/", | ||
| 50 | wsBaseUrl: "ws://192.168.1.44:3773/", | ||
| 51 | reachability: "lan", | ||
| 52 | compatibility: { | ||
| 53 | hostedHttpsApp: "mixed-content-blocked", | ||
| 54 | desktopApp: "compatible", | ||
| 55 | }, | ||
| 56 | source: "desktop-core", | ||
| 57 | status: "available", | ||
| 58 | isDefault: true, | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | }); | ||
packages/client-runtime/src/advertisedEndpoint.ts deleted-1| ... | @@ -1 +0,0 @@ | ||
| 1 | export * from "@t3tools/shared/advertisedEndpoint"; | ||
packages/client-runtime/src/archivedThreadsState.test.ts deleted-96| ... | @@ -1,96 +0,0 @@ | ||
| 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type ArchivedThreadsClient, | ||
| 7 | createArchivedThreadsManager, | ||
| 8 | makeArchivedThreadsEnvironmentKey, | ||
| 9 | parseArchivedThreadsEnvironmentKey, | ||
| 10 | readArchivedThreadsSnapshotState, | ||
| 11 | } from "./archivedThreadsState.ts"; | ||
| 12 | |||
| 13 | let registry = AtomRegistry.make(); | ||
| 14 | |||
| 15 | function resetAtomRegistry() { | ||
| 16 | registry.dispose(); | ||
| 17 | registry = AtomRegistry.make(); | ||
| 18 | } | ||
| 19 | |||
| 20 | function createSnapshot(id: string): OrchestrationShellSnapshot { | ||
| 21 | return { | ||
| 22 | snapshotSequence: 1, | ||
| 23 | projects: [], | ||
| 24 | threads: [], | ||
| 25 | updatedAt: `2026-05-08T00:00:00.000Z`, | ||
| 26 | id, | ||
| 27 | } as OrchestrationShellSnapshot; | ||
| 28 | } | ||
| 29 | |||
| 30 | describe("createArchivedThreadsManager", () => { | ||
| 31 | afterEach(() => { | ||
| 32 | resetAtomRegistry(); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("loads archived snapshots for configured environment clients", async () => { | ||
| 36 | const envA = EnvironmentId.make("env-a"); | ||
| 37 | const envB = EnvironmentId.make("env-b"); | ||
| 38 | const clients = new Map<EnvironmentId, ArchivedThreadsClient>([ | ||
| 39 | [ | ||
| 40 | envA, | ||
| 41 | { | ||
| 42 | getArchivedShellSnapshot: vi.fn(async () => createSnapshot("a")), | ||
| 43 | }, | ||
| 44 | ], | ||
| 45 | [ | ||
| 46 | envB, | ||
| 47 | { | ||
| 48 | getArchivedShellSnapshot: vi.fn(async () => createSnapshot("b")), | ||
| 49 | }, | ||
| 50 | ], | ||
| 51 | ]); | ||
| 52 | const manager = createArchivedThreadsManager({ | ||
| 53 | getRegistry: () => registry, | ||
| 54 | getClient: (environmentId) => clients.get(environmentId) ?? null, | ||
| 55 | }); | ||
| 56 | |||
| 57 | const result = registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envB, envA]))); | ||
| 58 | |||
| 59 | await vi.waitFor(() => { | ||
| 60 | const state = readArchivedThreadsSnapshotState( | ||
| 61 | registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB]))), | ||
| 62 | ); | ||
| 63 | expect(state.snapshots.map((snapshot) => snapshot.environmentId)).toEqual([envA, envB]); | ||
| 64 | }); | ||
| 65 | expect(readArchivedThreadsSnapshotState(result).isLoading).toBe(true); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("refreshes known snapshot groups that include an environment", async () => { | ||
| 69 | const envA = EnvironmentId.make("env-a"); | ||
| 70 | const envB = EnvironmentId.make("env-b"); | ||
| 71 | const getArchivedShellSnapshot = vi.fn(async () => | ||
| 72 | createSnapshot(`a-${getArchivedShellSnapshot.mock.calls.length}`), | ||
| 73 | ); | ||
| 74 | const manager = createArchivedThreadsManager({ | ||
| 75 | getRegistry: () => registry, | ||
| 76 | getClient: (environmentId) => (environmentId === envA ? { getArchivedShellSnapshot } : null), | ||
| 77 | staleTimeMs: 60_000, | ||
| 78 | }); | ||
| 79 | |||
| 80 | const atom = manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB])); | ||
| 81 | registry.get(atom); | ||
| 82 | await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(1)); | ||
| 83 | |||
| 84 | manager.refreshForEnvironment(envA); | ||
| 85 | |||
| 86 | await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(2)); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("round-trips environment keys in sorted order", () => { | ||
| 90 | const envA = EnvironmentId.make("env-a"); | ||
| 91 | const envB = EnvironmentId.make("env-b"); | ||
| 92 | const key = makeArchivedThreadsEnvironmentKey([envB, envA]); | ||
| 93 | |||
| 94 | expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); | ||
| 95 | }); | ||
| 96 | }); | ||
packages/client-runtime/src/archivedThreadsState.ts deleted-138| ... | @@ -1,138 +0,0 @@ | ||
| 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import { pipe } from "effect/Function"; | ||
| 6 | import * as Order from "effect/Order"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Result from "effect/Result"; | ||
| 9 | import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 10 | |||
| 11 | export type ArchivedSnapshotEntry = { | ||
| 12 | readonly environmentId: EnvironmentId; | ||
| 13 | readonly snapshot: OrchestrationShellSnapshot; | ||
| 14 | }; | ||
| 15 | |||
| 16 | export interface ArchivedThreadsClient { | ||
| 17 | readonly getArchivedShellSnapshot: () => Promise<OrchestrationShellSnapshot>; | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface ArchivedThreadsSnapshotState { | ||
| 21 | readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>; | ||
| 22 | readonly error: string | null; | ||
| 23 | readonly isLoading: boolean; | ||
| 24 | } | ||
| 25 | |||
| 26 | const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; | ||
| 27 | const DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS = 5_000; | ||
| 28 | const DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000; | ||
| 29 | const environmentIdOrder = Order.String as Order.Order<EnvironmentId>; | ||
| 30 | |||
| 31 | export function makeArchivedThreadsEnvironmentKey( | ||
| 32 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 33 | ): string { | ||
| 34 | return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) => | ||
| 35 | sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | |||
| 39 | export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<EnvironmentId> { | ||
| 40 | if (key.length === 0) { | ||
| 41 | return []; | ||
| 42 | } | ||
| 43 | return pipe( | ||
| 44 | key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), | ||
| 45 | Arr.map((environmentId) => EnvironmentId.make(environmentId)), | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | export function readArchivedThreadsSnapshotState( | ||
| 50 | result: AsyncResult.AsyncResult<ReadonlyArray<ArchivedSnapshotEntry>, unknown>, | ||
| 51 | ): ArchivedThreadsSnapshotState { | ||
| 52 | const snapshots = Option.getOrElse(AsyncResult.value(result), () => []); | ||
| 53 | let error: string | null = null; | ||
| 54 | if (result._tag === "Failure") { | ||
| 55 | const cause = Cause.squash(result.cause); | ||
| 56 | error = cause instanceof Error ? cause.message : "Failed to load archived threads."; | ||
| 57 | } | ||
| 58 | |||
| 59 | return { | ||
| 60 | snapshots, | ||
| 61 | error, | ||
| 62 | isLoading: result.waiting, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | export function createArchivedThreadsManager(config: { | ||
| 67 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 68 | readonly getClient: (environmentId: EnvironmentId) => ArchivedThreadsClient | null; | ||
| 69 | readonly staleTimeMs?: number; | ||
| 70 | readonly idleTtlMs?: number; | ||
| 71 | }) { | ||
| 72 | const knownEnvironmentKeys = new Set<string>(); | ||
| 73 | const knownEnvironmentIdsByKey = new Map<string, ReadonlySet<EnvironmentId>>(); | ||
| 74 | const staleTime = config.staleTimeMs ?? DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS; | ||
| 75 | const idleTtl = config.idleTtlMs ?? DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS; | ||
| 76 | |||
| 77 | const snapshotsAtom = Atom.family((environmentKey: string) => { | ||
| 78 | knownEnvironmentKeys.add(environmentKey); | ||
| 79 | knownEnvironmentIdsByKey.set( | ||
| 80 | environmentKey, | ||
| 81 | new Set(parseArchivedThreadsEnvironmentKey(environmentKey)), | ||
| 82 | ); | ||
| 83 | return Atom.make( | ||
| 84 | Effect.promise(async (): Promise<ReadonlyArray<ArchivedSnapshotEntry>> => { | ||
| 85 | const snapshots = await Promise.all( | ||
| 86 | pipe( | ||
| 87 | parseArchivedThreadsEnvironmentKey(environmentKey), | ||
| 88 | Arr.map(async (environmentId) => { | ||
| 89 | const client = config.getClient(environmentId); | ||
| 90 | if (!client) { | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | return { | ||
| 94 | environmentId, | ||
| 95 | snapshot: await client.getArchivedShellSnapshot(), | ||
| 96 | }; | ||
| 97 | }), | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | return pipe( | ||
| 101 | snapshots, | ||
| 102 | Arr.filterMap((snapshot) => | ||
| 103 | snapshot !== null ? Result.succeed(snapshot) : Result.failVoid, | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | }), | ||
| 107 | ).pipe( | ||
| 108 | Atom.swr({ | ||
| 109 | staleTime, | ||
| 110 | revalidateOnMount: true, | ||
| 111 | }), | ||
| 112 | Atom.setIdleTTL(idleTtl), | ||
| 113 | Atom.withLabel(`archived-thread-snapshots:${environmentKey}`), | ||
| 114 | ); | ||
| 115 | }); | ||
| 116 | |||
| 117 | function getAtom(environmentKey: string) { | ||
| 118 | return snapshotsAtom(environmentKey); | ||
| 119 | } | ||
| 120 | |||
| 121 | function refresh(environmentIds: ReadonlyArray<EnvironmentId>): void { | ||
| 122 | config.getRegistry().refresh(getAtom(makeArchivedThreadsEnvironmentKey(environmentIds))); | ||
| 123 | } | ||
| 124 | |||
| 125 | function refreshForEnvironment(environmentId: EnvironmentId): void { | ||
| 126 | for (const environmentKey of knownEnvironmentKeys) { | ||
| 127 | if (knownEnvironmentIdsByKey.get(environmentKey)?.has(environmentId)) { | ||
| 128 | config.getRegistry().refresh(getAtom(environmentKey)); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | return { | ||
| 134 | getAtom, | ||
| 135 | refresh, | ||
| 136 | refreshForEnvironment, | ||
| 137 | }; | ||
| 138 | } | ||
packages/client-runtime/src/authorization/index.ts created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | export * from "./layer.ts"; | ||
| 2 | export * from "./remote.ts"; | ||
| 3 | export * from "./service.ts"; | ||
| 4 | export * from "./tokenStore.ts"; | ||
packages/client-runtime/src/authorization/layer.test.ts created+344| ... | @@ -0,0 +1,344 @@ | ||
| 1 | import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Ref from "effect/Ref"; | ||
| 7 | |||
| 8 | import { ManagedRelayDpopSigner, ManagedRelayDpopSignerError } from "../relay/managedRelay.ts"; | ||
| 9 | import { remoteHttpClientLayer } from "../rpc/http.ts"; | ||
| 10 | import { ClientPresentation } from "../platform/capabilities.ts"; | ||
| 11 | import { RemoteEnvironmentAuthorization, type RelayEnvironmentAuthorization } from "./service.ts"; | ||
| 12 | import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; | ||
| 13 | import { remoteEnvironmentAuthorizationLayer } from "./layer.ts"; | ||
| 14 | |||
| 15 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | ||
| 16 | const ENDPOINT = { | ||
| 17 | httpBaseUrl: "https://environment.example.test", | ||
| 18 | wsBaseUrl: "wss://environment.example.test", | ||
| 19 | providerKind: "cloudflare_tunnel" as const, | ||
| 20 | }; | ||
| 21 | const DESCRIPTOR = { | ||
| 22 | environmentId: ENVIRONMENT_ID, | ||
| 23 | label: "Remote environment", | ||
| 24 | platform: { | ||
| 25 | os: "linux", | ||
| 26 | arch: "x64", | ||
| 27 | }, | ||
| 28 | serverVersion: "0.0.0-test", | ||
| 29 | capabilities: { | ||
| 30 | repositoryIdentity: true, | ||
| 31 | }, | ||
| 32 | }; | ||
| 33 | const BOOTSTRAP: RelayEnvironmentAuthorization = { | ||
| 34 | environmentId: ENVIRONMENT_ID, | ||
| 35 | endpoint: ENDPOINT, | ||
| 36 | credential: "relay-bootstrap", | ||
| 37 | }; | ||
| 38 | |||
| 39 | function recordedFetch(responses: ReadonlyArray<Response>) { | ||
| 40 | const calls: Array<readonly [RequestInfo | URL, RequestInit]> = []; | ||
| 41 | let responseIndex = 0; | ||
| 42 | const fetchFn = ((input, init) => { | ||
| 43 | calls.push([input, init ?? {}]); | ||
| 44 | const response = responses[responseIndex++]; | ||
| 45 | return response === undefined | ||
| 46 | ? Promise.reject(new Error(`Unexpected fetch call to ${String(input)}`)) | ||
| 47 | : Promise.resolve(response); | ||
| 48 | }) satisfies typeof fetch; | ||
| 49 | return { calls, fetchFn }; | ||
| 50 | } | ||
| 51 | |||
| 52 | const websocketTicket = (ticket: string) => | ||
| 53 | Response.json({ | ||
| 54 | ticket, | ||
| 55 | expiresAt: "2026-06-06T01:00:00.000Z", | ||
| 56 | }); | ||
| 57 | |||
| 58 | const accessToken = (token: string) => | ||
| 59 | Response.json({ | ||
| 60 | access_token: token, | ||
| 61 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 62 | token_type: "DPoP", | ||
| 63 | expires_in: 3_600, | ||
| 64 | scope: AuthStandardClientScopes.join(" "), | ||
| 65 | }); | ||
| 66 | |||
| 67 | const authInvalid = () => | ||
| 68 | Response.json( | ||
| 69 | { | ||
| 70 | _tag: "EnvironmentAuthInvalidError", | ||
| 71 | code: "auth_invalid", | ||
| 72 | reason: "invalid_credential", | ||
| 73 | traceId: "trace-auth-invalid", | ||
| 74 | }, | ||
| 75 | { status: 401 }, | ||
| 76 | ); | ||
| 77 | |||
| 78 | const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { | ||
| 79 | readonly initialToken?: RemoteDpopAccessToken; | ||
| 80 | readonly responses: ReadonlyArray<Response>; | ||
| 81 | }) { | ||
| 82 | const tokens = yield* Ref.make( | ||
| 83 | new Map( | ||
| 84 | input.initialToken === undefined | ||
| 85 | ? [] | ||
| 86 | : [[input.initialToken.environmentId, input.initialToken]], | ||
| 87 | ), | ||
| 88 | ); | ||
| 89 | const bootstrapCalls = yield* Ref.make(0); | ||
| 90 | const proofInputs = yield* Ref.make< | ||
| 91 | ReadonlyArray<{ | ||
| 92 | readonly method: string; | ||
| 93 | readonly url: string; | ||
| 94 | readonly accessToken?: string; | ||
| 95 | }> | ||
| 96 | >([]); | ||
| 97 | const fetch = recordedFetch(input.responses); | ||
| 98 | |||
| 99 | const tokenStore = RemoteDpopAccessTokenStore.of({ | ||
| 100 | get: (environmentId) => | ||
| 101 | Ref.get(tokens).pipe( | ||
| 102 | Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), | ||
| 103 | ), | ||
| 104 | put: (token) => | ||
| 105 | Ref.update(tokens, (current) => { | ||
| 106 | const next = new Map(current); | ||
| 107 | next.set(token.environmentId, token); | ||
| 108 | return next; | ||
| 109 | }), | ||
| 110 | remove: (environmentId) => | ||
| 111 | Ref.update(tokens, (current) => { | ||
| 112 | const next = new Map(current); | ||
| 113 | next.delete(environmentId); | ||
| 114 | return next; | ||
| 115 | }), | ||
| 116 | }); | ||
| 117 | const signer = ManagedRelayDpopSigner.of({ | ||
| 118 | thumbprint: Effect.succeed("thumbprint-1"), | ||
| 119 | createProof: (proofInput) => | ||
| 120 | Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( | ||
| 121 | Effect.as(`proof:${proofInput.url}`), | ||
| 122 | Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), | ||
| 123 | ), | ||
| 124 | }); | ||
| 125 | const layer = remoteEnvironmentAuthorizationLayer.pipe( | ||
| 126 | Layer.provide( | ||
| 127 | Layer.mergeAll( | ||
| 128 | remoteHttpClientLayer(fetch.fetchFn), | ||
| 129 | Layer.succeed(ManagedRelayDpopSigner, signer), | ||
| 130 | Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), | ||
| 131 | Layer.succeed( | ||
| 132 | ClientPresentation, | ||
| 133 | ClientPresentation.of({ | ||
| 134 | metadata: { | ||
| 135 | label: "T3 Code Test", | ||
| 136 | deviceType: "mobile", | ||
| 137 | os: "test", | ||
| 138 | }, | ||
| 139 | scopes: AuthStandardClientScopes, | ||
| 140 | }), | ||
| 141 | ), | ||
| 142 | ), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | const obtainBootstrap = Ref.update(bootstrapCalls, (count) => count + 1).pipe( | ||
| 146 | Effect.as(BOOTSTRAP), | ||
| 147 | ); | ||
| 148 | |||
| 149 | return { | ||
| 150 | layer, | ||
| 151 | tokens, | ||
| 152 | bootstrapCalls, | ||
| 153 | proofInputs, | ||
| 154 | fetch, | ||
| 155 | obtainBootstrap, | ||
| 156 | }; | ||
| 157 | }); | ||
| 158 | |||
| 159 | describe("RemoteEnvironmentAuthorization", () => { | ||
| 160 | it.effect("reuses a valid persisted environment token without contacting the relay", () => | ||
| 161 | Effect.gen(function* () { | ||
| 162 | const cached = new RemoteDpopAccessToken({ | ||
| 163 | environmentId: ENVIRONMENT_ID, | ||
| 164 | label: DESCRIPTOR.label, | ||
| 165 | endpoint: ENDPOINT, | ||
| 166 | accessToken: "cached-access-token", | ||
| 167 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | ||
| 168 | dpopThumbprint: "thumbprint-1", | ||
| 169 | }); | ||
| 170 | const harness = yield* makeHarness({ | ||
| 171 | initialToken: cached, | ||
| 172 | responses: [websocketTicket("cached-ticket")], | ||
| 173 | }); | ||
| 174 | |||
| 175 | const authorized = yield* Effect.gen(function* () { | ||
| 176 | const remote = yield* RemoteEnvironmentAuthorization; | ||
| 177 | return yield* remote.authorizeDpop({ | ||
| 178 | expectedEnvironmentId: ENVIRONMENT_ID, | ||
| 179 | obtainBootstrap: harness.obtainBootstrap, | ||
| 180 | }); | ||
| 181 | }).pipe(Effect.provide(harness.layer)); | ||
| 182 | |||
| 183 | expect(authorized.socketUrl).toContain("wsTicket=cached-ticket"); | ||
| 184 | expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); | ||
| 185 | expect(harness.fetch.calls).toHaveLength(1); | ||
| 186 | expect(String(harness.fetch.calls[0]?.[0])).toBe( | ||
| 187 | "https://environment.example.test/api/auth/websocket-ticket", | ||
| 188 | ); | ||
| 189 | }), | ||
| 190 | ); | ||
| 191 | |||
| 192 | it.effect("refreshes and persists an expired environment token", () => | ||
| 193 | Effect.gen(function* () { | ||
| 194 | const expired = new RemoteDpopAccessToken({ | ||
| 195 | environmentId: ENVIRONMENT_ID, | ||
| 196 | label: DESCRIPTOR.label, | ||
| 197 | endpoint: ENDPOINT, | ||
| 198 | accessToken: "expired-access-token", | ||
| 199 | expiresAtEpochMs: 0, | ||
| 200 | dpopThumbprint: "thumbprint-1", | ||
| 201 | }); | ||
| 202 | const harness = yield* makeHarness({ | ||
| 203 | initialToken: expired, | ||
| 204 | responses: [ | ||
| 205 | Response.json(DESCRIPTOR), | ||
| 206 | accessToken("fresh-access-token"), | ||
| 207 | websocketTicket("fresh-ticket"), | ||
| 208 | ], | ||
| 209 | }); | ||
| 210 | |||
| 211 | const authorized = yield* Effect.gen(function* () { | ||
| 212 | const remote = yield* RemoteEnvironmentAuthorization; | ||
| 213 | return yield* remote.authorizeDpop({ | ||
| 214 | expectedEnvironmentId: ENVIRONMENT_ID, | ||
| 215 | obtainBootstrap: harness.obtainBootstrap, | ||
| 216 | }); | ||
| 217 | }).pipe(Effect.provide(harness.layer)); | ||
| 218 | |||
| 219 | expect(authorized.socketUrl).toContain("wsTicket=fresh-ticket"); | ||
| 220 | expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); | ||
| 221 | expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( | ||
| 222 | expect.objectContaining({ | ||
| 223 | accessToken: "fresh-access-token", | ||
| 224 | dpopThumbprint: "thumbprint-1", | ||
| 225 | }), | ||
| 226 | ); | ||
| 227 | expect(harness.fetch.calls).toHaveLength(3); | ||
| 228 | }), | ||
| 229 | ); | ||
| 230 | |||
| 231 | it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const cached = new RemoteDpopAccessToken({ | ||
| 234 | environmentId: ENVIRONMENT_ID, | ||
| 235 | label: DESCRIPTOR.label, | ||
| 236 | endpoint: ENDPOINT, | ||
| 237 | accessToken: "invalid-access-token", | ||
| 238 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | ||
| 239 | dpopThumbprint: "thumbprint-1", | ||
| 240 | }); | ||
| 241 | const harness = yield* makeHarness({ | ||
| 242 | initialToken: cached, | ||
| 243 | responses: [ | ||
| 244 | authInvalid(), | ||
| 245 | Response.json(DESCRIPTOR), | ||
| 246 | accessToken("replacement-access-token"), | ||
| 247 | websocketTicket("replacement-ticket"), | ||
| 248 | ], | ||
| 249 | }); | ||
| 250 | |||
| 251 | const authorized = yield* Effect.gen(function* () { | ||
| 252 | const remote = yield* RemoteEnvironmentAuthorization; | ||
| 253 | return yield* remote.authorizeDpop({ | ||
| 254 | expectedEnvironmentId: ENVIRONMENT_ID, | ||
| 255 | obtainBootstrap: harness.obtainBootstrap, | ||
| 256 | }); | ||
| 257 | }).pipe(Effect.provide(harness.layer)); | ||
| 258 | |||
| 259 | expect(authorized.socketUrl).toContain("wsTicket=replacement-ticket"); | ||
| 260 | expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); | ||
| 261 | expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( | ||
| 262 | expect.objectContaining({ | ||
| 263 | accessToken: "replacement-access-token", | ||
| 264 | }), | ||
| 265 | ); | ||
| 266 | expect(harness.fetch.calls).toHaveLength(4); | ||
| 267 | }), | ||
| 268 | ); | ||
| 269 | |||
| 270 | it.effect("refreshes a cached endpoint after consecutive transient failures", () => | ||
| 271 | Effect.gen(function* () { | ||
| 272 | const cached = new RemoteDpopAccessToken({ | ||
| 273 | environmentId: ENVIRONMENT_ID, | ||
| 274 | label: DESCRIPTOR.label, | ||
| 275 | endpoint: ENDPOINT, | ||
| 276 | accessToken: "cached-access-token", | ||
| 277 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | ||
| 278 | dpopThumbprint: "thumbprint-1", | ||
| 279 | }); | ||
| 280 | const harness = yield* makeHarness({ | ||
| 281 | initialToken: cached, | ||
| 282 | responses: [ | ||
| 283 | new Response("endpoint unavailable", { status: 503 }), | ||
| 284 | new Response("endpoint still unavailable", { status: 503 }), | ||
| 285 | Response.json(DESCRIPTOR), | ||
| 286 | accessToken("replacement-access-token"), | ||
| 287 | websocketTicket("replacement-ticket"), | ||
| 288 | ], | ||
| 289 | }); | ||
| 290 | |||
| 291 | const authorized = yield* Effect.gen(function* () { | ||
| 292 | const remote = yield* RemoteEnvironmentAuthorization; | ||
| 293 | const firstFailure = yield* remote | ||
| 294 | .authorizeDpop({ | ||
| 295 | expectedEnvironmentId: ENVIRONMENT_ID, | ||
| 296 | obtainBootstrap: harness.obtainBootstrap, | ||
| 297 | }) | ||
| 298 | .pipe(Effect.flip); | ||
| 299 | |||
| 300 | expect(firstFailure._tag).toBe("ConnectionTransientError"); | ||
| 301 | expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); | ||
| 302 | expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toBe(cached); | ||
| 303 | |||
| 304 | return yield* remote.authorizeDpop({ | ||
| 305 | expectedEnvironmentId: ENVIRONMENT_ID, | ||
| 306 | obtainBootstrap: harness.obtainBootstrap, | ||
| 307 | }); | ||
| 308 | }).pipe(Effect.provide(harness.layer)); | ||
| 309 | |||
| 310 | expect(authorized.socketUrl).toContain("wsTicket=replacement-ticket"); | ||
| 311 | expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); | ||
| 312 | expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( | ||
| 313 | expect.objectContaining({ | ||
| 314 | accessToken: "replacement-access-token", | ||
| 315 | }), | ||
| 316 | ); | ||
| 317 | expect(harness.fetch.calls).toHaveLength(5); | ||
| 318 | }), | ||
| 319 | ); | ||
| 320 | |||
| 321 | it.effect("does not persist a refreshed token until its websocket ticket succeeds", () => | ||
| 322 | Effect.gen(function* () { | ||
| 323 | const harness = yield* makeHarness({ | ||
| 324 | responses: [ | ||
| 325 | Response.json(DESCRIPTOR), | ||
| 326 | accessToken("unusable-access-token"), | ||
| 327 | new Response("endpoint unavailable", { status: 503 }), | ||
| 328 | ], | ||
| 329 | }); | ||
| 330 | |||
| 331 | yield* Effect.gen(function* () { | ||
| 332 | const remote = yield* RemoteEnvironmentAuthorization; | ||
| 333 | return yield* remote.authorizeDpop({ | ||
| 334 | expectedEnvironmentId: ENVIRONMENT_ID, | ||
| 335 | obtainBootstrap: harness.obtainBootstrap, | ||
| 336 | }); | ||
| 337 | }).pipe(Effect.provide(harness.layer), Effect.flip); | ||
| 338 | |||
| 339 | expect((yield* Ref.get(harness.tokens)).has(ENVIRONMENT_ID)).toBe(false); | ||
| 340 | expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); | ||
| 341 | expect(harness.fetch.calls).toHaveLength(3); | ||
| 342 | }), | ||
| 343 | ); | ||
| 344 | }); | ||
packages/client-runtime/src/authorization/layer.ts created+268| ... | @@ -0,0 +1,268 @@ | ||
| 1 | import { | ||
| 2 | exchangeRemoteDpopAccessToken, | ||
| 3 | type RemoteEnvironmentAuthError, | ||
| 4 | resolveRemoteDpopWebSocketConnectionUrl, | ||
| 5 | resolveRemoteWebSocketConnectionUrl, | ||
| 6 | } from "./remote.ts"; | ||
| 7 | import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; | ||
| 8 | import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; | ||
| 9 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | ||
| 10 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | ||
| 11 | import { ClientPresentation } from "../platform/capabilities.ts"; | ||
| 12 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; | ||
| 13 | import { RemoteEnvironmentAuthorization } from "./service.ts"; | ||
| 14 | import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; | ||
| 15 | import * as Clock from "effect/Clock"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as Option from "effect/Option"; | ||
| 19 | import * as Ref from "effect/Ref"; | ||
| 20 | import * as Result from "effect/Result"; | ||
| 21 | import { HttpClient } from "effect/unstable/http"; | ||
| 22 | |||
| 23 | const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; | ||
| 24 | const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; | ||
| 25 | |||
| 26 | function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { | ||
| 27 | return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" | ||
| 28 | ? error | ||
| 29 | : mapRemoteEnvironmentError(error); | ||
| 30 | } | ||
| 31 | |||
| 32 | const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( | ||
| 33 | httpBaseUrl: string, | ||
| 34 | ) { | ||
| 35 | return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( | ||
| 36 | Effect.mapError(mapRemoteEnvironmentError), | ||
| 37 | ); | ||
| 38 | }); | ||
| 39 | |||
| 40 | export const remoteEnvironmentAuthorizationLayer = Layer.effect( | ||
| 41 | RemoteEnvironmentAuthorization, | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const signer = yield* ManagedRelayDpopSigner; | ||
| 44 | const presentation = yield* ClientPresentation; | ||
| 45 | const tokenStore = yield* RemoteDpopAccessTokenStore; | ||
| 46 | const httpClient = yield* HttpClient.HttpClient; | ||
| 47 | const cachedEndpointFailures = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); | ||
| 48 | |||
| 49 | const resetCachedEndpointFailures = (environmentId: string) => | ||
| 50 | Ref.update(cachedEndpointFailures, (current) => { | ||
| 51 | if (!current.has(environmentId)) { | ||
| 52 | return current; | ||
| 53 | } | ||
| 54 | const next = new Map(current); | ||
| 55 | next.delete(environmentId); | ||
| 56 | return next; | ||
| 57 | }); | ||
| 58 | |||
| 59 | const recordCachedEndpointFailure = (environmentId: string) => | ||
| 60 | Ref.modify(cachedEndpointFailures, (current) => { | ||
| 61 | const failureCount = (current.get(environmentId) ?? 0) + 1; | ||
| 62 | const next = new Map(current); | ||
| 63 | next.set(environmentId, failureCount); | ||
| 64 | return [failureCount, next] as const; | ||
| 65 | }); | ||
| 66 | |||
| 67 | const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( | ||
| 68 | function* (input: { | ||
| 69 | readonly expectedEnvironmentId: Parameters< | ||
| 70 | RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] | ||
| 71 | >[0]["expectedEnvironmentId"]; | ||
| 72 | readonly httpBaseUrl: string; | ||
| 73 | readonly wsBaseUrl: string; | ||
| 74 | readonly bearerToken: string; | ||
| 75 | }) { | ||
| 76 | const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( | ||
| 77 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 78 | ); | ||
| 79 | if (descriptor.environmentId !== input.expectedEnvironmentId) { | ||
| 80 | return yield* environmentMismatchError({ | ||
| 81 | expected: input.expectedEnvironmentId, | ||
| 82 | actual: descriptor.environmentId, | ||
| 83 | }); | ||
| 84 | } | ||
| 85 | const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ | ||
| 86 | wsBaseUrl: input.wsBaseUrl, | ||
| 87 | httpBaseUrl: input.httpBaseUrl, | ||
| 88 | bearerToken: input.bearerToken, | ||
| 89 | }).pipe( | ||
| 90 | Effect.mapError(mapRemoteEnvironmentError), | ||
| 91 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 92 | ); | ||
| 93 | return { | ||
| 94 | environmentId: descriptor.environmentId, | ||
| 95 | label: descriptor.label, | ||
| 96 | httpBaseUrl: input.httpBaseUrl, | ||
| 97 | socketUrl, | ||
| 98 | httpAuthorization: { | ||
| 99 | _tag: "Bearer" as const, | ||
| 100 | token: input.bearerToken, | ||
| 101 | }, | ||
| 102 | }; | ||
| 103 | }, | ||
| 104 | ); | ||
| 105 | |||
| 106 | const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( | ||
| 107 | function* (token: RemoteDpopAccessToken) { | ||
| 108 | const ticketProof = yield* signer | ||
| 109 | .createProof({ | ||
| 110 | method: "POST", | ||
| 111 | url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), | ||
| 112 | accessToken: token.accessToken, | ||
| 113 | }) | ||
| 114 | .pipe( | ||
| 115 | Effect.mapError( | ||
| 116 | () => | ||
| 117 | new ConnectionBlockedError({ | ||
| 118 | reason: "configuration", | ||
| 119 | message: "Could not create the websocket authorization proof.", | ||
| 120 | }), | ||
| 121 | ), | ||
| 122 | ); | ||
| 123 | return yield* resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 124 | wsBaseUrl: token.endpoint.wsBaseUrl, | ||
| 125 | httpBaseUrl: token.endpoint.httpBaseUrl, | ||
| 126 | accessToken: token.accessToken, | ||
| 127 | dpopProof: ticketProof, | ||
| 128 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); | ||
| 129 | }, | ||
| 130 | ); | ||
| 131 | |||
| 132 | const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( | ||
| 133 | function* (input: { | ||
| 134 | readonly expectedEnvironmentId: Parameters< | ||
| 135 | RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] | ||
| 136 | >[0]["expectedEnvironmentId"]; | ||
| 137 | readonly obtainBootstrap: Parameters< | ||
| 138 | RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] | ||
| 139 | >[0]["obtainBootstrap"]; | ||
| 140 | }) { | ||
| 141 | const thumbprint = yield* signer.thumbprint.pipe( | ||
| 142 | Effect.mapError( | ||
| 143 | () => | ||
| 144 | new ConnectionBlockedError({ | ||
| 145 | reason: "configuration", | ||
| 146 | message: "Could not load the environment authorization key.", | ||
| 147 | }), | ||
| 148 | ), | ||
| 149 | Effect.withSpan("environment.authorization.dpopKey.resolve"), | ||
| 150 | ); | ||
| 151 | const now = yield* Clock.currentTimeMillis; | ||
| 152 | const cached = yield* tokenStore | ||
| 153 | .get(input.expectedEnvironmentId) | ||
| 154 | .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); | ||
| 155 | if ( | ||
| 156 | Option.isSome(cached) && | ||
| 157 | cached.value.environmentId === input.expectedEnvironmentId && | ||
| 158 | cached.value.dpopThumbprint === thumbprint && | ||
| 159 | cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS | ||
| 160 | ) { | ||
| 161 | yield* Effect.annotateCurrentSpan({ | ||
| 162 | "connection.remote_token_cache": "hit", | ||
| 163 | }); | ||
| 164 | const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); | ||
| 165 | if (Result.isSuccess(cachedSocket)) { | ||
| 166 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | ||
| 167 | return { | ||
| 168 | environmentId: cached.value.environmentId, | ||
| 169 | label: cached.value.label, | ||
| 170 | httpBaseUrl: cached.value.endpoint.httpBaseUrl, | ||
| 171 | socketUrl: cachedSocket.success, | ||
| 172 | httpAuthorization: { | ||
| 173 | _tag: "Dpop" as const, | ||
| 174 | accessToken: cached.value.accessToken, | ||
| 175 | }, | ||
| 176 | }; | ||
| 177 | } | ||
| 178 | if (cachedSocket.failure._tag === "ConnectionBlockedError") { | ||
| 179 | return yield* mapDpopSocketError(cachedSocket.failure); | ||
| 180 | } | ||
| 181 | const mappedFailure = mapDpopSocketError(cachedSocket.failure); | ||
| 182 | if (mappedFailure._tag === "ConnectionTransientError") { | ||
| 183 | const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); | ||
| 184 | if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { | ||
| 185 | return yield* mappedFailure; | ||
| 186 | } | ||
| 187 | } | ||
| 188 | yield* tokenStore | ||
| 189 | .remove(input.expectedEnvironmentId) | ||
| 190 | .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); | ||
| 191 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | ||
| 192 | } | ||
| 193 | |||
| 194 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | ||
| 195 | yield* Effect.annotateCurrentSpan({ | ||
| 196 | "connection.remote_token_cache": "miss", | ||
| 197 | }); | ||
| 198 | const bootstrap = yield* input.obtainBootstrap; | ||
| 199 | const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( | ||
| 200 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 201 | Effect.withSpan("environment.authorization.descriptor"), | ||
| 202 | ); | ||
| 203 | if (descriptor.environmentId !== input.expectedEnvironmentId) { | ||
| 204 | return yield* environmentMismatchError({ | ||
| 205 | expected: input.expectedEnvironmentId, | ||
| 206 | actual: descriptor.environmentId, | ||
| 207 | }); | ||
| 208 | } | ||
| 209 | const bootstrapProof = yield* signer | ||
| 210 | .createProof({ | ||
| 211 | method: "POST", | ||
| 212 | url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), | ||
| 213 | }) | ||
| 214 | .pipe( | ||
| 215 | Effect.mapError( | ||
| 216 | () => | ||
| 217 | new ConnectionBlockedError({ | ||
| 218 | reason: "configuration", | ||
| 219 | message: "Could not create the environment authorization proof.", | ||
| 220 | }), | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | const access = yield* exchangeRemoteDpopAccessToken({ | ||
| 224 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, | ||
| 225 | credential: bootstrap.credential, | ||
| 226 | dpopProof: bootstrapProof, | ||
| 227 | scopes: presentation.scopes, | ||
| 228 | clientMetadata: presentation.metadata, | ||
| 229 | }).pipe( | ||
| 230 | Effect.mapError(mapRemoteEnvironmentError), | ||
| 231 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 232 | Effect.withSpan("environment.authorization.accessToken.exchange"), | ||
| 233 | ); | ||
| 234 | const issuedAt = yield* Clock.currentTimeMillis; | ||
| 235 | const token = new RemoteDpopAccessToken({ | ||
| 236 | environmentId: descriptor.environmentId, | ||
| 237 | label: descriptor.label, | ||
| 238 | endpoint: bootstrap.endpoint, | ||
| 239 | accessToken: access.access_token, | ||
| 240 | expiresAtEpochMs: issuedAt + access.expires_in * 1_000, | ||
| 241 | dpopThumbprint: thumbprint, | ||
| 242 | }); | ||
| 243 | const socketUrl = yield* createDpopSocketUrl(token).pipe( | ||
| 244 | Effect.mapError(mapDpopSocketError), | ||
| 245 | ); | ||
| 246 | yield* tokenStore | ||
| 247 | .put(token) | ||
| 248 | .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); | ||
| 249 | return { | ||
| 250 | environmentId: descriptor.environmentId, | ||
| 251 | label: descriptor.label, | ||
| 252 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, | ||
| 253 | socketUrl, | ||
| 254 | httpAuthorization: { | ||
| 255 | _tag: "Dpop" as const, | ||
| 256 | accessToken: token.accessToken, | ||
| 257 | }, | ||
| 258 | }; | ||
| 259 | }, | ||
| 260 | ); | ||
| 261 | |||
| 262 | return RemoteEnvironmentAuthorization.of({ | ||
| 263 | authorizeBearer, | ||
| 264 | authorizeDpop: (input) => | ||
| 265 | authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), | ||
| 266 | }); | ||
| 267 | }), | ||
| 268 | ); | ||
packages/client-runtime/src/authorization/remote.test.ts created+475| ... | @@ -0,0 +1,475 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import * as TestClock from "effect/testing/TestClock"; | ||
| 7 | |||
| 8 | import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; | ||
| 9 | import { | ||
| 10 | bootstrapRemoteBearerSession, | ||
| 11 | exchangeRemoteDpopAccessToken, | ||
| 12 | fetchRemoteDpopSessionState, | ||
| 13 | fetchRemoteSessionState, | ||
| 14 | issueRemoteDpopWebSocketTicket, | ||
| 15 | issueRemoteWebSocketTicket, | ||
| 16 | RemoteEnvironmentAuthInvalidJsonError, | ||
| 17 | RemoteEnvironmentAuthTimeoutError, | ||
| 18 | resolveRemoteWebSocketConnectionUrl, | ||
| 19 | } from "./remote.ts"; | ||
| 20 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | ||
| 21 | import { remoteHttpClientLayer } from "../rpc/http.ts"; | ||
| 22 | |||
| 23 | const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); | ||
| 24 | |||
| 25 | type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; | ||
| 26 | |||
| 27 | const recordedFetch = (...responses: ReadonlyArray<Response>) => { | ||
| 28 | const calls: Array<FetchCall> = []; | ||
| 29 | let responseIndex = 0; | ||
| 30 | const fetchFn = ((input, init) => { | ||
| 31 | calls.push([input, init ?? {}]); | ||
| 32 | const response = responses[responseIndex++]; | ||
| 33 | if (!response) { | ||
| 34 | return Promise.reject(new Error("Unexpected fetch call")); | ||
| 35 | } | ||
| 36 | return Promise.resolve(response); | ||
| 37 | }) satisfies typeof fetch; | ||
| 38 | |||
| 39 | return { fetchFn, calls }; | ||
| 40 | }; | ||
| 41 | |||
| 42 | const hangingFetch = () => { | ||
| 43 | const calls: Array<FetchCall> = []; | ||
| 44 | const fetchFn = ((input, init) => { | ||
| 45 | calls.push([input, init ?? {}]); | ||
| 46 | return new Promise<Response>(() => undefined); | ||
| 47 | }) satisfies typeof fetch; | ||
| 48 | |||
| 49 | return { fetchFn, calls }; | ||
| 50 | }; | ||
| 51 | |||
| 52 | const provideRemoteHttp = (fetchFn: typeof fetch) => Effect.provide(remoteHttpClientLayer(fetchFn)); | ||
| 53 | |||
| 54 | const expectFetchCall = ( | ||
| 55 | calls: ReadonlyArray<FetchCall>, | ||
| 56 | index: number, | ||
| 57 | expected: { | ||
| 58 | readonly url: string; | ||
| 59 | readonly method: string; | ||
| 60 | readonly headers?: Record<string, string>; | ||
| 61 | readonly body?: string; | ||
| 62 | }, | ||
| 63 | ): void => { | ||
| 64 | const call = calls[index - 1]; | ||
| 65 | expect(call).toBeDefined(); | ||
| 66 | if (!call) { | ||
| 67 | return; | ||
| 68 | } | ||
| 69 | |||
| 70 | const [url, init] = call; | ||
| 71 | expect(String(url)).toBe(expected.url); | ||
| 72 | expect(init).toEqual( | ||
| 73 | expect.objectContaining({ | ||
| 74 | method: expected.method, | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | expect(init.headers).toEqual(expect.objectContaining(expected.headers ?? {})); | ||
| 78 | |||
| 79 | if ("body" in expected) { | ||
| 80 | const body = init.body; | ||
| 81 | if (typeof body === "string") { | ||
| 82 | expect(body).toBe(expected.body); | ||
| 83 | } else if (body instanceof Uint8Array) { | ||
| 84 | expect(new TextDecoder().decode(body)).toBe(expected.body); | ||
| 85 | } else { | ||
| 86 | throw new Error("Expected fetch request body"); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | }; | ||
| 90 | |||
| 91 | describe("remote environment authorization", () => { | ||
| 92 | it.effect("bootstraps bearer auth against a remote backend", () => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const fetch = recordedFetch( | ||
| 95 | Response.json( | ||
| 96 | { | ||
| 97 | access_token: "bearer-token", | ||
| 98 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 99 | token_type: "Bearer", | ||
| 100 | expires_in: 3600, | ||
| 101 | scope: | ||
| 102 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 103 | }, | ||
| 104 | { status: 200 }, | ||
| 105 | ), | ||
| 106 | ); | ||
| 107 | |||
| 108 | const result = yield* bootstrapRemoteBearerSession({ | ||
| 109 | httpBaseUrl: "https://remote.example.com/", | ||
| 110 | credential: "pairing-token", | ||
| 111 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 112 | |||
| 113 | expect(result).toMatchObject({ | ||
| 114 | token_type: "Bearer", | ||
| 115 | access_token: "bearer-token", | ||
| 116 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 117 | }); | ||
| 118 | expectFetchCall(fetch.calls, 1, { | ||
| 119 | url: "https://remote.example.com/oauth/token", | ||
| 120 | method: "POST", | ||
| 121 | headers: { | ||
| 122 | "content-type": "application/x-www-form-urlencoded", | ||
| 123 | }, | ||
| 124 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token", | ||
| 125 | }); | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | |||
| 129 | it.effect("exchanges managed credentials and admits websocket requests with DPoP", () => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const fetch = recordedFetch( | ||
| 132 | Response.json({ | ||
| 133 | access_token: "dpop-access-token", | ||
| 134 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 135 | token_type: "DPoP", | ||
| 136 | expires_in: 3600, | ||
| 137 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 138 | }), | ||
| 139 | Response.json({ | ||
| 140 | ticket: "ws-ticket", | ||
| 141 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | |||
| 145 | const token = yield* exchangeRemoteDpopAccessToken({ | ||
| 146 | httpBaseUrl: "https://remote.example.com/", | ||
| 147 | credential: "one-time-credential", | ||
| 148 | dpopProof: "token-proof", | ||
| 149 | clientMetadata: { | ||
| 150 | label: "T3 Code Mobile", | ||
| 151 | deviceType: "mobile", | ||
| 152 | os: "iOS", | ||
| 153 | }, | ||
| 154 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 155 | yield* issueRemoteDpopWebSocketTicket({ | ||
| 156 | httpBaseUrl: "https://remote.example.com/", | ||
| 157 | accessToken: token.access_token, | ||
| 158 | dpopProof: "resource-proof", | ||
| 159 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 160 | |||
| 161 | expectFetchCall(fetch.calls, 1, { | ||
| 162 | url: "https://remote.example.com/oauth/token", | ||
| 163 | method: "POST", | ||
| 164 | headers: { dpop: "token-proof", "content-type": "application/x-www-form-urlencoded" }, | ||
| 165 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=one-time-credential&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS", | ||
| 166 | }); | ||
| 167 | expectFetchCall(fetch.calls, 2, { | ||
| 168 | url: "https://remote.example.com/api/auth/websocket-ticket", | ||
| 169 | method: "POST", | ||
| 170 | headers: { | ||
| 171 | authorization: "DPoP dpop-access-token", | ||
| 172 | dpop: "resource-proof", | ||
| 173 | }, | ||
| 174 | }); | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | |||
| 178 | it.effect("submits optional client display metadata during bearer token exchange", () => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const fetch = recordedFetch( | ||
| 181 | Response.json( | ||
| 182 | { | ||
| 183 | access_token: "bearer-token", | ||
| 184 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 185 | token_type: "Bearer", | ||
| 186 | expires_in: 3600, | ||
| 187 | scope: | ||
| 188 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 189 | }, | ||
| 190 | { status: 200 }, | ||
| 191 | ), | ||
| 192 | ); | ||
| 193 | |||
| 194 | yield* bootstrapRemoteBearerSession({ | ||
| 195 | httpBaseUrl: "https://remote.example.com/", | ||
| 196 | credential: "pairing-token", | ||
| 197 | clientMetadata: { | ||
| 198 | label: "T3 Code Mobile", | ||
| 199 | deviceType: "mobile", | ||
| 200 | os: "iOS", | ||
| 201 | }, | ||
| 202 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 203 | |||
| 204 | expectFetchCall(fetch.calls, 1, { | ||
| 205 | url: "https://remote.example.com/oauth/token", | ||
| 206 | method: "POST", | ||
| 207 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS", | ||
| 208 | }); | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | |||
| 212 | it.effect("allows a client to explicitly narrow a pairing grant", () => | ||
| 213 | Effect.gen(function* () { | ||
| 214 | const fetch = recordedFetch( | ||
| 215 | Response.json( | ||
| 216 | { | ||
| 217 | access_token: "read-only-token", | ||
| 218 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 219 | token_type: "Bearer", | ||
| 220 | expires_in: 3600, | ||
| 221 | scope: "orchestration:read", | ||
| 222 | }, | ||
| 223 | { status: 200 }, | ||
| 224 | ), | ||
| 225 | ); | ||
| 226 | |||
| 227 | yield* bootstrapRemoteBearerSession({ | ||
| 228 | httpBaseUrl: "https://remote.example.com/", | ||
| 229 | credential: "pairing-token", | ||
| 230 | scopes: ["orchestration:read"], | ||
| 231 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 232 | |||
| 233 | expectFetchCall(fetch.calls, 1, { | ||
| 234 | url: "https://remote.example.com/oauth/token", | ||
| 235 | method: "POST", | ||
| 236 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=orchestration%3Aread", | ||
| 237 | }); | ||
| 238 | }), | ||
| 239 | ); | ||
| 240 | |||
| 241 | it.effect("loads remote session state and websocket tickets over bearer auth", () => | ||
| 242 | Effect.gen(function* () { | ||
| 243 | const fetch = recordedFetch( | ||
| 244 | Response.json( | ||
| 245 | { | ||
| 246 | environmentId: "environment-remote", | ||
| 247 | label: "Remote environment", | ||
| 248 | platform: { | ||
| 249 | os: "linux", | ||
| 250 | arch: "x64", | ||
| 251 | }, | ||
| 252 | serverVersion: "0.0.0-test", | ||
| 253 | capabilities: { | ||
| 254 | repositoryIdentity: true, | ||
| 255 | }, | ||
| 256 | }, | ||
| 257 | { status: 200 }, | ||
| 258 | ), | ||
| 259 | Response.json( | ||
| 260 | { | ||
| 261 | authenticated: true, | ||
| 262 | auth: { | ||
| 263 | policy: "remote-reachable", | ||
| 264 | bootstrapMethods: ["one-time-token"], | ||
| 265 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | ||
| 266 | sessionCookieName: "t3_session", | ||
| 267 | }, | ||
| 268 | scopes: [ | ||
| 269 | "orchestration:read", | ||
| 270 | "orchestration:operate", | ||
| 271 | "terminal:operate", | ||
| 272 | "review:write", | ||
| 273 | "relay:read", | ||
| 274 | ], | ||
| 275 | sessionMethod: "bearer-access-token", | ||
| 276 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 277 | }, | ||
| 278 | { status: 200 }, | ||
| 279 | ), | ||
| 280 | Response.json( | ||
| 281 | { | ||
| 282 | ticket: "ws-ticket", | ||
| 283 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 284 | }, | ||
| 285 | { status: 200 }, | ||
| 286 | ), | ||
| 287 | ); | ||
| 288 | |||
| 289 | const environment = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 290 | httpBaseUrl: "https://remote.example.com/", | ||
| 291 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 292 | expect(environment).toMatchObject({ | ||
| 293 | environmentId: "environment-remote", | ||
| 294 | label: "Remote environment", | ||
| 295 | }); | ||
| 296 | |||
| 297 | const session = yield* fetchRemoteSessionState({ | ||
| 298 | httpBaseUrl: "https://remote.example.com/", | ||
| 299 | bearerToken: "bearer-token", | ||
| 300 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 301 | expect(session).toMatchObject({ | ||
| 302 | authenticated: true, | ||
| 303 | scopes: [ | ||
| 304 | "orchestration:read", | ||
| 305 | "orchestration:operate", | ||
| 306 | "terminal:operate", | ||
| 307 | "review:write", | ||
| 308 | "relay:read", | ||
| 309 | ], | ||
| 310 | }); | ||
| 311 | |||
| 312 | const ticket = yield* issueRemoteWebSocketTicket({ | ||
| 313 | httpBaseUrl: "https://remote.example.com/", | ||
| 314 | bearerToken: "bearer-token", | ||
| 315 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 316 | expect(ticket).toMatchObject({ | ||
| 317 | ticket: "ws-ticket", | ||
| 318 | }); | ||
| 319 | |||
| 320 | expectFetchCall(fetch.calls, 1, { | ||
| 321 | url: "https://remote.example.com/.well-known/t3/environment", | ||
| 322 | method: "GET", | ||
| 323 | }); | ||
| 324 | expectFetchCall(fetch.calls, 2, { | ||
| 325 | url: "https://remote.example.com/api/auth/session", | ||
| 326 | method: "GET", | ||
| 327 | headers: { | ||
| 328 | authorization: "Bearer bearer-token", | ||
| 329 | }, | ||
| 330 | }); | ||
| 331 | expectFetchCall(fetch.calls, 3, { | ||
| 332 | url: "https://remote.example.com/api/auth/websocket-ticket", | ||
| 333 | method: "POST", | ||
| 334 | headers: { | ||
| 335 | authorization: "Bearer bearer-token", | ||
| 336 | }, | ||
| 337 | }); | ||
| 338 | }), | ||
| 339 | ); | ||
| 340 | |||
| 341 | it.effect("loads remote session state with a DPoP-bound access token", () => | ||
| 342 | Effect.gen(function* () { | ||
| 343 | const fetch = recordedFetch( | ||
| 344 | Response.json({ | ||
| 345 | authenticated: true, | ||
| 346 | auth: { | ||
| 347 | policy: "remote-reachable", | ||
| 348 | bootstrapMethods: ["one-time-token"], | ||
| 349 | sessionMethods: ["dpop-access-token"], | ||
| 350 | sessionCookieName: "t3_session", | ||
| 351 | }, | ||
| 352 | sessionMethod: "dpop-access-token", | ||
| 353 | scopes: [ | ||
| 354 | "orchestration:read", | ||
| 355 | "orchestration:operate", | ||
| 356 | "terminal:operate", | ||
| 357 | "review:write", | ||
| 358 | ], | ||
| 359 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 360 | }), | ||
| 361 | ); | ||
| 362 | |||
| 363 | yield* fetchRemoteDpopSessionState({ | ||
| 364 | httpBaseUrl: "https://remote.example.com/", | ||
| 365 | accessToken: "dpop-access-token", | ||
| 366 | dpopProof: "dpop-proof", | ||
| 367 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 368 | |||
| 369 | expectFetchCall(fetch.calls, 1, { | ||
| 370 | url: "https://remote.example.com/api/auth/session", | ||
| 371 | method: "GET", | ||
| 372 | headers: { | ||
| 373 | authorization: "DPoP dpop-access-token", | ||
| 374 | dpop: "dpop-proof", | ||
| 375 | }, | ||
| 376 | }); | ||
| 377 | }), | ||
| 378 | ); | ||
| 379 | |||
| 380 | it.effect("fails hung fetch requests on the configured timeout", () => | ||
| 381 | Effect.gen(function* () { | ||
| 382 | const fetch = hangingFetch(); | ||
| 383 | |||
| 384 | const errorFiber = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 385 | httpBaseUrl: "http://remote.example.com/", | ||
| 386 | timeoutMs: 25, | ||
| 387 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip, Effect.forkScoped); | ||
| 388 | yield* Effect.yieldNow; | ||
| 389 | yield* TestClock.adjust(Duration.millis(25)); | ||
| 390 | const error = yield* Fiber.join(errorFiber); | ||
| 391 | |||
| 392 | expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError); | ||
| 393 | expect(error.message).toBe( | ||
| 394 | "Remote environment endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", | ||
| 395 | ); | ||
| 396 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 397 | ); | ||
| 398 | |||
| 399 | it.effect("revives declared typed errors from remote auth failures", () => | ||
| 400 | Effect.gen(function* () { | ||
| 401 | const fetch = recordedFetch( | ||
| 402 | Response.json( | ||
| 403 | { | ||
| 404 | _tag: "EnvironmentAuthInvalidError", | ||
| 405 | code: "auth_invalid", | ||
| 406 | reason: "missing_credential", | ||
| 407 | traceId: "trace-auth-test", | ||
| 408 | }, | ||
| 409 | { status: 401 }, | ||
| 410 | ), | ||
| 411 | ); | ||
| 412 | |||
| 413 | const error = yield* issueRemoteWebSocketTicket({ | ||
| 414 | httpBaseUrl: "https://remote.example.com/", | ||
| 415 | bearerToken: "expired-token", | ||
| 416 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip); | ||
| 417 | |||
| 418 | expect(isEnvironmentAuthInvalidError(error)).toBe(true); | ||
| 419 | if (isEnvironmentAuthInvalidError(error)) { | ||
| 420 | expect(error.reason).toBe("missing_credential"); | ||
| 421 | expect(error.traceId).toBe("trace-auth-test"); | ||
| 422 | } | ||
| 423 | }), | ||
| 424 | ); | ||
| 425 | |||
| 426 | it.effect("classifies malformed successful remote auth responses as invalid responses", () => | ||
| 427 | Effect.gen(function* () { | ||
| 428 | const fetch = recordedFetch( | ||
| 429 | Response.json( | ||
| 430 | { | ||
| 431 | access_token: "", | ||
| 432 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 433 | token_type: "Bearer", | ||
| 434 | expires_in: 3600, | ||
| 435 | scope: | ||
| 436 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 437 | }, | ||
| 438 | { status: 200 }, | ||
| 439 | ), | ||
| 440 | ); | ||
| 441 | |||
| 442 | const error = yield* bootstrapRemoteBearerSession({ | ||
| 443 | httpBaseUrl: "https://remote.example.com/", | ||
| 444 | credential: "pairing-token", | ||
| 445 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip); | ||
| 446 | |||
| 447 | expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError); | ||
| 448 | expect(error.message).toBe( | ||
| 449 | "Remote environment endpoint returned an invalid response from https://remote.example.com/oauth/token.", | ||
| 450 | ); | ||
| 451 | }), | ||
| 452 | ); | ||
| 453 | |||
| 454 | it.effect("mints a websocket url that targets the rpc route with a short-lived ticket", () => | ||
| 455 | Effect.gen(function* () { | ||
| 456 | const fetch = recordedFetch( | ||
| 457 | Response.json( | ||
| 458 | { | ||
| 459 | ticket: "ws-ticket", | ||
| 460 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 461 | }, | ||
| 462 | { status: 200 }, | ||
| 463 | ), | ||
| 464 | ); | ||
| 465 | |||
| 466 | const url = yield* resolveRemoteWebSocketConnectionUrl({ | ||
| 467 | wsBaseUrl: "wss://remote.example.com/", | ||
| 468 | httpBaseUrl: "https://remote.example.com/", | ||
| 469 | bearerToken: "bearer-token", | ||
| 470 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 471 | |||
| 472 | expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); | ||
| 473 | }), | ||
| 474 | ); | ||
| 475 | }); | ||
packages/client-runtime/src/authorization/remote.ts created+214| ... | @@ -0,0 +1,214 @@ | ||
| 1 | import { | ||
| 2 | AuthAccessTokenType, | ||
| 3 | type AuthClientPresentationMetadata, | ||
| 4 | AuthEnvironmentBootstrapTokenType, | ||
| 5 | AuthTokenExchangeGrantType, | ||
| 6 | type AuthEnvironmentScope, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | ||
| 11 | import { | ||
| 12 | executeEnvironmentHttpRequest, | ||
| 13 | makeEnvironmentHttpApiClient, | ||
| 14 | type RemoteEnvironmentRequestError, | ||
| 15 | } from "../rpc/http.ts"; | ||
| 16 | |||
| 17 | export { | ||
| 18 | RemoteEnvironmentAuthFetchError, | ||
| 19 | RemoteEnvironmentAuthInvalidJsonError, | ||
| 20 | RemoteEnvironmentAuthTimeoutError, | ||
| 21 | RemoteEnvironmentAuthUndeclaredStatusError, | ||
| 22 | } from "../rpc/http.ts"; | ||
| 23 | export type RemoteEnvironmentAuthError = RemoteEnvironmentRequestError; | ||
| 24 | |||
| 25 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; | ||
| 26 | |||
| 27 | const clientMetadataTokenExchangeFields = ( | ||
| 28 | clientMetadata: AuthClientPresentationMetadata | undefined, | ||
| 29 | ) => ({ | ||
| 30 | ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), | ||
| 31 | ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), | ||
| 32 | ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), | ||
| 33 | }); | ||
| 34 | |||
| 35 | export const exchangeRemoteDpopAccessToken = Effect.fn( | ||
| 36 | "clientRuntime.authorization.exchangeRemoteDpopAccessToken", | ||
| 37 | )(function* (input: { | ||
| 38 | readonly httpBaseUrl: string; | ||
| 39 | readonly credential: string; | ||
| 40 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 41 | readonly clientMetadata?: AuthClientPresentationMetadata; | ||
| 42 | readonly dpopProof: string; | ||
| 43 | readonly timeoutMs?: number; | ||
| 44 | }) { | ||
| 45 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 46 | const response = yield* executeEnvironmentHttpRequest( | ||
| 47 | environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), | ||
| 48 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 49 | client.auth.token({ | ||
| 50 | headers: { dpop: input.dpopProof }, | ||
| 51 | payload: { | ||
| 52 | grant_type: AuthTokenExchangeGrantType, | ||
| 53 | subject_token: input.credential, | ||
| 54 | subject_token_type: AuthEnvironmentBootstrapTokenType, | ||
| 55 | requested_token_type: AuthAccessTokenType, | ||
| 56 | ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), | ||
| 57 | ...clientMetadataTokenExchangeFields(input.clientMetadata), | ||
| 58 | }, | ||
| 59 | }), | ||
| 60 | ); | ||
| 61 | return response; | ||
| 62 | }); | ||
| 63 | |||
| 64 | export const bootstrapRemoteBearerSession = Effect.fn( | ||
| 65 | "clientRuntime.authorization.bootstrapRemoteBearerSession", | ||
| 66 | )(function* (input: { | ||
| 67 | readonly httpBaseUrl: string; | ||
| 68 | readonly credential: string; | ||
| 69 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 70 | readonly clientMetadata?: AuthClientPresentationMetadata; | ||
| 71 | readonly timeoutMs?: number; | ||
| 72 | }) { | ||
| 73 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 74 | return yield* executeEnvironmentHttpRequest( | ||
| 75 | environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), | ||
| 76 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 77 | client.auth.token({ | ||
| 78 | headers: {}, | ||
| 79 | payload: { | ||
| 80 | grant_type: AuthTokenExchangeGrantType, | ||
| 81 | subject_token: input.credential, | ||
| 82 | subject_token_type: AuthEnvironmentBootstrapTokenType, | ||
| 83 | requested_token_type: AuthAccessTokenType, | ||
| 84 | ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), | ||
| 85 | ...clientMetadataTokenExchangeFields(input.clientMetadata), | ||
| 86 | }, | ||
| 87 | }), | ||
| 88 | ); | ||
| 89 | }); | ||
| 90 | |||
| 91 | export const fetchRemoteSessionState = Effect.fn( | ||
| 92 | "clientRuntime.authorization.fetchRemoteSessionState", | ||
| 93 | )(function* (input: { | ||
| 94 | readonly httpBaseUrl: string; | ||
| 95 | readonly bearerToken: string; | ||
| 96 | readonly timeoutMs?: number; | ||
| 97 | }) { | ||
| 98 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 99 | return yield* executeEnvironmentHttpRequest( | ||
| 100 | environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), | ||
| 101 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 102 | client.auth.session({ | ||
| 103 | headers: { | ||
| 104 | authorization: `Bearer ${input.bearerToken}`, | ||
| 105 | }, | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | }); | ||
| 109 | |||
| 110 | export const fetchRemoteDpopSessionState = Effect.fn( | ||
| 111 | "clientRuntime.authorization.fetchRemoteDpopSessionState", | ||
| 112 | )(function* (input: { | ||
| 113 | readonly httpBaseUrl: string; | ||
| 114 | readonly accessToken: string; | ||
| 115 | readonly dpopProof: string; | ||
| 116 | readonly timeoutMs?: number; | ||
| 117 | }) { | ||
| 118 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 119 | return yield* executeEnvironmentHttpRequest( | ||
| 120 | environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), | ||
| 121 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 122 | client.auth.session({ | ||
| 123 | headers: { | ||
| 124 | authorization: `DPoP ${input.accessToken}`, | ||
| 125 | dpop: input.dpopProof, | ||
| 126 | }, | ||
| 127 | }), | ||
| 128 | ); | ||
| 129 | }); | ||
| 130 | |||
| 131 | export const issueRemoteWebSocketTicket = Effect.fn( | ||
| 132 | "clientRuntime.authorization.issueRemoteWebSocketTicket", | ||
| 133 | )(function* (input: { | ||
| 134 | readonly httpBaseUrl: string; | ||
| 135 | readonly bearerToken: string; | ||
| 136 | readonly timeoutMs?: number; | ||
| 137 | }) { | ||
| 138 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 139 | return yield* executeEnvironmentHttpRequest( | ||
| 140 | environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), | ||
| 141 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 142 | client.auth.webSocketTicket({ | ||
| 143 | headers: { | ||
| 144 | authorization: `Bearer ${input.bearerToken}`, | ||
| 145 | }, | ||
| 146 | }), | ||
| 147 | ); | ||
| 148 | }); | ||
| 149 | |||
| 150 | export const issueRemoteDpopWebSocketTicket = Effect.fn( | ||
| 151 | "clientRuntime.authorization.issueRemoteDpopWebSocketTicket", | ||
| 152 | )(function* (input: { | ||
| 153 | readonly httpBaseUrl: string; | ||
| 154 | readonly accessToken: string; | ||
| 155 | readonly dpopProof: string; | ||
| 156 | readonly timeoutMs?: number; | ||
| 157 | }) { | ||
| 158 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 159 | return yield* executeEnvironmentHttpRequest( | ||
| 160 | environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), | ||
| 161 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 162 | client.auth.webSocketTicket({ | ||
| 163 | headers: { | ||
| 164 | authorization: `DPoP ${input.accessToken}`, | ||
| 165 | dpop: input.dpopProof, | ||
| 166 | }, | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | }); | ||
| 170 | |||
| 171 | export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ||
| 172 | "clientRuntime.authorization.resolveRemoteWebSocketConnectionUrl", | ||
| 173 | )(function* (input: { | ||
| 174 | readonly wsBaseUrl: string; | ||
| 175 | readonly httpBaseUrl: string; | ||
| 176 | readonly bearerToken: string; | ||
| 177 | readonly timeoutMs?: number; | ||
| 178 | }) { | ||
| 179 | const issued = yield* issueRemoteWebSocketTicket({ | ||
| 180 | httpBaseUrl: input.httpBaseUrl, | ||
| 181 | bearerToken: input.bearerToken, | ||
| 182 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 183 | }); | ||
| 184 | |||
| 185 | const url = new URL(input.wsBaseUrl); | ||
| 186 | if (url.pathname === "" || url.pathname === "/") { | ||
| 187 | url.pathname = "/ws"; | ||
| 188 | } | ||
| 189 | url.searchParams.set("wsTicket", issued.ticket); | ||
| 190 | return url.toString(); | ||
| 191 | }); | ||
| 192 | |||
| 193 | export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( | ||
| 194 | "clientRuntime.authorization.resolveRemoteDpopWebSocketConnectionUrl", | ||
| 195 | )(function* (input: { | ||
| 196 | readonly wsBaseUrl: string; | ||
| 197 | readonly httpBaseUrl: string; | ||
| 198 | readonly accessToken: string; | ||
| 199 | readonly dpopProof: string; | ||
| 200 | readonly timeoutMs?: number; | ||
| 201 | }) { | ||
| 202 | const issued = yield* issueRemoteDpopWebSocketTicket({ | ||
| 203 | httpBaseUrl: input.httpBaseUrl, | ||
| 204 | accessToken: input.accessToken, | ||
| 205 | dpopProof: input.dpopProof, | ||
| 206 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 207 | }); | ||
| 208 | const url = new URL(input.wsBaseUrl); | ||
| 209 | if (url.pathname === "" || url.pathname === "/") { | ||
| 210 | url.pathname = "/ws"; | ||
| 211 | } | ||
| 212 | url.searchParams.set("wsTicket", issued.ticket); | ||
| 213 | return url.toString(); | ||
| 214 | }); | ||
packages/client-runtime/src/authorization/service.ts created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import type * as Effect from "effect/Effect"; | ||
| 5 | |||
| 6 | import type { ConnectionAttemptError, PreparedHttpAuthorization } from "../connection/model.ts"; | ||
| 7 | |||
| 8 | export interface RelayEnvironmentAuthorization { | ||
| 9 | readonly environmentId: EnvironmentId; | ||
| 10 | readonly endpoint: RelayManagedEndpoint; | ||
| 11 | readonly credential: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface AuthorizedRemoteEnvironment { | ||
| 15 | readonly environmentId: EnvironmentId; | ||
| 16 | readonly label: string; | ||
| 17 | readonly httpBaseUrl: string; | ||
| 18 | readonly socketUrl: string; | ||
| 19 | readonly httpAuthorization: PreparedHttpAuthorization; | ||
| 20 | } | ||
| 21 | |||
| 22 | export class RemoteEnvironmentAuthorization extends Context.Service< | ||
| 23 | RemoteEnvironmentAuthorization, | ||
| 24 | { | ||
| 25 | readonly authorizeBearer: (input: { | ||
| 26 | readonly expectedEnvironmentId: EnvironmentId; | ||
| 27 | readonly httpBaseUrl: string; | ||
| 28 | readonly wsBaseUrl: string; | ||
| 29 | readonly bearerToken: string; | ||
| 30 | }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>; | ||
| 31 | readonly authorizeDpop: (input: { | ||
| 32 | readonly expectedEnvironmentId: EnvironmentId; | ||
| 33 | readonly obtainBootstrap: Effect.Effect< | ||
| 34 | RelayEnvironmentAuthorization, | ||
| 35 | ConnectionAttemptError | ||
| 36 | >; | ||
| 37 | }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>; | ||
| 38 | } | ||
| 39 | >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} | ||
packages/client-runtime/src/authorization/tokenStore.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { RelayManagedEndpoint } from "@t3tools/contracts/relay"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import type * as Effect from "effect/Effect"; | ||
| 5 | import type * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | |||
| 8 | import type { ConnectionAttemptError } from "../connection/model.ts"; | ||
| 9 | |||
| 10 | export class RemoteDpopAccessToken extends Schema.Class<RemoteDpopAccessToken>( | ||
| 11 | "@t3tools/client-runtime/authorization/RemoteDpopAccessToken", | ||
| 12 | )({ | ||
| 13 | environmentId: EnvironmentId, | ||
| 14 | label: Schema.String, | ||
| 15 | endpoint: RelayManagedEndpoint, | ||
| 16 | accessToken: Schema.String, | ||
| 17 | expiresAtEpochMs: Schema.Number, | ||
| 18 | dpopThumbprint: Schema.String, | ||
| 19 | }) {} | ||
| 20 | |||
| 21 | export class RemoteDpopAccessTokenStore extends Context.Service< | ||
| 22 | RemoteDpopAccessTokenStore, | ||
| 23 | { | ||
| 24 | readonly get: ( | ||
| 25 | environmentId: EnvironmentId, | ||
| 26 | ) => Effect.Effect<Option.Option<RemoteDpopAccessToken>, ConnectionAttemptError>; | ||
| 27 | readonly put: (token: RemoteDpopAccessToken) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 28 | readonly remove: (environmentId: EnvironmentId) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 29 | } | ||
| 30 | >()("@t3tools/client-runtime/authorization/tokenStore/RemoteDpopAccessTokenStore") {} | ||
packages/client-runtime/src/checkpointDiffState.test.ts deleted-135| ... | @@ -1,135 +0,0 @@ | ||
| 1 | import { EnvironmentId, ThreadId, type OrchestrationGetTurnDiffResult } from "@t3tools/contracts"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type CheckpointDiffClient, | ||
| 7 | createCheckpointDiffManager, | ||
| 8 | EMPTY_CHECKPOINT_DIFF_STATE, | ||
| 9 | getCheckpointDiffTargetKey, | ||
| 10 | } from "./checkpointDiffState.ts"; | ||
| 11 | |||
| 12 | let registry = AtomRegistry.make(); | ||
| 13 | |||
| 14 | function resetAtomRegistry() { | ||
| 15 | registry.dispose(); | ||
| 16 | registry = AtomRegistry.make(); | ||
| 17 | } | ||
| 18 | |||
| 19 | const TARGET = { | ||
| 20 | environmentId: EnvironmentId.make("env-local"), | ||
| 21 | threadId: ThreadId.make("thread-1"), | ||
| 22 | fromTurnCount: 1, | ||
| 23 | toTurnCount: 2, | ||
| 24 | ignoreWhitespace: false, | ||
| 25 | }; | ||
| 26 | |||
| 27 | const PATCH_RESULT: OrchestrationGetTurnDiffResult = { | ||
| 28 | threadId: TARGET.threadId, | ||
| 29 | diff: "patch", | ||
| 30 | fromTurnCount: 1, | ||
| 31 | toTurnCount: 2, | ||
| 32 | }; | ||
| 33 | |||
| 34 | function createClient() { | ||
| 35 | return { | ||
| 36 | getTurnDiff: vi.fn(async () => PATCH_RESULT), | ||
| 37 | getFullThreadDiff: vi.fn(async () => PATCH_RESULT), | ||
| 38 | } satisfies CheckpointDiffClient; | ||
| 39 | } | ||
| 40 | |||
| 41 | describe("createCheckpointDiffManager", () => { | ||
| 42 | afterEach(() => { | ||
| 43 | resetAtomRegistry(); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("loads a turn checkpoint diff into atom state", async () => { | ||
| 47 | const client = createClient(); | ||
| 48 | const manager = createCheckpointDiffManager({ | ||
| 49 | getRegistry: () => registry, | ||
| 50 | getClient: () => client, | ||
| 51 | }); | ||
| 52 | |||
| 53 | await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); | ||
| 54 | |||
| 55 | expect(client.getTurnDiff).toHaveBeenCalledWith({ | ||
| 56 | threadId: TARGET.threadId, | ||
| 57 | fromTurnCount: 1, | ||
| 58 | toTurnCount: 2, | ||
| 59 | ignoreWhitespace: false, | ||
| 60 | }); | ||
| 61 | expect(client.getFullThreadDiff).not.toHaveBeenCalled(); | ||
| 62 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 63 | data: PATCH_RESULT, | ||
| 64 | error: null, | ||
| 65 | isPending: false, | ||
| 66 | }); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("loads a full thread diff when the range starts at zero", async () => { | ||
| 70 | const client = createClient(); | ||
| 71 | const manager = createCheckpointDiffManager({ | ||
| 72 | getRegistry: () => registry, | ||
| 73 | getClient: () => client, | ||
| 74 | }); | ||
| 75 | |||
| 76 | await manager.load({ ...TARGET, fromTurnCount: 0 }); | ||
| 77 | |||
| 78 | expect(client.getFullThreadDiff).toHaveBeenCalledWith({ | ||
| 79 | threadId: TARGET.threadId, | ||
| 80 | toTurnCount: 2, | ||
| 81 | ignoreWhitespace: false, | ||
| 82 | }); | ||
| 83 | expect(client.getTurnDiff).not.toHaveBeenCalled(); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("returns empty state for invalid targets", () => { | ||
| 87 | const manager = createCheckpointDiffManager({ | ||
| 88 | getRegistry: () => registry, | ||
| 89 | getClient: () => createClient(), | ||
| 90 | }); | ||
| 91 | |||
| 92 | expect(manager.getSnapshot({ ...TARGET, threadId: null })).toBe(EMPTY_CHECKPOINT_DIFF_STATE); | ||
| 93 | expect(getCheckpointDiffTargetKey({ ...TARGET, threadId: null })).toBeNull(); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("deduplicates in-flight requests and reuses successful cached data", async () => { | ||
| 97 | const client = createClient(); | ||
| 98 | const manager = createCheckpointDiffManager({ | ||
| 99 | getRegistry: () => registry, | ||
| 100 | getClient: () => client, | ||
| 101 | }); | ||
| 102 | |||
| 103 | const first = manager.load(TARGET); | ||
| 104 | const second = manager.load(TARGET); | ||
| 105 | |||
| 106 | expect(first).toBe(second); | ||
| 107 | await first; | ||
| 108 | await manager.load(TARGET); | ||
| 109 | |||
| 110 | expect(client.getTurnDiff).toHaveBeenCalledTimes(1); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("retries temporarily unavailable checkpoint diffs", async () => { | ||
| 114 | let attempts = 0; | ||
| 115 | const client = { | ||
| 116 | getFullThreadDiff: vi.fn(async () => PATCH_RESULT), | ||
| 117 | getTurnDiff: vi.fn(async () => { | ||
| 118 | attempts += 1; | ||
| 119 | if (attempts < 3) { | ||
| 120 | throw new Error("checkpoint is unavailable for turn"); | ||
| 121 | } | ||
| 122 | return PATCH_RESULT; | ||
| 123 | }), | ||
| 124 | } satisfies CheckpointDiffClient; | ||
| 125 | const manager = createCheckpointDiffManager({ | ||
| 126 | getRegistry: () => registry, | ||
| 127 | getClient: () => client, | ||
| 128 | retryDelay: async () => undefined, | ||
| 129 | }); | ||
| 130 | |||
| 131 | await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); | ||
| 132 | |||
| 133 | expect(client.getTurnDiff).toHaveBeenCalledTimes(3); | ||
| 134 | }); | ||
| 135 | }); | ||
packages/client-runtime/src/checkpointDiffState.ts deleted-313| ... | @@ -1,313 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type EnvironmentId, | ||
| 3 | OrchestrationGetFullThreadDiffInput, | ||
| 4 | type OrchestrationGetFullThreadDiffResult, | ||
| 5 | OrchestrationGetTurnDiffInput, | ||
| 6 | type OrchestrationGetTurnDiffResult, | ||
| 7 | type ThreadId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 14 | |||
| 15 | export type CheckpointDiffResult = | ||
| 16 | | OrchestrationGetTurnDiffResult | ||
| 17 | | OrchestrationGetFullThreadDiffResult; | ||
| 18 | |||
| 19 | export interface CheckpointDiffState { | ||
| 20 | readonly data: CheckpointDiffResult | null; | ||
| 21 | readonly error: string | null; | ||
| 22 | readonly isPending: boolean; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface CheckpointDiffTarget { | ||
| 26 | readonly environmentId: EnvironmentId | null; | ||
| 27 | readonly threadId: ThreadId | null; | ||
| 28 | readonly fromTurnCount: number | null; | ||
| 29 | readonly toTurnCount: number | null; | ||
| 30 | readonly ignoreWhitespace: boolean; | ||
| 31 | readonly cacheScope?: string | null; | ||
| 32 | } | ||
| 33 | |||
| 34 | export interface CheckpointDiffClient { | ||
| 35 | readonly getTurnDiff: ( | ||
| 36 | input: OrchestrationGetTurnDiffInput, | ||
| 37 | ) => Promise<OrchestrationGetTurnDiffResult>; | ||
| 38 | readonly getFullThreadDiff: ( | ||
| 39 | input: OrchestrationGetFullThreadDiffInput, | ||
| 40 | ) => Promise<OrchestrationGetFullThreadDiffResult>; | ||
| 41 | } | ||
| 42 | |||
| 43 | export const EMPTY_CHECKPOINT_DIFF_STATE = Object.freeze<CheckpointDiffState>({ | ||
| 44 | data: null, | ||
| 45 | error: null, | ||
| 46 | isPending: false, | ||
| 47 | }); | ||
| 48 | |||
| 49 | const INITIAL_CHECKPOINT_DIFF_STATE = Object.freeze<CheckpointDiffState>({ | ||
| 50 | data: null, | ||
| 51 | error: null, | ||
| 52 | isPending: true, | ||
| 53 | }); | ||
| 54 | |||
| 55 | const knownCheckpointDiffKeys = new Set<string>(); | ||
| 56 | |||
| 57 | export const checkpointDiffStateAtom = Atom.family((key: string) => { | ||
| 58 | knownCheckpointDiffKeys.add(key); | ||
| 59 | return Atom.make(INITIAL_CHECKPOINT_DIFF_STATE).pipe( | ||
| 60 | Atom.keepAlive, | ||
| 61 | Atom.withLabel(`checkpoint-diff:${key}`), | ||
| 62 | ); | ||
| 63 | }); | ||
| 64 | |||
| 65 | export const EMPTY_CHECKPOINT_DIFF_ATOM = Atom.make(EMPTY_CHECKPOINT_DIFF_STATE).pipe( | ||
| 66 | Atom.keepAlive, | ||
| 67 | Atom.withLabel("checkpoint-diff:null"), | ||
| 68 | ); | ||
| 69 | |||
| 70 | const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput); | ||
| 71 | const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput); | ||
| 72 | |||
| 73 | type CheckpointDiffRequest = | ||
| 74 | | { | ||
| 75 | readonly kind: "fullThreadDiff"; | ||
| 76 | readonly input: OrchestrationGetFullThreadDiffInput; | ||
| 77 | } | ||
| 78 | | { | ||
| 79 | readonly kind: "turnDiff"; | ||
| 80 | readonly input: OrchestrationGetTurnDiffInput; | ||
| 81 | }; | ||
| 82 | |||
| 83 | export function getCheckpointDiffTargetKey(target: CheckpointDiffTarget): string | null { | ||
| 84 | const decoded = decodeCheckpointDiffRequest(target); | ||
| 85 | if (target.environmentId === null || decoded._tag === "None") { | ||
| 86 | return null; | ||
| 87 | } | ||
| 88 | |||
| 89 | return [ | ||
| 90 | target.environmentId, | ||
| 91 | target.threadId, | ||
| 92 | target.fromTurnCount, | ||
| 93 | target.toTurnCount, | ||
| 94 | target.ignoreWhitespace, | ||
| 95 | target.cacheScope ?? null, | ||
| 96 | ].join(":"); | ||
| 97 | } | ||
| 98 | |||
| 99 | function decodeCheckpointDiffRequest(target: CheckpointDiffTarget) { | ||
| 100 | if (target.fromTurnCount === 0) { | ||
| 101 | return decodeFullThreadDiffInput({ | ||
| 102 | threadId: target.threadId, | ||
| 103 | toTurnCount: target.toTurnCount, | ||
| 104 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 105 | }).pipe(Option.map((input) => ({ kind: "fullThreadDiff" as const, input }))); | ||
| 106 | } | ||
| 107 | |||
| 108 | return decodeTurnDiffInput({ | ||
| 109 | threadId: target.threadId, | ||
| 110 | fromTurnCount: target.fromTurnCount, | ||
| 111 | toTurnCount: target.toTurnCount, | ||
| 112 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 113 | }).pipe(Option.map((input) => ({ kind: "turnDiff" as const, input }))); | ||
| 114 | } | ||
| 115 | |||
| 116 | function asCheckpointErrorMessage(error: unknown): string { | ||
| 117 | if (error instanceof Error) { | ||
| 118 | return error.message; | ||
| 119 | } | ||
| 120 | if (typeof error === "string") { | ||
| 121 | return error; | ||
| 122 | } | ||
| 123 | return ""; | ||
| 124 | } | ||
| 125 | |||
| 126 | export function normalizeCheckpointDiffErrorMessage(error: unknown): string { | ||
| 127 | const message = asCheckpointErrorMessage(error).trim(); | ||
| 128 | if (message.length === 0) { | ||
| 129 | return "Failed to load checkpoint diff."; | ||
| 130 | } | ||
| 131 | |||
| 132 | const lower = message.toLowerCase(); | ||
| 133 | if (lower.includes("not a git repository")) { | ||
| 134 | return "Turn diffs are unavailable because this project is not a git repository."; | ||
| 135 | } | ||
| 136 | |||
| 137 | if ( | ||
| 138 | lower.includes("checkpoint unavailable for thread") || | ||
| 139 | lower.includes("checkpoint invariant violation") | ||
| 140 | ) { | ||
| 141 | const separatorIndex = message.indexOf(":"); | ||
| 142 | if (separatorIndex >= 0) { | ||
| 143 | const detail = message.slice(separatorIndex + 1).trim(); | ||
| 144 | if (detail.length > 0) { | ||
| 145 | return detail; | ||
| 146 | } | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | return message; | ||
| 151 | } | ||
| 152 | |||
| 153 | function isCheckpointTemporarilyUnavailable(error: unknown): boolean { | ||
| 154 | const message = asCheckpointErrorMessage(error).toLowerCase(); | ||
| 155 | return ( | ||
| 156 | message.includes("exceeds current turn count") || | ||
| 157 | message.includes("checkpoint is unavailable for turn") || | ||
| 158 | message.includes("filesystem checkpoint is unavailable") | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | |||
| 162 | function defaultRetryDelay(attempt: number, error: unknown): Promise<void> { | ||
| 163 | const delayMs = isCheckpointTemporarilyUnavailable(error) | ||
| 164 | ? Math.min(5_000, 250 * 2 ** (attempt - 1)) | ||
| 165 | : Math.min(1_000, 100 * 2 ** (attempt - 1)); | ||
| 166 | return Effect.runPromise(Effect.sleep(Duration.millis(delayMs))); | ||
| 167 | } | ||
| 168 | |||
| 169 | export function createCheckpointDiffManager(config: { | ||
| 170 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 171 | readonly getClient: (environmentId: EnvironmentId) => CheckpointDiffClient | null; | ||
| 172 | readonly retryDelay?: (attempt: number, error: unknown) => Promise<void>; | ||
| 173 | }) { | ||
| 174 | const inFlight = new Map<string, Promise<CheckpointDiffResult | null>>(); | ||
| 175 | const versions = new Map<string, number>(); | ||
| 176 | |||
| 177 | function getVersion(targetKey: string): number { | ||
| 178 | return versions.get(targetKey) ?? 0; | ||
| 179 | } | ||
| 180 | |||
| 181 | function bumpVersion(targetKey: string): void { | ||
| 182 | versions.set(targetKey, getVersion(targetKey) + 1); | ||
| 183 | } | ||
| 184 | |||
| 185 | function setState(targetKey: string, state: CheckpointDiffState): void { | ||
| 186 | config.getRegistry().set(checkpointDiffStateAtom(targetKey), state); | ||
| 187 | } | ||
| 188 | |||
| 189 | function markPending(targetKey: string): void { | ||
| 190 | const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); | ||
| 191 | setState( | ||
| 192 | targetKey, | ||
| 193 | current.data === null ? INITIAL_CHECKPOINT_DIFF_STATE : { ...current, isPending: true }, | ||
| 194 | ); | ||
| 195 | } | ||
| 196 | |||
| 197 | function setError(targetKey: string, error: unknown): void { | ||
| 198 | const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); | ||
| 199 | setState(targetKey, { | ||
| 200 | data: current.data, | ||
| 201 | error: normalizeCheckpointDiffErrorMessage(error), | ||
| 202 | isPending: false, | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | |||
| 206 | async function requestWithRetry( | ||
| 207 | client: CheckpointDiffClient, | ||
| 208 | request: CheckpointDiffRequest, | ||
| 209 | ): Promise<CheckpointDiffResult> { | ||
| 210 | let attempt = 0; | ||
| 211 | while (true) { | ||
| 212 | attempt += 1; | ||
| 213 | try { | ||
| 214 | if (request.kind === "fullThreadDiff") { | ||
| 215 | return await client.getFullThreadDiff(request.input); | ||
| 216 | } | ||
| 217 | return await client.getTurnDiff(request.input); | ||
| 218 | } catch (error) { | ||
| 219 | const maxAttempts = isCheckpointTemporarilyUnavailable(error) ? 13 : 4; | ||
| 220 | if (attempt >= maxAttempts) { | ||
| 221 | throw error; | ||
| 222 | } | ||
| 223 | await (config.retryDelay ?? defaultRetryDelay)(attempt, error); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | function load( | ||
| 229 | target: CheckpointDiffTarget, | ||
| 230 | client?: CheckpointDiffClient, | ||
| 231 | options?: { readonly force?: boolean }, | ||
| 232 | ): Promise<CheckpointDiffResult | null> { | ||
| 233 | const targetKey = getCheckpointDiffTargetKey(target); | ||
| 234 | const decoded = decodeCheckpointDiffRequest(target); | ||
| 235 | if (targetKey === null || target.environmentId === null || decoded._tag === "None") { | ||
| 236 | return Promise.resolve(null); | ||
| 237 | } | ||
| 238 | |||
| 239 | if (!options?.force) { | ||
| 240 | const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); | ||
| 241 | if (current.data !== null && current.error === null) { | ||
| 242 | return Promise.resolve(current.data); | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | const existing = inFlight.get(targetKey); | ||
| 247 | if (existing) { | ||
| 248 | return existing; | ||
| 249 | } | ||
| 250 | |||
| 251 | const resolved = client ?? config.getClient(target.environmentId); | ||
| 252 | if (!resolved) { | ||
| 253 | setError(targetKey, new Error("Remote connection is not ready.")); | ||
| 254 | return Promise.resolve(config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data); | ||
| 255 | } | ||
| 256 | |||
| 257 | markPending(targetKey); | ||
| 258 | const version = getVersion(targetKey); | ||
| 259 | const promise = requestWithRetry(resolved, decoded.value).then( | ||
| 260 | (result) => { | ||
| 261 | if (getVersion(targetKey) === version) { | ||
| 262 | setState(targetKey, { data: result, error: null, isPending: false }); | ||
| 263 | } | ||
| 264 | return result; | ||
| 265 | }, | ||
| 266 | (error: unknown) => { | ||
| 267 | if (getVersion(targetKey) === version) { | ||
| 268 | setError(targetKey, error); | ||
| 269 | } | ||
| 270 | return config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data; | ||
| 271 | }, | ||
| 272 | ); | ||
| 273 | inFlight.set(targetKey, promise); | ||
| 274 | void promise.finally(() => { | ||
| 275 | if (inFlight.get(targetKey) === promise) { | ||
| 276 | inFlight.delete(targetKey); | ||
| 277 | } | ||
| 278 | }); | ||
| 279 | return promise; | ||
| 280 | } | ||
| 281 | |||
| 282 | function getSnapshot(target: CheckpointDiffTarget): CheckpointDiffState { | ||
| 283 | const targetKey = getCheckpointDiffTargetKey(target); | ||
| 284 | return targetKey === null | ||
| 285 | ? EMPTY_CHECKPOINT_DIFF_STATE | ||
| 286 | : config.getRegistry().get(checkpointDiffStateAtom(targetKey)); | ||
| 287 | } | ||
| 288 | |||
| 289 | function invalidate(target?: CheckpointDiffTarget): void { | ||
| 290 | if (target) { | ||
| 291 | const targetKey = getCheckpointDiffTargetKey(target); | ||
| 292 | if (targetKey === null) { | ||
| 293 | return; | ||
| 294 | } | ||
| 295 | bumpVersion(targetKey); | ||
| 296 | inFlight.delete(targetKey); | ||
| 297 | setState(targetKey, INITIAL_CHECKPOINT_DIFF_STATE); | ||
| 298 | return; | ||
| 299 | } | ||
| 300 | |||
| 301 | for (const key of knownCheckpointDiffKeys) { | ||
| 302 | bumpVersion(key); | ||
| 303 | setState(key, INITIAL_CHECKPOINT_DIFF_STATE); | ||
| 304 | } | ||
| 305 | inFlight.clear(); | ||
| 306 | } | ||
| 307 | |||
| 308 | return { | ||
| 309 | getSnapshot, | ||
| 310 | invalidate, | ||
| 311 | load, | ||
| 312 | }; | ||
| 313 | } | ||
packages/client-runtime/src/composerPathSearchState.test.ts deleted-185| ... | @@ -1,185 +0,0 @@ | ||
| 1 | import { assert, beforeEach, it, vi } from "vite-plus/test"; | ||
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type ComposerPathSearchClient, | ||
| 7 | createComposerPathSearchManager, | ||
| 8 | EMPTY_COMPOSER_PATH_SEARCH_STATE, | ||
| 9 | getComposerPathSearchTargetKey, | ||
| 10 | } from "./composerPathSearchState.ts"; | ||
| 11 | |||
| 12 | let registry = AtomRegistry.make(); | ||
| 13 | |||
| 14 | const noop = () => undefined; | ||
| 15 | |||
| 16 | beforeEach(() => { | ||
| 17 | registry.dispose(); | ||
| 18 | registry = AtomRegistry.make(); | ||
| 19 | }); | ||
| 20 | |||
| 21 | function flushAsyncWork(): Promise<void> { | ||
| 22 | return Promise.resolve().then(() => undefined); | ||
| 23 | } | ||
| 24 | |||
| 25 | const TARGET = { | ||
| 26 | environmentId: "env-local" as EnvironmentId, | ||
| 27 | cwd: "/repo", | ||
| 28 | query: "src", | ||
| 29 | }; | ||
| 30 | |||
| 31 | it("derives null keys for inactive path searches", () => { | ||
| 32 | assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, query: "" }), null); | ||
| 33 | assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, cwd: null }), null); | ||
| 34 | assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, environmentId: null }), null); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("stores path search results in atom state", async () => { | ||
| 38 | const manager = createComposerPathSearchManager({ | ||
| 39 | getRegistry: () => registry, | ||
| 40 | debounceMs: 0, | ||
| 41 | getClient: () => ({ | ||
| 42 | searchEntries: async () => ({ | ||
| 43 | entries: [ | ||
| 44 | { path: "src/index.ts", kind: "file" }, | ||
| 45 | { path: "src/components", kind: "directory" }, | ||
| 46 | ], | ||
| 47 | truncated: false, | ||
| 48 | }), | ||
| 49 | }), | ||
| 50 | }); | ||
| 51 | |||
| 52 | manager.search(TARGET); | ||
| 53 | await flushAsyncWork(); | ||
| 54 | |||
| 55 | assert.deepStrictEqual(manager.getSnapshot(TARGET), { | ||
| 56 | entries: [ | ||
| 57 | { path: "src/index.ts", kind: "file" }, | ||
| 58 | { path: "src/components", kind: "directory" }, | ||
| 59 | ], | ||
| 60 | isPending: false, | ||
| 61 | error: null, | ||
| 62 | }); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("reuses fresh cached path search results", async () => { | ||
| 66 | const searchEntries = vi.fn(async () => ({ | ||
| 67 | entries: [{ path: "src/index.ts", kind: "file" as const }], | ||
| 68 | truncated: false, | ||
| 69 | })); | ||
| 70 | const manager = createComposerPathSearchManager({ | ||
| 71 | getRegistry: () => registry, | ||
| 72 | debounceMs: 0, | ||
| 73 | staleTimeMs: 15_000, | ||
| 74 | getClient: () => ({ searchEntries }), | ||
| 75 | }); | ||
| 76 | |||
| 77 | manager.search(TARGET); | ||
| 78 | await flushAsyncWork(); | ||
| 79 | manager.search(TARGET); | ||
| 80 | await flushAsyncWork(); | ||
| 81 | |||
| 82 | assert.strictEqual(searchEntries.mock.calls.length, 1); | ||
| 83 | assert.deepStrictEqual(manager.getSnapshot(TARGET), { | ||
| 84 | entries: [{ path: "src/index.ts", kind: "file" }], | ||
| 85 | isPending: false, | ||
| 86 | error: null, | ||
| 87 | }); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("invalidates watched path searches and refreshes without clearing entries", async () => { | ||
| 91 | type SearchResult = Awaited<ReturnType<ComposerPathSearchClient["searchEntries"]>>; | ||
| 92 | |||
| 93 | let resolveSecond: (value: SearchResult) => void = noop; | ||
| 94 | let callCount = 0; | ||
| 95 | const searchEntries = vi.fn((() => { | ||
| 96 | callCount += 1; | ||
| 97 | if (callCount === 1) { | ||
| 98 | return Promise.resolve({ | ||
| 99 | entries: [{ path: "src/old.ts", kind: "file" as const }], | ||
| 100 | truncated: false, | ||
| 101 | }); | ||
| 102 | } | ||
| 103 | return new Promise((resolve) => { | ||
| 104 | resolveSecond = resolve; | ||
| 105 | }); | ||
| 106 | }) satisfies ComposerPathSearchClient["searchEntries"]); | ||
| 107 | const manager = createComposerPathSearchManager({ | ||
| 108 | getRegistry: () => registry, | ||
| 109 | debounceMs: 0, | ||
| 110 | staleTimeMs: 15_000, | ||
| 111 | getClient: () => ({ searchEntries }), | ||
| 112 | }); | ||
| 113 | |||
| 114 | const unwatch = manager.watch(TARGET); | ||
| 115 | await flushAsyncWork(); | ||
| 116 | manager.invalidate(); | ||
| 117 | |||
| 118 | assert.deepStrictEqual(manager.getSnapshot(TARGET), { | ||
| 119 | entries: [{ path: "src/old.ts", kind: "file" }], | ||
| 120 | isPending: true, | ||
| 121 | error: null, | ||
| 122 | }); | ||
| 123 | |||
| 124 | resolveSecond({ | ||
| 125 | entries: [{ path: "src/new.ts", kind: "file" }], | ||
| 126 | truncated: false, | ||
| 127 | }); | ||
| 128 | await flushAsyncWork(); | ||
| 129 | |||
| 130 | assert.deepStrictEqual(manager.getSnapshot(TARGET), { | ||
| 131 | entries: [{ path: "src/new.ts", kind: "file" }], | ||
| 132 | isPending: false, | ||
| 133 | error: null, | ||
| 134 | }); | ||
| 135 | assert.strictEqual(searchEntries.mock.calls.length, 2); | ||
| 136 | unwatch(); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it("ignores stale path search results after a newer request starts", async () => { | ||
| 140 | let resolveFirst: (value: { | ||
| 141 | entries: ReadonlyArray<{ path: string; kind: "file" | "directory" }>; | ||
| 142 | truncated: boolean; | ||
| 143 | }) => void = noop; | ||
| 144 | const manager = createComposerPathSearchManager({ | ||
| 145 | getRegistry: () => registry, | ||
| 146 | debounceMs: 0, | ||
| 147 | getClient: () => ({ | ||
| 148 | searchEntries: (input: Parameters<ComposerPathSearchClient["searchEntries"]>[0]) => { | ||
| 149 | if (input.query === "first") { | ||
| 150 | return new Promise((resolve) => { | ||
| 151 | resolveFirst = resolve; | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | return Promise.resolve({ | ||
| 155 | entries: [{ path: "second.ts", kind: "file" }], | ||
| 156 | truncated: false, | ||
| 157 | }); | ||
| 158 | }, | ||
| 159 | }), | ||
| 160 | }); | ||
| 161 | |||
| 162 | manager.search({ ...TARGET, query: "first" }); | ||
| 163 | manager.search({ ...TARGET, query: "second" }); | ||
| 164 | await flushAsyncWork(); | ||
| 165 | resolveFirst({ entries: [{ path: "first.ts", kind: "file" }], truncated: false }); | ||
| 166 | await flushAsyncWork(); | ||
| 167 | |||
| 168 | assert.deepStrictEqual(manager.getSnapshot({ ...TARGET, query: "second" }), { | ||
| 169 | entries: [{ path: "second.ts", kind: "file" }], | ||
| 170 | isPending: false, | ||
| 171 | error: null, | ||
| 172 | }); | ||
| 173 | }); | ||
| 174 | |||
| 175 | it("returns the empty snapshot for inactive targets", () => { | ||
| 176 | const manager = createComposerPathSearchManager({ | ||
| 177 | getRegistry: () => registry, | ||
| 178 | getClient: () => null, | ||
| 179 | }); | ||
| 180 | |||
| 181 | assert.deepStrictEqual( | ||
| 182 | manager.getSnapshot({ environmentId: null, cwd: null, query: null }), | ||
| 183 | EMPTY_COMPOSER_PATH_SEARCH_STATE, | ||
| 184 | ); | ||
| 185 | }); | ||
packages/client-runtime/src/composerPathSearchState.ts deleted-341| ... | @@ -1,341 +0,0 @@ | ||
| 1 | import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; | ||
| 2 | import * as Clock from "effect/Clock"; | ||
| 3 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 7 | |||
| 8 | export interface ComposerPathSearchEntry { | ||
| 9 | readonly path: string; | ||
| 10 | readonly kind: "file" | "directory"; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface ComposerPathSearchState { | ||
| 14 | readonly entries: ReadonlyArray<ComposerPathSearchEntry>; | ||
| 15 | readonly isPending: boolean; | ||
| 16 | readonly error: string | null; | ||
| 17 | } | ||
| 18 | |||
| 19 | export interface ComposerPathSearchTarget { | ||
| 20 | readonly environmentId: EnvironmentId | null; | ||
| 21 | readonly cwd: string | null; | ||
| 22 | readonly query: string | null; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface ComposerPathSearchClient { | ||
| 26 | readonly searchEntries: (input: { | ||
| 27 | readonly cwd: string; | ||
| 28 | readonly query: string; | ||
| 29 | readonly limit: number; | ||
| 30 | }) => Promise<ProjectSearchEntriesResult>; | ||
| 31 | } | ||
| 32 | |||
| 33 | interface WatchedEntry { | ||
| 34 | refCount: number; | ||
| 35 | target: ComposerPathSearchTarget & { | ||
| 36 | readonly environmentId: EnvironmentId; | ||
| 37 | readonly cwd: string; | ||
| 38 | }; | ||
| 39 | teardown: () => void; | ||
| 40 | } | ||
| 41 | |||
| 42 | export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({ | ||
| 43 | entries: [], | ||
| 44 | isPending: false, | ||
| 45 | error: null, | ||
| 46 | }); | ||
| 47 | |||
| 48 | const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({ | ||
| 49 | entries: [], | ||
| 50 | isPending: true, | ||
| 51 | error: null, | ||
| 52 | }); | ||
| 53 | |||
| 54 | const NOOP: () => void = () => undefined; | ||
| 55 | const DEFAULT_DEBOUNCE_MS = 200; | ||
| 56 | const DEFAULT_LIMIT = 20; | ||
| 57 | |||
| 58 | export const composerPathSearchStateAtom = Atom.family((key: string) => | ||
| 59 | Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( | ||
| 60 | Atom.keepAlive, | ||
| 61 | Atom.withLabel(`composer-path-search:${key}`), | ||
| 62 | ), | ||
| 63 | ); | ||
| 64 | |||
| 65 | export const EMPTY_COMPOSER_PATH_SEARCH_ATOM = Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( | ||
| 66 | Atom.keepAlive, | ||
| 67 | Atom.withLabel("composer-path-search:null"), | ||
| 68 | ); | ||
| 69 | |||
| 70 | export function normalizeComposerPathSearchQuery(query: string | null): string { | ||
| 71 | return query?.trim() ?? ""; | ||
| 72 | } | ||
| 73 | |||
| 74 | export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget): string | null { | ||
| 75 | const query = normalizeComposerPathSearchQuery(target.query); | ||
| 76 | if (target.environmentId === null || target.cwd === null || query.length === 0) { | ||
| 77 | return null; | ||
| 78 | } | ||
| 79 | |||
| 80 | return `${target.environmentId}:${target.cwd}:${query}`; | ||
| 81 | } | ||
| 82 | |||
| 83 | function toSearchEntries( | ||
| 84 | entries: ProjectSearchEntriesResult["entries"], | ||
| 85 | ): ReadonlyArray<ComposerPathSearchEntry> { | ||
| 86 | return entries; | ||
| 87 | } | ||
| 88 | |||
| 89 | export function createComposerPathSearchManager(config: { | ||
| 90 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 91 | readonly getClient: (environmentId: EnvironmentId) => ComposerPathSearchClient | null; | ||
| 92 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 93 | readonly debounceMs?: number; | ||
| 94 | readonly limit?: number; | ||
| 95 | readonly staleTimeMs?: number; | ||
| 96 | }) { | ||
| 97 | const watched = new Map<string, WatchedEntry>(); | ||
| 98 | const versions = new Map<string, number>(); | ||
| 99 | const timers = new Map<string, Fiber.Fiber<void, never>>(); | ||
| 100 | const lastLoadedAt = new Map<string, number>(); | ||
| 101 | const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; | ||
| 102 | const limit = config.limit ?? DEFAULT_LIMIT; | ||
| 103 | |||
| 104 | function bumpVersion(targetKey: string): number { | ||
| 105 | const next = (versions.get(targetKey) ?? 0) + 1; | ||
| 106 | versions.set(targetKey, next); | ||
| 107 | return next; | ||
| 108 | } | ||
| 109 | |||
| 110 | function setState(targetKey: string, state: ComposerPathSearchState): void { | ||
| 111 | config.getRegistry().set(composerPathSearchStateAtom(targetKey), state); | ||
| 112 | } | ||
| 113 | |||
| 114 | function clearTimer(targetKey: string): void { | ||
| 115 | const fiber = timers.get(targetKey); | ||
| 116 | if (fiber) { | ||
| 117 | Effect.runFork(Fiber.interrupt(fiber)); | ||
| 118 | timers.delete(targetKey); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | function getSnapshot(target: ComposerPathSearchTarget): ComposerPathSearchState { | ||
| 123 | const targetKey = getComposerPathSearchTargetKey(target); | ||
| 124 | return targetKey === null | ||
| 125 | ? EMPTY_COMPOSER_PATH_SEARCH_STATE | ||
| 126 | : config.getRegistry().get(composerPathSearchStateAtom(targetKey)); | ||
| 127 | } | ||
| 128 | |||
| 129 | function runSearch( | ||
| 130 | targetKey: string, | ||
| 131 | target: ComposerPathSearchTarget & { | ||
| 132 | readonly environmentId: EnvironmentId; | ||
| 133 | readonly cwd: string; | ||
| 134 | }, | ||
| 135 | client: ComposerPathSearchClient, | ||
| 136 | version: number, | ||
| 137 | ): void { | ||
| 138 | void client | ||
| 139 | .searchEntries({ | ||
| 140 | cwd: target.cwd, | ||
| 141 | query: normalizeComposerPathSearchQuery(target.query), | ||
| 142 | limit, | ||
| 143 | }) | ||
| 144 | .then((result) => { | ||
| 145 | if (versions.get(targetKey) !== version) { | ||
| 146 | return; | ||
| 147 | } | ||
| 148 | setState(targetKey, { | ||
| 149 | entries: toSearchEntries(result.entries), | ||
| 150 | isPending: false, | ||
| 151 | error: null, | ||
| 152 | }); | ||
| 153 | lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); | ||
| 154 | }) | ||
| 155 | .catch((error: unknown) => { | ||
| 156 | if (versions.get(targetKey) !== version) { | ||
| 157 | return; | ||
| 158 | } | ||
| 159 | const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); | ||
| 160 | setState(targetKey, { | ||
| 161 | entries: current.entries, | ||
| 162 | isPending: false, | ||
| 163 | error: error instanceof Error ? error.message : "Failed to search project files.", | ||
| 164 | }); | ||
| 165 | }); | ||
| 166 | } | ||
| 167 | |||
| 168 | function search( | ||
| 169 | target: ComposerPathSearchTarget, | ||
| 170 | client?: ComposerPathSearchClient, | ||
| 171 | options?: { readonly force?: boolean }, | ||
| 172 | ): void { | ||
| 173 | const targetKey = getComposerPathSearchTargetKey(target); | ||
| 174 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 175 | return; | ||
| 176 | } | ||
| 177 | |||
| 178 | const resolved = client ?? config.getClient(target.environmentId); | ||
| 179 | if (!resolved) { | ||
| 180 | setState(targetKey, { | ||
| 181 | entries: [], | ||
| 182 | isPending: false, | ||
| 183 | error: "Remote connection is not ready.", | ||
| 184 | }); | ||
| 185 | return; | ||
| 186 | } | ||
| 187 | |||
| 188 | const lastLoaded = lastLoadedAt.get(targetKey); | ||
| 189 | if ( | ||
| 190 | !options?.force && | ||
| 191 | lastLoaded !== undefined && | ||
| 192 | config.staleTimeMs !== undefined && | ||
| 193 | Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs | ||
| 194 | ) { | ||
| 195 | return; | ||
| 196 | } | ||
| 197 | |||
| 198 | const version = bumpVersion(targetKey); | ||
| 199 | clearTimer(targetKey); | ||
| 200 | const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); | ||
| 201 | setState( | ||
| 202 | targetKey, | ||
| 203 | current.entries.length === 0 | ||
| 204 | ? PENDING_COMPOSER_PATH_SEARCH_STATE | ||
| 205 | : { ...current, isPending: true, error: null }, | ||
| 206 | ); | ||
| 207 | |||
| 208 | const readyTarget = { | ||
| 209 | ...target, | ||
| 210 | environmentId: target.environmentId, | ||
| 211 | cwd: target.cwd, | ||
| 212 | }; | ||
| 213 | |||
| 214 | if (debounceMs <= 0) { | ||
| 215 | runSearch(targetKey, readyTarget, resolved, version); | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | |||
| 219 | const fiber = Effect.runFork( | ||
| 220 | Effect.sleep(Duration.millis(debounceMs)).pipe( | ||
| 221 | Effect.andThen( | ||
| 222 | Effect.sync(() => { | ||
| 223 | timers.delete(targetKey); | ||
| 224 | runSearch(targetKey, readyTarget, resolved, version); | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | ), | ||
| 228 | ); | ||
| 229 | timers.set(targetKey, fiber); | ||
| 230 | } | ||
| 231 | |||
| 232 | function watch(target: ComposerPathSearchTarget): () => void { | ||
| 233 | const targetKey = getComposerPathSearchTargetKey(target); | ||
| 234 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 235 | return NOOP; | ||
| 236 | } | ||
| 237 | |||
| 238 | const readyTarget = { | ||
| 239 | ...target, | ||
| 240 | environmentId: target.environmentId, | ||
| 241 | cwd: target.cwd, | ||
| 242 | }; | ||
| 243 | |||
| 244 | const existing = watched.get(targetKey); | ||
| 245 | if (existing) { | ||
| 246 | existing.refCount += 1; | ||
| 247 | return () => unwatch(targetKey); | ||
| 248 | } | ||
| 249 | |||
| 250 | let currentClient: ComposerPathSearchClient | null = null; | ||
| 251 | const sync = () => { | ||
| 252 | const client = config.getClient(target.environmentId!); | ||
| 253 | if (!client) { | ||
| 254 | currentClient = null; | ||
| 255 | setState(targetKey, { | ||
| 256 | entries: [], | ||
| 257 | isPending: false, | ||
| 258 | error: "Remote connection is not ready.", | ||
| 259 | }); | ||
| 260 | return; | ||
| 261 | } | ||
| 262 | |||
| 263 | if (currentClient === client) { | ||
| 264 | return; | ||
| 265 | } | ||
| 266 | |||
| 267 | currentClient = client; | ||
| 268 | search(readyTarget, client); | ||
| 269 | }; | ||
| 270 | |||
| 271 | const unsubscribe = config.subscribeClientChanges?.(sync) ?? NOOP; | ||
| 272 | sync(); | ||
| 273 | |||
| 274 | watched.set(targetKey, { | ||
| 275 | refCount: 1, | ||
| 276 | target: readyTarget, | ||
| 277 | teardown: () => { | ||
| 278 | unsubscribe(); | ||
| 279 | clearTimer(targetKey); | ||
| 280 | bumpVersion(targetKey); | ||
| 281 | }, | ||
| 282 | }); | ||
| 283 | |||
| 284 | return () => unwatch(targetKey); | ||
| 285 | } | ||
| 286 | |||
| 287 | function unwatch(targetKey: string): void { | ||
| 288 | const entry = watched.get(targetKey); | ||
| 289 | if (!entry) { | ||
| 290 | return; | ||
| 291 | } | ||
| 292 | |||
| 293 | entry.refCount -= 1; | ||
| 294 | if (entry.refCount > 0) { | ||
| 295 | return; | ||
| 296 | } | ||
| 297 | |||
| 298 | entry.teardown(); | ||
| 299 | watched.delete(targetKey); | ||
| 300 | } | ||
| 301 | |||
| 302 | function reset(): void { | ||
| 303 | for (const entry of watched.values()) { | ||
| 304 | entry.teardown(); | ||
| 305 | } | ||
| 306 | watched.clear(); | ||
| 307 | versions.clear(); | ||
| 308 | for (const targetKey of timers.keys()) { | ||
| 309 | clearTimer(targetKey); | ||
| 310 | } | ||
| 311 | lastLoadedAt.clear(); | ||
| 312 | } | ||
| 313 | |||
| 314 | function invalidate(target?: ComposerPathSearchTarget): void { | ||
| 315 | if (target) { | ||
| 316 | const targetKey = getComposerPathSearchTargetKey(target); | ||
| 317 | if (targetKey === null) { | ||
| 318 | return; | ||
| 319 | } | ||
| 320 | lastLoadedAt.delete(targetKey); | ||
| 321 | const watchedEntry = watched.get(targetKey); | ||
| 322 | if (watchedEntry) { | ||
| 323 | search(watchedEntry.target, undefined, { force: true }); | ||
| 324 | } | ||
| 325 | return; | ||
| 326 | } | ||
| 327 | |||
| 328 | lastLoadedAt.clear(); | ||
| 329 | for (const watchedEntry of watched.values()) { | ||
| 330 | search(watchedEntry.target, undefined, { force: true }); | ||
| 331 | } | ||
| 332 | } | ||
| 333 | |||
| 334 | return { | ||
| 335 | invalidate, | ||
| 336 | getSnapshot, | ||
| 337 | search, | ||
| 338 | watch, | ||
| 339 | reset, | ||
| 340 | }; | ||
| 341 | } | ||
packages/client-runtime/src/connection/catalog.ts created+143| ... | @@ -0,0 +1,143 @@ | ||
| 1 | import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import type * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | |||
| 7 | import type { ConnectionAttemptError } from "./model.ts"; | ||
| 8 | import { | ||
| 9 | BearerConnectionTarget, | ||
| 10 | PrimaryConnectionTarget, | ||
| 11 | RelayConnectionTarget, | ||
| 12 | SshConnectionTarget, | ||
| 13 | type ConnectionTarget, | ||
| 14 | } from "./model.ts"; | ||
| 15 | |||
| 16 | const ConnectionProfileBase = { | ||
| 17 | connectionId: Schema.String, | ||
| 18 | environmentId: EnvironmentId, | ||
| 19 | label: Schema.String, | ||
| 20 | }; | ||
| 21 | |||
| 22 | export class BearerConnectionProfile extends Schema.TaggedClass<BearerConnectionProfile>()( | ||
| 23 | "BearerConnectionProfile", | ||
| 24 | { | ||
| 25 | ...ConnectionProfileBase, | ||
| 26 | httpBaseUrl: Schema.String, | ||
| 27 | wsBaseUrl: Schema.String, | ||
| 28 | }, | ||
| 29 | ) {} | ||
| 30 | |||
| 31 | export class SshConnectionProfile extends Schema.TaggedClass<SshConnectionProfile>()( | ||
| 32 | "SshConnectionProfile", | ||
| 33 | { | ||
| 34 | ...ConnectionProfileBase, | ||
| 35 | target: DesktopSshEnvironmentTargetSchema, | ||
| 36 | }, | ||
| 37 | ) {} | ||
| 38 | |||
| 39 | export const ConnectionProfile = Schema.Union([BearerConnectionProfile, SshConnectionProfile]); | ||
| 40 | export type ConnectionProfile = typeof ConnectionProfile.Type; | ||
| 41 | |||
| 42 | export interface ConnectionCatalogEntry { | ||
| 43 | readonly target: ConnectionTarget; | ||
| 44 | readonly profile: Option.Option<ConnectionProfile>; | ||
| 45 | } | ||
| 46 | |||
| 47 | export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()( | ||
| 48 | "BearerConnectionCredential", | ||
| 49 | { | ||
| 50 | token: Schema.String, | ||
| 51 | }, | ||
| 52 | ) {} | ||
| 53 | |||
| 54 | export const ConnectionCredential = Schema.Union([BearerConnectionCredential]); | ||
| 55 | export type ConnectionCredential = typeof ConnectionCredential.Type; | ||
| 56 | |||
| 57 | export class PrimaryConnectionRegistration extends Schema.TaggedClass<PrimaryConnectionRegistration>()( | ||
| 58 | "PrimaryConnectionRegistration", | ||
| 59 | { | ||
| 60 | target: PrimaryConnectionTarget, | ||
| 61 | }, | ||
| 62 | ) {} | ||
| 63 | |||
| 64 | export class RelayConnectionRegistration extends Schema.TaggedClass<RelayConnectionRegistration>()( | ||
| 65 | "RelayConnectionRegistration", | ||
| 66 | { | ||
| 67 | target: RelayConnectionTarget, | ||
| 68 | }, | ||
| 69 | ) {} | ||
| 70 | |||
| 71 | export class BearerConnectionRegistration extends Schema.TaggedClass<BearerConnectionRegistration>()( | ||
| 72 | "BearerConnectionRegistration", | ||
| 73 | { | ||
| 74 | target: BearerConnectionTarget, | ||
| 75 | profile: BearerConnectionProfile, | ||
| 76 | credential: BearerConnectionCredential, | ||
| 77 | }, | ||
| 78 | ) {} | ||
| 79 | |||
| 80 | export class SshConnectionRegistration extends Schema.TaggedClass<SshConnectionRegistration>()( | ||
| 81 | "SshConnectionRegistration", | ||
| 82 | { | ||
| 83 | target: SshConnectionTarget, | ||
| 84 | profile: SshConnectionProfile, | ||
| 85 | }, | ||
| 86 | ) {} | ||
| 87 | |||
| 88 | export const ConnectionRegistration = Schema.Union([ | ||
| 89 | RelayConnectionRegistration, | ||
| 90 | BearerConnectionRegistration, | ||
| 91 | SshConnectionRegistration, | ||
| 92 | ]); | ||
| 93 | export type ConnectionRegistration = typeof ConnectionRegistration.Type; | ||
| 94 | |||
| 95 | export function connectionRegistrationTarget( | ||
| 96 | registration: ConnectionRegistration | PrimaryConnectionRegistration, | ||
| 97 | ): ConnectionTarget { | ||
| 98 | return registration.target; | ||
| 99 | } | ||
| 100 | |||
| 101 | export function connectionRegistrationCatalogEntry( | ||
| 102 | registration: ConnectionRegistration | PrimaryConnectionRegistration, | ||
| 103 | ): ConnectionCatalogEntry { | ||
| 104 | switch (registration._tag) { | ||
| 105 | case "PrimaryConnectionRegistration": | ||
| 106 | case "RelayConnectionRegistration": | ||
| 107 | return { | ||
| 108 | target: registration.target, | ||
| 109 | profile: Option.none(), | ||
| 110 | }; | ||
| 111 | case "BearerConnectionRegistration": | ||
| 112 | case "SshConnectionRegistration": | ||
| 113 | return { | ||
| 114 | target: registration.target, | ||
| 115 | profile: Option.some(registration.profile), | ||
| 116 | }; | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | export class ConnectionProfileStore extends Context.Service< | ||
| 121 | ConnectionProfileStore, | ||
| 122 | { | ||
| 123 | readonly get: ( | ||
| 124 | connectionId: string, | ||
| 125 | ) => Effect.Effect<Option.Option<ConnectionProfile>, ConnectionAttemptError>; | ||
| 126 | readonly put: (profile: ConnectionProfile) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 127 | readonly remove: (connectionId: string) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 128 | } | ||
| 129 | >()("@t3tools/client-runtime/connection/catalog/ConnectionProfileStore") {} | ||
| 130 | |||
| 131 | export class ConnectionCredentialStore extends Context.Service< | ||
| 132 | ConnectionCredentialStore, | ||
| 133 | { | ||
| 134 | readonly get: ( | ||
| 135 | connectionId: string, | ||
| 136 | ) => Effect.Effect<Option.Option<ConnectionCredential>, ConnectionAttemptError>; | ||
| 137 | readonly put: ( | ||
| 138 | connectionId: string, | ||
| 139 | credential: ConnectionCredential, | ||
| 140 | ) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 141 | readonly remove: (connectionId: string) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 142 | } | ||
| 143 | >()("@t3tools/client-runtime/connection/catalog/ConnectionCredentialStore") {} | ||
packages/client-runtime/src/connection/connectivity.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import type * as Effect from "effect/Effect"; | ||
| 3 | import type * as Stream from "effect/Stream"; | ||
| 4 | |||
| 5 | import type { NetworkStatus } from "./model.ts"; | ||
| 6 | |||
| 7 | export class Connectivity extends Context.Service< | ||
| 8 | Connectivity, | ||
| 9 | { | ||
| 10 | readonly status: Effect.Effect<NetworkStatus>; | ||
| 11 | readonly changes: Stream.Stream<NetworkStatus>; | ||
| 12 | } | ||
| 13 | >()("@t3tools/client-runtime/connection/connectivity") {} | ||
packages/client-runtime/src/connection/driver.ts created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import type * as Scope from "effect/Scope"; | ||
| 5 | |||
| 6 | import type { ConnectionCatalogEntry } from "./catalog.ts"; | ||
| 7 | import type { | ||
| 8 | ConnectionAttemptError, | ||
| 9 | ConnectionAttemptStage, | ||
| 10 | PreparedConnection, | ||
| 11 | } from "./model.ts"; | ||
| 12 | import { ConnectionResolver } from "./resolver.ts"; | ||
| 13 | import { RpcSessionFactory, type RpcSession } from "../rpc/session.ts"; | ||
| 14 | |||
| 15 | export type ConnectionDriverProgress = | ||
| 16 | | { | ||
| 17 | readonly stage: "preparing"; | ||
| 18 | } | ||
| 19 | | { | ||
| 20 | readonly stage: Exclude<ConnectionAttemptStage, "preparing">; | ||
| 21 | readonly prepared: PreparedConnection; | ||
| 22 | }; | ||
| 23 | |||
| 24 | export interface EnvironmentConnectionLease { | ||
| 25 | readonly prepared: PreparedConnection; | ||
| 26 | readonly session: RpcSession; | ||
| 27 | } | ||
| 28 | |||
| 29 | export interface ConnectionDriverService { | ||
| 30 | readonly connect: ( | ||
| 31 | entry: ConnectionCatalogEntry, | ||
| 32 | reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>, | ||
| 33 | ) => Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>; | ||
| 34 | } | ||
| 35 | |||
| 36 | export class ConnectionDriver extends Context.Service<ConnectionDriver, ConnectionDriverService>()( | ||
| 37 | "@t3tools/client-runtime/connection/driver/ConnectionDriver", | ||
| 38 | ) {} | ||
| 39 | |||
| 40 | export const connectionDriverLayer = Layer.effect( | ||
| 41 | ConnectionDriver, | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const resolver = yield* ConnectionResolver; | ||
| 44 | const sessions = yield* RpcSessionFactory; | ||
| 45 | |||
| 46 | const connect = Effect.fn("ConnectionDriver.connect")(function* ( | ||
| 47 | entry: ConnectionCatalogEntry, | ||
| 48 | reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>, | ||
| 49 | ) { | ||
| 50 | const target = entry.target; | ||
| 51 | yield* Effect.annotateCurrentSpan({ | ||
| 52 | "connection.environment.id": target.environmentId, | ||
| 53 | "connection.target.kind": target._tag, | ||
| 54 | }); | ||
| 55 | yield* reportProgress({ stage: "preparing" }); | ||
| 56 | const prepared = yield* resolver.prepare(entry); | ||
| 57 | yield* reportProgress({ stage: "opening", prepared }); | ||
| 58 | const session = yield* sessions.connect(prepared); | ||
| 59 | yield* reportProgress({ stage: "synchronizing", prepared }); | ||
| 60 | yield* session.ready; | ||
| 61 | return { prepared, session } satisfies EnvironmentConnectionLease; | ||
| 62 | }); | ||
| 63 | |||
| 64 | return ConnectionDriver.of({ connect }); | ||
| 65 | }), | ||
| 66 | ); | ||
packages/client-runtime/src/connection/errors.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { RelayProtectedError } from "@t3tools/contracts/relay"; | ||
| 3 | import type { ManagedRelayClientError } from "../relay/managedRelay.ts"; | ||
| 4 | import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts"; | ||
| 5 | import { | ||
| 6 | ConnectionBlockedError, | ||
| 7 | type ConnectionAttemptError, | ||
| 8 | ConnectionTransientError, | ||
| 9 | } from "./model.ts"; | ||
| 10 | |||
| 11 | export function profileMissingError(connectionId: string): ConnectionBlockedError { | ||
| 12 | return new ConnectionBlockedError({ | ||
| 13 | reason: "configuration", | ||
| 14 | message: `Connection profile ${connectionId} is unavailable.`, | ||
| 15 | }); | ||
| 16 | } | ||
| 17 | |||
| 18 | export function credentialMissingError(connectionId: string): ConnectionBlockedError { | ||
| 19 | return new ConnectionBlockedError({ | ||
| 20 | reason: "authentication", | ||
| 21 | message: `Connection credential ${connectionId} is unavailable.`, | ||
| 22 | }); | ||
| 23 | } | ||
| 24 | |||
| 25 | export function environmentMismatchError(input: { | ||
| 26 | readonly expected: EnvironmentId; | ||
| 27 | readonly actual: EnvironmentId; | ||
| 28 | }): ConnectionBlockedError { | ||
| 29 | return new ConnectionBlockedError({ | ||
| 30 | reason: "configuration", | ||
| 31 | message: `Connected environment ${input.actual} does not match ${input.expected}.`, | ||
| 32 | }); | ||
| 33 | } | ||
| 34 | |||
| 35 | function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError { | ||
| 36 | switch (error._tag) { | ||
| 37 | case "RelayAuthInvalidError": | ||
| 38 | case "RelayEnvironmentLinkProofExpiredError": | ||
| 39 | case "RelayAgentActivityPublishProofExpiredError": | ||
| 40 | case "RelayAgentActivityPublishProofInvalidError": | ||
| 41 | return new ConnectionBlockedError({ | ||
| 42 | reason: "authentication", | ||
| 43 | message: error.message, | ||
| 44 | traceId: error.traceId, | ||
| 45 | }); | ||
| 46 | case "RelayEnvironmentConnectNotAuthorizedError": | ||
| 47 | case "RelayEnvironmentLinkProofInvalidError": | ||
| 48 | return new ConnectionBlockedError({ | ||
| 49 | reason: "permission", | ||
| 50 | message: error.message, | ||
| 51 | traceId: error.traceId, | ||
| 52 | }); | ||
| 53 | case "RelayEnvironmentEndpointTimedOutError": | ||
| 54 | return new ConnectionTransientError({ | ||
| 55 | reason: "timeout", | ||
| 56 | message: error.message, | ||
| 57 | traceId: error.traceId, | ||
| 58 | }); | ||
| 59 | case "RelayEnvironmentEndpointUnavailableError": | ||
| 60 | case "RelayEnvironmentLinkUnavailableError": | ||
| 61 | return new ConnectionTransientError({ | ||
| 62 | reason: "endpoint-unavailable", | ||
| 63 | message: error.message, | ||
| 64 | traceId: error.traceId, | ||
| 65 | }); | ||
| 66 | case "RelayEnvironmentLinkFailedError": | ||
| 67 | case "RelayInternalError": | ||
| 68 | return new ConnectionTransientError({ | ||
| 69 | reason: "relay-unavailable", | ||
| 70 | message: error.message, | ||
| 71 | traceId: error.traceId, | ||
| 72 | }); | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError { | ||
| 77 | if (error.relayError) { | ||
| 78 | return relayProtectedError(error.relayError); | ||
| 79 | } | ||
| 80 | if (error.cause?._tag === "ManagedRelayRequestTimeoutError") { | ||
| 81 | return new ConnectionTransientError({ | ||
| 82 | reason: "timeout", | ||
| 83 | message: error.message, | ||
| 84 | ...(error.traceId ? { traceId: error.traceId } : {}), | ||
| 85 | }); | ||
| 86 | } | ||
| 87 | return new ConnectionTransientError({ | ||
| 88 | reason: "relay-unavailable", | ||
| 89 | message: error.message, | ||
| 90 | ...(error.traceId ? { traceId: error.traceId } : {}), | ||
| 91 | }); | ||
| 92 | } | ||
| 93 | |||
| 94 | export function mapRemoteEnvironmentError( | ||
| 95 | error: RemoteEnvironmentAuthError, | ||
| 96 | ): ConnectionAttemptError { | ||
| 97 | switch (error._tag) { | ||
| 98 | case "EnvironmentAuthInvalidError": | ||
| 99 | return new ConnectionBlockedError({ | ||
| 100 | reason: "authentication", | ||
| 101 | message: "The environment credential is invalid.", | ||
| 102 | traceId: error.traceId, | ||
| 103 | }); | ||
| 104 | case "EnvironmentScopeRequiredError": | ||
| 105 | case "EnvironmentOperationForbiddenError": | ||
| 106 | return new ConnectionBlockedError({ | ||
| 107 | reason: "permission", | ||
| 108 | message: "The environment credential does not grant the required access.", | ||
| 109 | traceId: error.traceId, | ||
| 110 | }); | ||
| 111 | case "EnvironmentRequestInvalidError": | ||
| 112 | return new ConnectionBlockedError({ | ||
| 113 | reason: "configuration", | ||
| 114 | message: "The environment rejected the authentication request.", | ||
| 115 | traceId: error.traceId, | ||
| 116 | }); | ||
| 117 | case "RemoteEnvironmentAuthTimeoutError": | ||
| 118 | return new ConnectionTransientError({ | ||
| 119 | reason: "timeout", | ||
| 120 | message: error.message, | ||
| 121 | }); | ||
| 122 | case "RemoteEnvironmentAuthFetchError": | ||
| 123 | return new ConnectionTransientError({ | ||
| 124 | reason: "network", | ||
| 125 | message: error.message, | ||
| 126 | }); | ||
| 127 | case "EnvironmentInternalError": | ||
| 128 | return new ConnectionTransientError({ | ||
| 129 | reason: "remote-unavailable", | ||
| 130 | message: "The environment could not authorize the connection.", | ||
| 131 | traceId: error.traceId, | ||
| 132 | }); | ||
| 133 | case "RemoteEnvironmentAuthInvalidJsonError": | ||
| 134 | case "RemoteEnvironmentAuthUndeclaredStatusError": | ||
| 135 | return new ConnectionTransientError({ | ||
| 136 | reason: "remote-unavailable", | ||
| 137 | message: error.message, | ||
| 138 | }); | ||
| 139 | } | ||
| 140 | } | ||
packages/client-runtime/src/connection/index.ts created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | export * from "./catalog.ts"; | ||
| 2 | export * from "./connectivity.ts"; | ||
| 3 | export * from "./driver.ts"; | ||
| 4 | export * from "./errors.ts"; | ||
| 5 | export * from "./layer.ts"; | ||
| 6 | export * from "./model.ts"; | ||
| 7 | export * from "./onboarding.ts"; | ||
| 8 | export * from "./presentation.ts"; | ||
| 9 | export * from "./registry.ts"; | ||
| 10 | export * from "./resolver.ts"; | ||
| 11 | export * from "./supervisor.ts"; | ||
| 12 | export * from "./wakeups.ts"; | ||
packages/client-runtime/src/connection/layer.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import * as Stream from "effect/Stream"; | ||
| 4 | |||
| 5 | import { connectionResolverLayer } from "./resolver.ts"; | ||
| 6 | import { connectionDriverLayer } from "./driver.ts"; | ||
| 7 | import { environmentRegistryLayer, EnvironmentRegistry } from "./registry.ts"; | ||
| 8 | import { connectionOnboardingLayer } from "./onboarding.ts"; | ||
| 9 | import { PlatformConnectionSource } from "../platform/source.ts"; | ||
| 10 | import { relayEnvironmentDiscoveryLayer } from "../relay/discovery.ts"; | ||
| 11 | import { remoteEnvironmentAuthorizationLayer } from "../authorization/layer.ts"; | ||
| 12 | import { rpcSessionFactoryLayer } from "../rpc/session.ts"; | ||
| 13 | |||
| 14 | const resolverLayer = connectionResolverLayer.pipe( | ||
| 15 | Layer.provide(remoteEnvironmentAuthorizationLayer), | ||
| 16 | ); | ||
| 17 | |||
| 18 | const driverLayer = connectionDriverLayer.pipe( | ||
| 19 | Layer.provide(Layer.mergeAll(resolverLayer, rpcSessionFactoryLayer)), | ||
| 20 | ); | ||
| 21 | |||
| 22 | const registryLayer = environmentRegistryLayer.pipe(Layer.provide(driverLayer)); | ||
| 23 | |||
| 24 | const onboardingLayer = connectionOnboardingLayer.pipe(Layer.provide(registryLayer)); | ||
| 25 | |||
| 26 | const connectionServicesLayer = Layer.mergeAll( | ||
| 27 | registryLayer, | ||
| 28 | relayEnvironmentDiscoveryLayer, | ||
| 29 | onboardingLayer, | ||
| 30 | ); | ||
| 31 | |||
| 32 | const connectionStartupLayer = Layer.effectDiscard( | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const registry = yield* EnvironmentRegistry; | ||
| 35 | const platformSource = yield* PlatformConnectionSource; | ||
| 36 | yield* registry.start; | ||
| 37 | yield* platformSource.registrations.pipe( | ||
| 38 | Stream.runForEach(registry.registerPlatform), | ||
| 39 | Effect.forkScoped, | ||
| 40 | ); | ||
| 41 | }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), | ||
| 42 | ); | ||
| 43 | |||
| 44 | export const connectionLayer = connectionStartupLayer.pipe( | ||
| 45 | Layer.provideMerge(connectionServicesLayer), | ||
| 46 | ); | ||
packages/client-runtime/src/connection/model.ts created+168| ... | @@ -0,0 +1,168 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | const ConnectionTargetBase = { | ||
| 5 | environmentId: EnvironmentId, | ||
| 6 | label: Schema.String, | ||
| 7 | }; | ||
| 8 | |||
| 9 | export class PrimaryConnectionTarget extends Schema.TaggedClass<PrimaryConnectionTarget>()( | ||
| 10 | "PrimaryConnectionTarget", | ||
| 11 | { | ||
| 12 | ...ConnectionTargetBase, | ||
| 13 | httpBaseUrl: Schema.String, | ||
| 14 | wsBaseUrl: Schema.String, | ||
| 15 | }, | ||
| 16 | ) {} | ||
| 17 | |||
| 18 | export class BearerConnectionTarget extends Schema.TaggedClass<BearerConnectionTarget>()( | ||
| 19 | "BearerConnectionTarget", | ||
| 20 | { | ||
| 21 | ...ConnectionTargetBase, | ||
| 22 | connectionId: Schema.String, | ||
| 23 | }, | ||
| 24 | ) {} | ||
| 25 | |||
| 26 | export class RelayConnectionTarget extends Schema.TaggedClass<RelayConnectionTarget>()( | ||
| 27 | "RelayConnectionTarget", | ||
| 28 | { | ||
| 29 | ...ConnectionTargetBase, | ||
| 30 | }, | ||
| 31 | ) {} | ||
| 32 | |||
| 33 | export class SshConnectionTarget extends Schema.TaggedClass<SshConnectionTarget>()( | ||
| 34 | "SshConnectionTarget", | ||
| 35 | { | ||
| 36 | ...ConnectionTargetBase, | ||
| 37 | connectionId: Schema.String, | ||
| 38 | }, | ||
| 39 | ) {} | ||
| 40 | |||
| 41 | export const ConnectionTarget = Schema.Union([ | ||
| 42 | PrimaryConnectionTarget, | ||
| 43 | BearerConnectionTarget, | ||
| 44 | RelayConnectionTarget, | ||
| 45 | SshConnectionTarget, | ||
| 46 | ]); | ||
| 47 | export type ConnectionTarget = typeof ConnectionTarget.Type; | ||
| 48 | |||
| 49 | export const PersistedConnectionTarget = Schema.Union([ | ||
| 50 | BearerConnectionTarget, | ||
| 51 | RelayConnectionTarget, | ||
| 52 | SshConnectionTarget, | ||
| 53 | ]); | ||
| 54 | export type PersistedConnectionTarget = typeof PersistedConnectionTarget.Type; | ||
| 55 | |||
| 56 | export type ConnectionTargetKind = ConnectionTarget["_tag"]; | ||
| 57 | |||
| 58 | export type NetworkStatus = "unknown" | "offline" | "online"; | ||
| 59 | |||
| 60 | export type ConnectionTransientReason = | ||
| 61 | | "network" | ||
| 62 | | "timeout" | ||
| 63 | | "transport" | ||
| 64 | | "endpoint-unavailable" | ||
| 65 | | "relay-unavailable" | ||
| 66 | | "remote-unavailable"; | ||
| 67 | |||
| 68 | export type ConnectionBlockedReason = | ||
| 69 | | "authentication" | ||
| 70 | | "configuration" | ||
| 71 | | "permission" | ||
| 72 | | "unsupported"; | ||
| 73 | |||
| 74 | export class ConnectionTransientError extends Schema.TaggedErrorClass<ConnectionTransientError>()( | ||
| 75 | "ConnectionTransientError", | ||
| 76 | { | ||
| 77 | reason: Schema.Literals([ | ||
| 78 | "network", | ||
| 79 | "timeout", | ||
| 80 | "transport", | ||
| 81 | "endpoint-unavailable", | ||
| 82 | "relay-unavailable", | ||
| 83 | "remote-unavailable", | ||
| 84 | ]), | ||
| 85 | message: Schema.String, | ||
| 86 | traceId: Schema.optionalKey(Schema.String), | ||
| 87 | }, | ||
| 88 | ) {} | ||
| 89 | |||
| 90 | export class ConnectionBlockedError extends Schema.TaggedErrorClass<ConnectionBlockedError>()( | ||
| 91 | "ConnectionBlockedError", | ||
| 92 | { | ||
| 93 | reason: Schema.Literals(["authentication", "configuration", "permission", "unsupported"]), | ||
| 94 | message: Schema.String, | ||
| 95 | traceId: Schema.optionalKey(Schema.String), | ||
| 96 | }, | ||
| 97 | ) {} | ||
| 98 | |||
| 99 | export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError; | ||
| 100 | |||
| 101 | export type PreparedHttpAuthorization = | ||
| 102 | | { | ||
| 103 | readonly _tag: "Bearer"; | ||
| 104 | readonly token: string; | ||
| 105 | } | ||
| 106 | | { | ||
| 107 | readonly _tag: "Dpop"; | ||
| 108 | readonly accessToken: string; | ||
| 109 | }; | ||
| 110 | |||
| 111 | export interface PreparedConnection { | ||
| 112 | readonly environmentId: EnvironmentId; | ||
| 113 | readonly label: string; | ||
| 114 | readonly httpBaseUrl: string; | ||
| 115 | readonly socketUrl: string; | ||
| 116 | readonly httpAuthorization: PreparedHttpAuthorization | null; | ||
| 117 | readonly target: ConnectionTarget; | ||
| 118 | } | ||
| 119 | |||
| 120 | export type SupervisorConnectionPhase = | ||
| 121 | | "available" | ||
| 122 | | "offline" | ||
| 123 | | "connecting" | ||
| 124 | | "backoff" | ||
| 125 | | "connected" | ||
| 126 | | "blocked"; | ||
| 127 | |||
| 128 | export type ConnectionAttemptStage = "preparing" | "opening" | "synchronizing"; | ||
| 129 | |||
| 130 | export interface SupervisorConnectionState { | ||
| 131 | readonly desired: boolean; | ||
| 132 | readonly network: NetworkStatus; | ||
| 133 | readonly phase: SupervisorConnectionPhase; | ||
| 134 | readonly stage: ConnectionAttemptStage | null; | ||
| 135 | readonly attempt: number; | ||
| 136 | readonly generation: number; | ||
| 137 | readonly lastFailure: ConnectionAttemptError | null; | ||
| 138 | readonly retryAt: number | null; | ||
| 139 | } | ||
| 140 | |||
| 141 | export type ConnectionProjectionPhase = "disconnected" | "synchronizing" | "ready"; | ||
| 142 | |||
| 143 | export function connectionProjectionPhase( | ||
| 144 | state: SupervisorConnectionState, | ||
| 145 | ): ConnectionProjectionPhase { | ||
| 146 | switch (state.phase) { | ||
| 147 | case "connecting": | ||
| 148 | return "synchronizing"; | ||
| 149 | case "connected": | ||
| 150 | return "ready"; | ||
| 151 | case "available": | ||
| 152 | case "offline": | ||
| 153 | case "backoff": | ||
| 154 | case "blocked": | ||
| 155 | return "disconnected"; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | export const AVAILABLE_CONNECTION_STATE: SupervisorConnectionState = Object.freeze({ | ||
| 160 | desired: false, | ||
| 161 | network: "unknown", | ||
| 162 | phase: "available", | ||
| 163 | stage: null, | ||
| 164 | attempt: 0, | ||
| 165 | generation: 0, | ||
| 166 | lastFailure: null, | ||
| 167 | retryAt: null, | ||
| 168 | }); | ||
packages/client-runtime/src/connection/onboarding.test.ts created+257| ... | @@ -0,0 +1,257 @@ | ||
| 1 | import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | |||
| 7 | import { remoteHttpClientLayer } from "../rpc/http.ts"; | ||
| 8 | import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; | ||
| 9 | import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts"; | ||
| 10 | import { BearerConnectionTarget } from "./model.ts"; | ||
| 11 | import { | ||
| 12 | prepareBearerConnectionUpdate, | ||
| 13 | preparePairingRegistration, | ||
| 14 | prepareSshRegistration, | ||
| 15 | } from "./onboarding.ts"; | ||
| 16 | |||
| 17 | const CLIENT_PRESENTATION_LAYER = Layer.succeed( | ||
| 18 | ClientPresentation, | ||
| 19 | ClientPresentation.of({ | ||
| 20 | metadata: { | ||
| 21 | label: "T3 Code Test", | ||
| 22 | deviceType: "desktop", | ||
| 23 | os: "Test OS", | ||
| 24 | }, | ||
| 25 | scopes: AuthStandardClientScopes, | ||
| 26 | }), | ||
| 27 | ); | ||
| 28 | |||
| 29 | function pairingHttpLayer( | ||
| 30 | calls: Array<{ readonly url: string; readonly init: RequestInit }>, | ||
| 31 | options?: { readonly failDescriptor?: boolean }, | ||
| 32 | ) { | ||
| 33 | const fetchFn = ((input, init = {}) => { | ||
| 34 | const url = String(input); | ||
| 35 | calls.push({ url, init }); | ||
| 36 | |||
| 37 | if (url.endsWith("/.well-known/t3/environment")) { | ||
| 38 | if (options?.failDescriptor === true) { | ||
| 39 | return Promise.resolve( | ||
| 40 | Response.json({ message: "descriptor unavailable" }, { status: 503 }), | ||
| 41 | ); | ||
| 42 | } | ||
| 43 | return Promise.resolve( | ||
| 44 | Response.json({ | ||
| 45 | environmentId: "environment-paired", | ||
| 46 | label: "Paired environment", | ||
| 47 | platform: { | ||
| 48 | os: "linux", | ||
| 49 | arch: "x64", | ||
| 50 | }, | ||
| 51 | serverVersion: "0.0.0-test", | ||
| 52 | capabilities: { | ||
| 53 | repositoryIdentity: true, | ||
| 54 | }, | ||
| 55 | }), | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | if (url.endsWith("/oauth/token")) { | ||
| 60 | return Promise.resolve( | ||
| 61 | Response.json({ | ||
| 62 | access_token: "bearer-token", | ||
| 63 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 64 | token_type: "Bearer", | ||
| 65 | expires_in: 3600, | ||
| 66 | scope: AuthStandardClientScopes.join(" "), | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | return Promise.reject(new Error(`Unexpected request: ${url}`)); | ||
| 72 | }) satisfies typeof fetch; | ||
| 73 | |||
| 74 | return remoteHttpClientLayer(fetchFn); | ||
| 75 | } | ||
| 76 | |||
| 77 | describe("connection onboarding", () => { | ||
| 78 | it.effect("prepares a persisted bearer registration from pairing details", () => | ||
| 79 | Effect.gen(function* () { | ||
| 80 | const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; | ||
| 81 | const registration = yield* preparePairingRegistration({ | ||
| 82 | host: "remote.example.test", | ||
| 83 | pairingCode: "pairing-token", | ||
| 84 | }).pipe(Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls)))); | ||
| 85 | |||
| 86 | expect(registration).toMatchObject({ | ||
| 87 | _tag: "BearerConnectionRegistration", | ||
| 88 | target: { | ||
| 89 | environmentId: "environment-paired", | ||
| 90 | label: "Paired environment", | ||
| 91 | connectionId: "bearer:environment-paired", | ||
| 92 | }, | ||
| 93 | profile: { | ||
| 94 | environmentId: "environment-paired", | ||
| 95 | label: "Paired environment", | ||
| 96 | connectionId: "bearer:environment-paired", | ||
| 97 | httpBaseUrl: "https://remote.example.test/", | ||
| 98 | wsBaseUrl: "wss://remote.example.test/", | ||
| 99 | }, | ||
| 100 | credential: { | ||
| 101 | token: "bearer-token", | ||
| 102 | }, | ||
| 103 | }); | ||
| 104 | expect(calls.map((call) => call.url)).toEqual([ | ||
| 105 | "https://remote.example.test/.well-known/t3/environment", | ||
| 106 | "https://remote.example.test/oauth/token", | ||
| 107 | ]); | ||
| 108 | |||
| 109 | const tokenRequest = calls.find((call) => call.url.endsWith("/oauth/token")); | ||
| 110 | const tokenBody = | ||
| 111 | tokenRequest?.init.body instanceof Uint8Array | ||
| 112 | ? new TextDecoder().decode(tokenRequest.init.body) | ||
| 113 | : String(tokenRequest?.init.body); | ||
| 114 | const tokenParams = new URLSearchParams(tokenBody); | ||
| 115 | expect(tokenParams.get("subject_token")).toBe("pairing-token"); | ||
| 116 | expect(tokenParams.get("scope")).toBe(AuthStandardClientScopes.join(" ")); | ||
| 117 | expect(tokenParams.get("client_label")).toBe("T3 Code Test"); | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect("does not consume a pairing credential when descriptor discovery fails", () => | ||
| 122 | Effect.gen(function* () { | ||
| 123 | const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; | ||
| 124 | |||
| 125 | yield* preparePairingRegistration({ | ||
| 126 | host: "remote.example.test", | ||
| 127 | pairingCode: "pairing-token", | ||
| 128 | }).pipe( | ||
| 129 | Effect.provide( | ||
| 130 | Layer.mergeAll( | ||
| 131 | CLIENT_PRESENTATION_LAYER, | ||
| 132 | pairingHttpLayer(calls, { failDescriptor: true }), | ||
| 133 | ), | ||
| 134 | ), | ||
| 135 | Effect.flip, | ||
| 136 | ); | ||
| 137 | |||
| 138 | expect(calls.map((call) => call.url)).toEqual([ | ||
| 139 | "https://remote.example.test/.well-known/t3/environment", | ||
| 140 | ]); | ||
| 141 | }), | ||
| 142 | ); | ||
| 143 | |||
| 144 | it.effect("rejects invalid pairing details before making a request", () => | ||
| 145 | Effect.gen(function* () { | ||
| 146 | const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; | ||
| 147 | const error = yield* preparePairingRegistration({ | ||
| 148 | host: "", | ||
| 149 | pairingCode: "", | ||
| 150 | }).pipe( | ||
| 151 | Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls))), | ||
| 152 | Effect.flip, | ||
| 153 | ); | ||
| 154 | |||
| 155 | expect(error).toMatchObject({ | ||
| 156 | _tag: "ConnectionBlockedError", | ||
| 157 | reason: "configuration", | ||
| 158 | message: "Enter a backend URL.", | ||
| 159 | }); | ||
| 160 | expect(calls).toEqual([]); | ||
| 161 | }), | ||
| 162 | ); | ||
| 163 | |||
| 164 | it.effect("updates bearer metadata while preserving the credential and identity", () => | ||
| 165 | Effect.gen(function* () { | ||
| 166 | const environmentId = EnvironmentId.make("environment-paired"); | ||
| 167 | const registration = yield* prepareBearerConnectionUpdate({ | ||
| 168 | input: { | ||
| 169 | environmentId, | ||
| 170 | label: " Renamed environment ", | ||
| 171 | httpBaseUrl: "http://100.65.180.100:3773/path", | ||
| 172 | }, | ||
| 173 | entry: Option.some({ | ||
| 174 | target: new BearerConnectionTarget({ | ||
| 175 | environmentId, | ||
| 176 | label: "Old label", | ||
| 177 | connectionId: "bearer:environment-paired", | ||
| 178 | }), | ||
| 179 | profile: Option.some( | ||
| 180 | new BearerConnectionProfile({ | ||
| 181 | connectionId: "bearer:environment-paired", | ||
| 182 | environmentId, | ||
| 183 | label: "Old label", | ||
| 184 | httpBaseUrl: "http://old.example.test/", | ||
| 185 | wsBaseUrl: "ws://old.example.test/", | ||
| 186 | }), | ||
| 187 | ), | ||
| 188 | }), | ||
| 189 | credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })), | ||
| 190 | }); | ||
| 191 | |||
| 192 | expect(registration).toMatchObject({ | ||
| 193 | target: { | ||
| 194 | environmentId, | ||
| 195 | label: "Renamed environment", | ||
| 196 | connectionId: "bearer:environment-paired", | ||
| 197 | }, | ||
| 198 | profile: { | ||
| 199 | environmentId, | ||
| 200 | label: "Renamed environment", | ||
| 201 | httpBaseUrl: "http://100.65.180.100:3773/", | ||
| 202 | wsBaseUrl: "ws://100.65.180.100:3773/", | ||
| 203 | }, | ||
| 204 | credential: { token: "bearer-token" }, | ||
| 205 | }); | ||
| 206 | }), | ||
| 207 | ); | ||
| 208 | |||
| 209 | it.effect("prepares an SSH registration from the provisioned platform environment", () => | ||
| 210 | Effect.gen(function* () { | ||
| 211 | const target = { | ||
| 212 | alias: "devbox", | ||
| 213 | hostname: "devbox.example.test", | ||
| 214 | username: "developer", | ||
| 215 | port: 22, | ||
| 216 | }; | ||
| 217 | const registration = yield* prepareSshRegistration({ | ||
| 218 | target, | ||
| 219 | }).pipe( | ||
| 220 | Effect.provideService( | ||
| 221 | SshEnvironmentGateway, | ||
| 222 | SshEnvironmentGateway.of({ | ||
| 223 | provision: () => | ||
| 224 | Effect.succeed({ | ||
| 225 | environmentId: EnvironmentId.make("environment-ssh"), | ||
| 226 | label: "Remote development box", | ||
| 227 | bootstrap: { | ||
| 228 | target, | ||
| 229 | httpBaseUrl: "http://127.0.0.1:3201", | ||
| 230 | wsBaseUrl: "ws://127.0.0.1:3201", | ||
| 231 | pairingToken: "pairing-token", | ||
| 232 | }, | ||
| 233 | bearerToken: "bearer-token", | ||
| 234 | }), | ||
| 235 | prepare: () => Effect.die("unused"), | ||
| 236 | disconnect: () => Effect.die("unused"), | ||
| 237 | }), | ||
| 238 | ), | ||
| 239 | ); | ||
| 240 | |||
| 241 | expect(registration).toMatchObject({ | ||
| 242 | _tag: "SshConnectionRegistration", | ||
| 243 | target: { | ||
| 244 | environmentId: "environment-ssh", | ||
| 245 | label: "Remote development box", | ||
| 246 | connectionId: "ssh:environment-ssh", | ||
| 247 | }, | ||
| 248 | profile: { | ||
| 249 | environmentId: "environment-ssh", | ||
| 250 | label: "Remote development box", | ||
| 251 | connectionId: "ssh:environment-ssh", | ||
| 252 | target, | ||
| 253 | }, | ||
| 254 | }); | ||
| 255 | }), | ||
| 256 | ); | ||
| 257 | }); | ||
packages/client-runtime/src/connection/onboarding.ts created+267| ... | @@ -0,0 +1,267 @@ | ||
| 1 | import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 9 | import { HttpClient } from "effect/unstable/http"; | ||
| 10 | |||
| 11 | import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; | ||
| 12 | import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; | ||
| 13 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | ||
| 14 | import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; | ||
| 15 | import { | ||
| 16 | BearerConnectionCredential, | ||
| 17 | BearerConnectionProfile, | ||
| 18 | BearerConnectionRegistration, | ||
| 19 | type ConnectionCatalogEntry, | ||
| 20 | type ConnectionCredential, | ||
| 21 | ConnectionCredentialStore, | ||
| 22 | SshConnectionProfile, | ||
| 23 | SshConnectionRegistration, | ||
| 24 | } from "./catalog.ts"; | ||
| 25 | import { mapRemoteEnvironmentError } from "./errors.ts"; | ||
| 26 | import { | ||
| 27 | BearerConnectionTarget, | ||
| 28 | ConnectionBlockedError, | ||
| 29 | SshConnectionTarget, | ||
| 30 | type ConnectionAttemptError, | ||
| 31 | } from "./model.ts"; | ||
| 32 | import type { ConnectionPersistenceError } from "../platform/persistence.ts"; | ||
| 33 | import { EnvironmentRegistry } from "./registry.ts"; | ||
| 34 | |||
| 35 | export interface PairingConnectionInput { | ||
| 36 | readonly pairingUrl?: string; | ||
| 37 | readonly host?: string; | ||
| 38 | readonly pairingCode?: string; | ||
| 39 | } | ||
| 40 | |||
| 41 | export interface SshConnectionInput { | ||
| 42 | readonly target: DesktopSshEnvironmentTarget; | ||
| 43 | readonly label?: string; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface BearerConnectionUpdateInput { | ||
| 47 | readonly environmentId: EnvironmentId; | ||
| 48 | readonly label: string; | ||
| 49 | readonly httpBaseUrl: string; | ||
| 50 | } | ||
| 51 | |||
| 52 | export class ConnectionOnboarding extends Context.Service< | ||
| 53 | ConnectionOnboarding, | ||
| 54 | { | ||
| 55 | readonly registerPairing: ( | ||
| 56 | input: PairingConnectionInput, | ||
| 57 | ) => Effect.Effect<EnvironmentId, ConnectionAttemptError | ConnectionPersistenceError>; | ||
| 58 | readonly registerSsh: ( | ||
| 59 | input: SshConnectionInput, | ||
| 60 | ) => Effect.Effect<EnvironmentId, ConnectionAttemptError | ConnectionPersistenceError>; | ||
| 61 | readonly updateBearer: ( | ||
| 62 | input: BearerConnectionUpdateInput, | ||
| 63 | ) => Effect.Effect<void, ConnectionAttemptError | ConnectionPersistenceError>; | ||
| 64 | } | ||
| 65 | >()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {} | ||
| 66 | |||
| 67 | const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.resolvePairingTarget")( | ||
| 68 | function* (input: PairingConnectionInput) { | ||
| 69 | return yield* Effect.try({ | ||
| 70 | try: () => resolveRemotePairingTarget(input), | ||
| 71 | catch: (cause) => | ||
| 72 | new ConnectionBlockedError({ | ||
| 73 | reason: "configuration", | ||
| 74 | message: cause instanceof Error ? cause.message : "The pairing details are invalid.", | ||
| 75 | }), | ||
| 76 | }); | ||
| 77 | }, | ||
| 78 | ); | ||
| 79 | |||
| 80 | export const preparePairingRegistration = Effect.fn( | ||
| 81 | "clientRuntime.connection.onboarding.preparePairingRegistration", | ||
| 82 | )(function* (input: PairingConnectionInput) { | ||
| 83 | const target = yield* resolvePairingTarget(input); | ||
| 84 | const presentation = yield* ClientPresentation; | ||
| 85 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 86 | httpBaseUrl: target.httpBaseUrl, | ||
| 87 | }).pipe(Effect.mapError(mapRemoteEnvironmentError)); | ||
| 88 | const access = yield* bootstrapRemoteBearerSession({ | ||
| 89 | httpBaseUrl: target.httpBaseUrl, | ||
| 90 | credential: target.credential, | ||
| 91 | scopes: presentation.scopes, | ||
| 92 | clientMetadata: presentation.metadata, | ||
| 93 | }).pipe(Effect.mapError(mapRemoteEnvironmentError)); | ||
| 94 | const connectionId = `bearer:${descriptor.environmentId}`; | ||
| 95 | |||
| 96 | return new BearerConnectionRegistration({ | ||
| 97 | target: new BearerConnectionTarget({ | ||
| 98 | environmentId: descriptor.environmentId, | ||
| 99 | label: descriptor.label, | ||
| 100 | connectionId, | ||
| 101 | }), | ||
| 102 | profile: new BearerConnectionProfile({ | ||
| 103 | connectionId, | ||
| 104 | environmentId: descriptor.environmentId, | ||
| 105 | label: descriptor.label, | ||
| 106 | httpBaseUrl: target.httpBaseUrl, | ||
| 107 | wsBaseUrl: target.wsBaseUrl, | ||
| 108 | }), | ||
| 109 | credential: new BearerConnectionCredential({ | ||
| 110 | token: access.access_token, | ||
| 111 | }), | ||
| 112 | }); | ||
| 113 | }); | ||
| 114 | |||
| 115 | export const registerPairingConnection = Effect.fn( | ||
| 116 | "clientRuntime.connection.onboarding.registerPairingConnection", | ||
| 117 | )(function* (input: PairingConnectionInput) { | ||
| 118 | const registration = yield* preparePairingRegistration(input); | ||
| 119 | const registry = yield* EnvironmentRegistry; | ||
| 120 | yield* registry.register(registration); | ||
| 121 | return registration.target.environmentId; | ||
| 122 | }); | ||
| 123 | |||
| 124 | const isBearerCredential = Schema.is(BearerConnectionCredential); | ||
| 125 | const isBearerProfile = Schema.is(BearerConnectionProfile); | ||
| 126 | |||
| 127 | export const updateBearerConnection = Effect.fn( | ||
| 128 | "clientRuntime.connection.onboarding.updateBearerConnection", | ||
| 129 | )(function* (input: BearerConnectionUpdateInput) { | ||
| 130 | const registry = yield* EnvironmentRegistry; | ||
| 131 | const credentials = yield* ConnectionCredentialStore; | ||
| 132 | const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId); | ||
| 133 | const credential = | ||
| 134 | entry?.target._tag === "BearerConnectionTarget" | ||
| 135 | ? yield* credentials.get(entry.target.connectionId) | ||
| 136 | : Option.none(); | ||
| 137 | const registration = yield* prepareBearerConnectionUpdate({ | ||
| 138 | input, | ||
| 139 | entry: Option.fromUndefinedOr(entry), | ||
| 140 | credential, | ||
| 141 | }); | ||
| 142 | yield* registry.register(registration); | ||
| 143 | }); | ||
| 144 | |||
| 145 | export const prepareBearerConnectionUpdate = Effect.fn( | ||
| 146 | "clientRuntime.connection.onboarding.prepareBearerConnectionUpdate", | ||
| 147 | )(function* (options: { | ||
| 148 | readonly input: BearerConnectionUpdateInput; | ||
| 149 | readonly entry: Option.Option<ConnectionCatalogEntry>; | ||
| 150 | readonly credential: Option.Option<ConnectionCredential>; | ||
| 151 | }) { | ||
| 152 | const entry = Option.getOrNull(options.entry); | ||
| 153 | if ( | ||
| 154 | entry === undefined || | ||
| 155 | entry === null || | ||
| 156 | entry.target._tag !== "BearerConnectionTarget" || | ||
| 157 | Option.isNone(entry.profile) || | ||
| 158 | !isBearerProfile(entry.profile.value) | ||
| 159 | ) { | ||
| 160 | return yield* new ConnectionBlockedError({ | ||
| 161 | reason: "configuration", | ||
| 162 | message: "Only saved bearer environments can be edited.", | ||
| 163 | }); | ||
| 164 | } | ||
| 165 | |||
| 166 | const credential = options.credential; | ||
| 167 | if (Option.isNone(credential) || !isBearerCredential(credential.value)) { | ||
| 168 | return yield* new ConnectionBlockedError({ | ||
| 169 | reason: "authentication", | ||
| 170 | message: "The saved bearer credential is unavailable.", | ||
| 171 | }); | ||
| 172 | } | ||
| 173 | |||
| 174 | const label = options.input.label.trim(); | ||
| 175 | if (label === "") { | ||
| 176 | return yield* new ConnectionBlockedError({ | ||
| 177 | reason: "configuration", | ||
| 178 | message: "Environment label cannot be empty.", | ||
| 179 | }); | ||
| 180 | } | ||
| 181 | const httpBaseUrl = yield* Effect.try({ | ||
| 182 | try: () => normalizeHttpBaseUrl(options.input.httpBaseUrl), | ||
| 183 | catch: (cause) => | ||
| 184 | new ConnectionBlockedError({ | ||
| 185 | reason: "configuration", | ||
| 186 | message: cause instanceof Error ? cause.message : "The environment URL is invalid.", | ||
| 187 | }), | ||
| 188 | }); | ||
| 189 | const connectionId = entry.target.connectionId; | ||
| 190 | return new BearerConnectionRegistration({ | ||
| 191 | target: new BearerConnectionTarget({ | ||
| 192 | environmentId: options.input.environmentId, | ||
| 193 | label, | ||
| 194 | connectionId, | ||
| 195 | }), | ||
| 196 | profile: new BearerConnectionProfile({ | ||
| 197 | connectionId, | ||
| 198 | environmentId: options.input.environmentId, | ||
| 199 | label, | ||
| 200 | httpBaseUrl, | ||
| 201 | wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), | ||
| 202 | }), | ||
| 203 | credential: credential.value, | ||
| 204 | }); | ||
| 205 | }); | ||
| 206 | |||
| 207 | export const prepareSshRegistration = Effect.fn( | ||
| 208 | "clientRuntime.connection.onboarding.prepareSshRegistration", | ||
| 209 | )(function* (input: SshConnectionInput) { | ||
| 210 | const gateway = yield* SshEnvironmentGateway; | ||
| 211 | const provisioned = yield* gateway.provision(input.target); | ||
| 212 | const connectionId = `ssh:${provisioned.environmentId}`; | ||
| 213 | const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias; | ||
| 214 | |||
| 215 | return new SshConnectionRegistration({ | ||
| 216 | target: new SshConnectionTarget({ | ||
| 217 | environmentId: provisioned.environmentId, | ||
| 218 | label, | ||
| 219 | connectionId, | ||
| 220 | }), | ||
| 221 | profile: new SshConnectionProfile({ | ||
| 222 | connectionId, | ||
| 223 | environmentId: provisioned.environmentId, | ||
| 224 | label, | ||
| 225 | target: provisioned.bootstrap.target, | ||
| 226 | }), | ||
| 227 | }); | ||
| 228 | }); | ||
| 229 | |||
| 230 | export const registerSshConnection = Effect.fn( | ||
| 231 | "clientRuntime.connection.onboarding.registerSshConnection", | ||
| 232 | )(function* (input: SshConnectionInput) { | ||
| 233 | const registration = yield* prepareSshRegistration(input); | ||
| 234 | const registry = yield* EnvironmentRegistry; | ||
| 235 | yield* registry.register(registration); | ||
| 236 | return registration.target.environmentId; | ||
| 237 | }); | ||
| 238 | |||
| 239 | export const connectionOnboardingLayer = Layer.effect( | ||
| 240 | ConnectionOnboarding, | ||
| 241 | Effect.gen(function* () { | ||
| 242 | const registry = yield* EnvironmentRegistry; | ||
| 243 | const presentation = yield* ClientPresentation; | ||
| 244 | const httpClient = yield* HttpClient.HttpClient; | ||
| 245 | const ssh = yield* SshEnvironmentGateway; | ||
| 246 | const credentials = yield* ConnectionCredentialStore; | ||
| 247 | |||
| 248 | return ConnectionOnboarding.of({ | ||
| 249 | registerPairing: (input) => | ||
| 250 | registerPairingConnection(input).pipe( | ||
| 251 | Effect.provideService(EnvironmentRegistry, registry), | ||
| 252 | Effect.provideService(ClientPresentation, presentation), | ||
| 253 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 254 | ), | ||
| 255 | registerSsh: (input) => | ||
| 256 | registerSshConnection(input).pipe( | ||
| 257 | Effect.provideService(EnvironmentRegistry, registry), | ||
| 258 | Effect.provideService(SshEnvironmentGateway, ssh), | ||
| 259 | ), | ||
| 260 | updateBearer: (input) => | ||
| 261 | updateBearerConnection(input).pipe( | ||
| 262 | Effect.provideService(EnvironmentRegistry, registry), | ||
| 263 | Effect.provideService(ConnectionCredentialStore, credentials), | ||
| 264 | ), | ||
| 265 | }); | ||
| 266 | }), | ||
| 267 | ); | ||
packages/client-runtime/src/connection/presentation.test.ts created+184| ... | @@ -0,0 +1,184 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | |||
| 5 | import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; | ||
| 6 | import { | ||
| 7 | BearerConnectionTarget, | ||
| 8 | ConnectionTransientError, | ||
| 9 | type SupervisorConnectionState, | ||
| 10 | } from "./model.ts"; | ||
| 11 | import { | ||
| 12 | connectionCatalogDisplayUrl, | ||
| 13 | connectionPhaseMessage, | ||
| 14 | connectionStatusText, | ||
| 15 | presentEnvironmentConnection, | ||
| 16 | presentConnectionState, | ||
| 17 | } from "./presentation.ts"; | ||
| 18 | |||
| 19 | const TARGET = new BearerConnectionTarget({ | ||
| 20 | environmentId: EnvironmentId.make("environment-1"), | ||
| 21 | label: "Remote environment", | ||
| 22 | connectionId: "connection-1", | ||
| 23 | }); | ||
| 24 | |||
| 25 | const ENTRY: ConnectionCatalogEntry = { | ||
| 26 | target: TARGET, | ||
| 27 | profile: Option.some( | ||
| 28 | new BearerConnectionProfile({ | ||
| 29 | connectionId: TARGET.connectionId, | ||
| 30 | environmentId: TARGET.environmentId, | ||
| 31 | label: TARGET.label, | ||
| 32 | httpBaseUrl: "https://environment.example.test", | ||
| 33 | wsBaseUrl: "wss://environment.example.test", | ||
| 34 | }), | ||
| 35 | ), | ||
| 36 | }; | ||
| 37 | |||
| 38 | function supervisorState(overrides: Partial<SupervisorConnectionState>): SupervisorConnectionState { | ||
| 39 | return { | ||
| 40 | desired: true, | ||
| 41 | network: "online", | ||
| 42 | phase: "connecting", | ||
| 43 | stage: "preparing", | ||
| 44 | attempt: 1, | ||
| 45 | generation: 0, | ||
| 46 | lastFailure: null, | ||
| 47 | retryAt: null, | ||
| 48 | ...overrides, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | describe("connection presentation", () => { | ||
| 53 | it("preserves profile display information without exposing credentials", () => { | ||
| 54 | expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("distinguishes initial connection, reconnect, and retry errors", () => { | ||
| 58 | expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ | ||
| 59 | phase: "connecting", | ||
| 60 | error: null, | ||
| 61 | traceId: null, | ||
| 62 | }); | ||
| 63 | expect( | ||
| 64 | presentConnectionState( | ||
| 65 | supervisorState({ | ||
| 66 | phase: "connecting", | ||
| 67 | attempt: 2, | ||
| 68 | lastFailure: new ConnectionTransientError({ | ||
| 69 | reason: "transport", | ||
| 70 | message: "Socket closed.", | ||
| 71 | traceId: "trace-previous", | ||
| 72 | }), | ||
| 73 | }), | ||
| 74 | ), | ||
| 75 | ).toEqual({ | ||
| 76 | phase: "reconnecting", | ||
| 77 | error: "Socket closed.", | ||
| 78 | traceId: "trace-previous", | ||
| 79 | }); | ||
| 80 | expect( | ||
| 81 | presentConnectionState( | ||
| 82 | supervisorState({ | ||
| 83 | phase: "backoff", | ||
| 84 | attempt: 2, | ||
| 85 | retryAt: 1, | ||
| 86 | lastFailure: new ConnectionTransientError({ | ||
| 87 | reason: "transport", | ||
| 88 | message: "Disconnected.", | ||
| 89 | traceId: "trace-1", | ||
| 90 | }), | ||
| 91 | }), | ||
| 92 | ), | ||
| 93 | ).toEqual({ | ||
| 94 | phase: "reconnecting", | ||
| 95 | error: "Disconnected.", | ||
| 96 | traceId: "trace-1", | ||
| 97 | }); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("preserves the latest failure while the next attempt is active", () => { | ||
| 101 | expect( | ||
| 102 | presentEnvironmentConnection( | ||
| 103 | supervisorState({ | ||
| 104 | phase: "connecting", | ||
| 105 | stage: "opening", | ||
| 106 | attempt: 2, | ||
| 107 | lastFailure: new ConnectionTransientError({ | ||
| 108 | reason: "transport", | ||
| 109 | message: "Relay connection timed out.", | ||
| 110 | traceId: "trace-retry", | ||
| 111 | }), | ||
| 112 | }), | ||
| 113 | ), | ||
| 114 | ).toEqual({ | ||
| 115 | phase: "reconnecting", | ||
| 116 | error: "Relay connection timed out.", | ||
| 117 | traceId: "trace-retry", | ||
| 118 | }); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("gives offline status precedence in global messaging", () => { | ||
| 122 | expect(connectionPhaseMessage("connected", TARGET.label, "offline")).toBe("You are offline"); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("combines reconnect progress with the latest failure", () => { | ||
| 126 | expect( | ||
| 127 | connectionStatusText({ | ||
| 128 | phase: "reconnecting", | ||
| 129 | error: "Relay request timed out.", | ||
| 130 | traceId: "trace-retry", | ||
| 131 | }), | ||
| 132 | ).toBe("Failed to connect. Reconnecting... Reason: Relay request timed out."); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("presents the supervisor's offline state without consulting shell state", () => { | ||
| 136 | expect( | ||
| 137 | presentEnvironmentConnection( | ||
| 138 | supervisorState({ | ||
| 139 | network: "offline", | ||
| 140 | phase: "offline", | ||
| 141 | stage: null, | ||
| 142 | }), | ||
| 143 | ), | ||
| 144 | ).toEqual({ | ||
| 145 | phase: "offline", | ||
| 146 | error: null, | ||
| 147 | traceId: null, | ||
| 148 | }); | ||
| 149 | }); | ||
| 150 | |||
| 151 | it("presents a connected supervisor snapshot as connected", () => { | ||
| 152 | expect( | ||
| 153 | presentEnvironmentConnection( | ||
| 154 | supervisorState({ | ||
| 155 | phase: "connected", | ||
| 156 | stage: null, | ||
| 157 | generation: 1, | ||
| 158 | }), | ||
| 159 | ), | ||
| 160 | ).toEqual({ | ||
| 161 | phase: "connected", | ||
| 162 | error: null, | ||
| 163 | traceId: null, | ||
| 164 | }); | ||
| 165 | }); | ||
| 166 | |||
| 167 | it("preserves an explicitly available environment while offline", () => { | ||
| 168 | expect( | ||
| 169 | presentEnvironmentConnection( | ||
| 170 | supervisorState({ | ||
| 171 | desired: false, | ||
| 172 | network: "offline", | ||
| 173 | phase: "available", | ||
| 174 | stage: null, | ||
| 175 | attempt: 0, | ||
| 176 | }), | ||
| 177 | ), | ||
| 178 | ).toEqual({ | ||
| 179 | phase: "available", | ||
| 180 | error: null, | ||
| 181 | traceId: null, | ||
| 182 | }); | ||
| 183 | }); | ||
| 184 | }); | ||
packages/client-runtime/src/connection/presentation.ts created+122| ... | @@ -0,0 +1,122 @@ | ||
| 1 | import type { ServerConfig } from "@t3tools/contracts"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | |||
| 4 | import type { ConnectionCatalogEntry } from "./catalog.ts"; | ||
| 5 | import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; | ||
| 6 | |||
| 7 | export type EnvironmentConnectionPhase = | ||
| 8 | | "available" | ||
| 9 | | "offline" | ||
| 10 | | "connecting" | ||
| 11 | | "reconnecting" | ||
| 12 | | "connected" | ||
| 13 | | "error"; | ||
| 14 | |||
| 15 | export interface EnvironmentConnectionPresentation { | ||
| 16 | readonly phase: EnvironmentConnectionPhase; | ||
| 17 | readonly error: string | null; | ||
| 18 | readonly traceId: string | null; | ||
| 19 | } | ||
| 20 | |||
| 21 | export interface EnvironmentPresentation { | ||
| 22 | readonly entry: ConnectionCatalogEntry; | ||
| 23 | readonly connection: EnvironmentConnectionPresentation; | ||
| 24 | readonly serverConfig: ServerConfig | null; | ||
| 25 | } | ||
| 26 | |||
| 27 | export function presentConnectionState( | ||
| 28 | state: SupervisorConnectionState, | ||
| 29 | ): EnvironmentConnectionPresentation { | ||
| 30 | switch (state.phase) { | ||
| 31 | case "available": | ||
| 32 | return { phase: "available", error: null, traceId: null }; | ||
| 33 | case "offline": | ||
| 34 | return { phase: "offline", error: null, traceId: null }; | ||
| 35 | case "connecting": | ||
| 36 | return { | ||
| 37 | phase: state.attempt <= 1 && state.lastFailure === null ? "connecting" : "reconnecting", | ||
| 38 | error: state.lastFailure?.message ?? null, | ||
| 39 | traceId: state.lastFailure?.traceId ?? null, | ||
| 40 | }; | ||
| 41 | case "connected": | ||
| 42 | return { phase: "connected", error: null, traceId: null }; | ||
| 43 | case "backoff": | ||
| 44 | return { | ||
| 45 | phase: "reconnecting", | ||
| 46 | error: state.lastFailure?.message ?? null, | ||
| 47 | traceId: state.lastFailure?.traceId ?? null, | ||
| 48 | }; | ||
| 49 | case "blocked": | ||
| 50 | return { | ||
| 51 | phase: "error", | ||
| 52 | error: state.lastFailure?.message ?? null, | ||
| 53 | traceId: state.lastFailure?.traceId ?? null, | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | export function connectionStatusText(connection: EnvironmentConnectionPresentation): string { | ||
| 59 | switch (connection.phase) { | ||
| 60 | case "available": | ||
| 61 | return "Available"; | ||
| 62 | case "offline": | ||
| 63 | return "Offline"; | ||
| 64 | case "connecting": | ||
| 65 | return "Connecting..."; | ||
| 66 | case "reconnecting": | ||
| 67 | return connection.error | ||
| 68 | ? `Failed to connect. Reconnecting... Reason: ${connection.error}` | ||
| 69 | : "Reconnecting..."; | ||
| 70 | case "connected": | ||
| 71 | return "Connected"; | ||
| 72 | case "error": | ||
| 73 | return connection.error | ||
| 74 | ? `Connection failed. Reason: ${connection.error}` | ||
| 75 | : "Connection failed"; | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | export function presentEnvironmentConnection( | ||
| 80 | state: SupervisorConnectionState, | ||
| 81 | ): EnvironmentConnectionPresentation { | ||
| 82 | return presentConnectionState(state); | ||
| 83 | } | ||
| 84 | |||
| 85 | export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { | ||
| 86 | switch (entry.target._tag) { | ||
| 87 | case "PrimaryConnectionTarget": | ||
| 88 | return entry.target.httpBaseUrl; | ||
| 89 | case "RelayConnectionTarget": | ||
| 90 | return null; | ||
| 91 | case "BearerConnectionTarget": | ||
| 92 | return Option.isSome(entry.profile) && entry.profile.value._tag === "BearerConnectionProfile" | ||
| 93 | ? entry.profile.value.httpBaseUrl | ||
| 94 | : null; | ||
| 95 | case "SshConnectionTarget": | ||
| 96 | return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile" | ||
| 97 | ? `${entry.profile.value.target.username}@${entry.profile.value.target.hostname}` | ||
| 98 | : null; | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | export function connectionPhaseMessage( | ||
| 103 | phase: EnvironmentConnectionPhase, | ||
| 104 | label: string, | ||
| 105 | networkStatus: NetworkStatus, | ||
| 106 | ): string { | ||
| 107 | if (networkStatus === "offline" || phase === "offline") { | ||
| 108 | return "You are offline"; | ||
| 109 | } | ||
| 110 | switch (phase) { | ||
| 111 | case "available": | ||
| 112 | return "Available"; | ||
| 113 | case "connecting": | ||
| 114 | return `Connecting to ${label}...`; | ||
| 115 | case "reconnecting": | ||
| 116 | return `Reconnecting to ${label}...`; | ||
| 117 | case "connected": | ||
| 118 | return "Connected"; | ||
| 119 | case "error": | ||
| 120 | return "Connection failed"; | ||
| 121 | } | ||
| 122 | } | ||
packages/client-runtime/src/connection/registry.test.ts created+944| ... | @@ -0,0 +1,944 @@ | ||
| 1 | import { | ||
| 2 | type DesktopSshEnvironmentTarget, | ||
| 3 | EnvironmentId, | ||
| 4 | type OrchestrationShellSnapshot, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Deferred from "effect/Deferred"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Fiber from "effect/Fiber"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Ref from "effect/Ref"; | ||
| 13 | import * as Result from "effect/Result"; | ||
| 14 | import * as Stream from "effect/Stream"; | ||
| 15 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 16 | |||
| 17 | import { SshEnvironmentGateway } from "../platform/capabilities.ts"; | ||
| 18 | import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "../authorization/tokenStore.ts"; | ||
| 19 | import { | ||
| 20 | BearerConnectionCredential, | ||
| 21 | BearerConnectionProfile, | ||
| 22 | BearerConnectionRegistration, | ||
| 23 | type ConnectionRegistration, | ||
| 24 | ConnectionCredentialStore, | ||
| 25 | ConnectionProfileStore, | ||
| 26 | PrimaryConnectionRegistration, | ||
| 27 | RelayConnectionRegistration, | ||
| 28 | SshConnectionProfile, | ||
| 29 | type ConnectionCredential, | ||
| 30 | type ConnectionProfile, | ||
| 31 | } from "./catalog.ts"; | ||
| 32 | import { Connectivity } from "./connectivity.ts"; | ||
| 33 | import { ConnectionDriver } from "./driver.ts"; | ||
| 34 | import { | ||
| 35 | ConnectionTransientError, | ||
| 36 | BearerConnectionTarget, | ||
| 37 | PrimaryConnectionTarget, | ||
| 38 | RelayConnectionTarget, | ||
| 39 | SshConnectionTarget, | ||
| 40 | type ConnectionTarget, | ||
| 41 | type PreparedConnection, | ||
| 42 | type SupervisorConnectionState, | ||
| 43 | } from "./model.ts"; | ||
| 44 | import { | ||
| 45 | ConnectionPersistenceError, | ||
| 46 | ConnectionRegistrationStore, | ||
| 47 | ConnectionTargetStore, | ||
| 48 | EnvironmentCacheStore, | ||
| 49 | EnvironmentOwnedDataCleanup, | ||
| 50 | } from "../platform/persistence.ts"; | ||
| 51 | import { EnvironmentRegistry, environmentRegistryLayer } from "./registry.ts"; | ||
| 52 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 53 | import { EnvironmentSupervisor } from "./supervisor.ts"; | ||
| 54 | import { ConnectionWakeups } from "./wakeups.ts"; | ||
| 55 | |||
| 56 | const TARGET = new PrimaryConnectionTarget({ | ||
| 57 | environmentId: EnvironmentId.make("environment-1"), | ||
| 58 | label: "Test environment", | ||
| 59 | httpBaseUrl: "https://environment.example.test", | ||
| 60 | wsBaseUrl: "wss://environment.example.test", | ||
| 61 | }); | ||
| 62 | const SECOND_TARGET = new PrimaryConnectionTarget({ | ||
| 63 | environmentId: EnvironmentId.make("environment-2"), | ||
| 64 | label: "Second environment", | ||
| 65 | httpBaseUrl: "https://environment-2.example.test", | ||
| 66 | wsBaseUrl: "wss://environment-2.example.test", | ||
| 67 | }); | ||
| 68 | |||
| 69 | const PREPARED: PreparedConnection = { | ||
| 70 | environmentId: TARGET.environmentId, | ||
| 71 | label: TARGET.label, | ||
| 72 | httpBaseUrl: TARGET.httpBaseUrl, | ||
| 73 | socketUrl: "wss://environment.example.test/ws", | ||
| 74 | httpAuthorization: null, | ||
| 75 | target: TARGET, | ||
| 76 | }; | ||
| 77 | |||
| 78 | const RELAY_TARGET = new RelayConnectionTarget({ | ||
| 79 | environmentId: EnvironmentId.make("environment-relay"), | ||
| 80 | label: "Relay environment", | ||
| 81 | }); | ||
| 82 | const SECOND_RELAY_TARGET = new RelayConnectionTarget({ | ||
| 83 | environmentId: EnvironmentId.make("environment-relay-2"), | ||
| 84 | label: "Second relay environment", | ||
| 85 | }); | ||
| 86 | |||
| 87 | const BEARER_TARGET = new BearerConnectionTarget({ | ||
| 88 | environmentId: EnvironmentId.make("environment-bearer"), | ||
| 89 | label: "Bearer environment", | ||
| 90 | connectionId: "bearer-connection", | ||
| 91 | }); | ||
| 92 | const BEARER_PROFILE = new BearerConnectionProfile({ | ||
| 93 | connectionId: BEARER_TARGET.connectionId, | ||
| 94 | environmentId: BEARER_TARGET.environmentId, | ||
| 95 | label: BEARER_TARGET.label, | ||
| 96 | httpBaseUrl: "https://bearer.example.test", | ||
| 97 | wsBaseUrl: "wss://bearer.example.test", | ||
| 98 | }); | ||
| 99 | const BEARER_CREDENTIAL = new BearerConnectionCredential({ | ||
| 100 | token: "bearer-token", | ||
| 101 | }); | ||
| 102 | |||
| 103 | const SSH_TARGET: DesktopSshEnvironmentTarget = { | ||
| 104 | alias: "test", | ||
| 105 | hostname: "test.example.test", | ||
| 106 | username: "developer", | ||
| 107 | port: 22, | ||
| 108 | }; | ||
| 109 | const SSH_CONNECTION = new SshConnectionTarget({ | ||
| 110 | environmentId: EnvironmentId.make("environment-ssh"), | ||
| 111 | label: "SSH environment", | ||
| 112 | connectionId: "ssh-connection", | ||
| 113 | }); | ||
| 114 | const SSH_PROFILE = new SshConnectionProfile({ | ||
| 115 | connectionId: SSH_CONNECTION.connectionId, | ||
| 116 | environmentId: SSH_CONNECTION.environmentId, | ||
| 117 | label: SSH_CONNECTION.label, | ||
| 118 | target: SSH_TARGET, | ||
| 119 | }); | ||
| 120 | |||
| 121 | const CACHED_SNAPSHOT: OrchestrationShellSnapshot = { | ||
| 122 | snapshotSequence: 1, | ||
| 123 | projects: [], | ||
| 124 | threads: [], | ||
| 125 | updatedAt: "2026-06-06T00:00:00.000Z", | ||
| 126 | }; | ||
| 127 | |||
| 128 | interface SessionControl { | ||
| 129 | readonly closed: Deferred.Deferred<never, ConnectionTransientError>; | ||
| 130 | } | ||
| 131 | |||
| 132 | const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( | ||
| 133 | initialTargets: ReadonlyArray<ConnectionTarget>, | ||
| 134 | initialProfiles: ReadonlyArray<ConnectionProfile> = [], | ||
| 135 | initialCredentials: ReadonlyArray<readonly [string, ConnectionCredential]> = [], | ||
| 136 | options?: { | ||
| 137 | readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect<void>; | ||
| 138 | readonly beforeRegistrationRegister?: ( | ||
| 139 | registration: ConnectionRegistration, | ||
| 140 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 141 | readonly beforeRegistrationRemove?: ( | ||
| 142 | target: ConnectionTarget, | ||
| 143 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 144 | }, | ||
| 145 | ) { | ||
| 146 | const storedTargets = yield* Ref.make( | ||
| 147 | new Map(initialTargets.map((target) => [target.environmentId, target])), | ||
| 148 | ); | ||
| 149 | const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]])); | ||
| 150 | const cacheClears = yield* Ref.make<ReadonlyArray<EnvironmentId>>([]); | ||
| 151 | const ownedDataClears = yield* Ref.make<ReadonlyArray<EnvironmentId>>([]); | ||
| 152 | const sessions = yield* Ref.make<ReadonlyArray<SessionControl>>([]); | ||
| 153 | const releasedSessions = yield* Ref.make(0); | ||
| 154 | const storedProfiles = yield* Ref.make( | ||
| 155 | new Map(initialProfiles.map((profile) => [profile.connectionId, profile])), | ||
| 156 | ); | ||
| 157 | const profileReadCount = yield* Ref.make(0); | ||
| 158 | const storedCredentials = yield* Ref.make(new Map(initialCredentials)); | ||
| 159 | const storedRemoteTokens = yield* Ref.make( | ||
| 160 | new Map([ | ||
| 161 | [ | ||
| 162 | SSH_CONNECTION.environmentId, | ||
| 163 | new RemoteDpopAccessToken({ | ||
| 164 | environmentId: SSH_CONNECTION.environmentId, | ||
| 165 | label: SSH_CONNECTION.label, | ||
| 166 | endpoint: { | ||
| 167 | httpBaseUrl: "https://ssh.example.test", | ||
| 168 | wsBaseUrl: "wss://ssh.example.test", | ||
| 169 | providerKind: "cloudflare_tunnel", | ||
| 170 | }, | ||
| 171 | accessToken: "cached-token", | ||
| 172 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | ||
| 173 | dpopThumbprint: "thumbprint", | ||
| 174 | }), | ||
| 175 | ], | ||
| 176 | ]), | ||
| 177 | ); | ||
| 178 | const disconnectedSshTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]); | ||
| 179 | |||
| 180 | const targetStore = ConnectionTargetStore.of({ | ||
| 181 | list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), | ||
| 182 | }); | ||
| 183 | const registrationStore = ConnectionRegistrationStore.of({ | ||
| 184 | register: (registration) => | ||
| 185 | Effect.gen(function* () { | ||
| 186 | yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void; | ||
| 187 | yield* Ref.update(storedTargets, (current) => { | ||
| 188 | const next = new Map(current); | ||
| 189 | next.set(registration.target.environmentId, registration.target); | ||
| 190 | return next; | ||
| 191 | }); | ||
| 192 | switch (registration._tag) { | ||
| 193 | case "RelayConnectionRegistration": | ||
| 194 | return; | ||
| 195 | case "BearerConnectionRegistration": | ||
| 196 | yield* Ref.update(storedProfiles, (current) => { | ||
| 197 | const next = new Map(current); | ||
| 198 | next.set(registration.profile.connectionId, registration.profile); | ||
| 199 | return next; | ||
| 200 | }); | ||
| 201 | yield* Ref.update(storedCredentials, (current) => { | ||
| 202 | const next = new Map(current); | ||
| 203 | next.set(registration.target.connectionId, registration.credential); | ||
| 204 | return next; | ||
| 205 | }); | ||
| 206 | return; | ||
| 207 | case "SshConnectionRegistration": | ||
| 208 | yield* Ref.update(storedProfiles, (current) => { | ||
| 209 | const next = new Map(current); | ||
| 210 | next.set(registration.profile.connectionId, registration.profile); | ||
| 211 | return next; | ||
| 212 | }); | ||
| 213 | } | ||
| 214 | }), | ||
| 215 | remove: (target) => | ||
| 216 | Effect.gen(function* () { | ||
| 217 | yield* options?.beforeRegistrationRemove?.(target) ?? Effect.void; | ||
| 218 | yield* Ref.update(storedTargets, (current) => { | ||
| 219 | const next = new Map(current); | ||
| 220 | next.delete(target.environmentId); | ||
| 221 | return next; | ||
| 222 | }); | ||
| 223 | if (target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget") { | ||
| 224 | yield* Ref.update(storedProfiles, (current) => { | ||
| 225 | const next = new Map(current); | ||
| 226 | next.delete(target.connectionId); | ||
| 227 | return next; | ||
| 228 | }); | ||
| 229 | yield* Ref.update(storedCredentials, (current) => { | ||
| 230 | const next = new Map(current); | ||
| 231 | next.delete(target.connectionId); | ||
| 232 | return next; | ||
| 233 | }); | ||
| 234 | } | ||
| 235 | yield* Ref.update(storedRemoteTokens, (current) => { | ||
| 236 | const next = new Map(current); | ||
| 237 | next.delete(target.environmentId); | ||
| 238 | return next; | ||
| 239 | }); | ||
| 240 | }), | ||
| 241 | }); | ||
| 242 | const cacheStore = EnvironmentCacheStore.of({ | ||
| 243 | loadShell: (environmentId) => | ||
| 244 | Ref.get(shellCache).pipe( | ||
| 245 | Effect.map((cache) => Option.fromUndefinedOr(cache.get(environmentId))), | ||
| 246 | ), | ||
| 247 | saveShell: (environmentId, snapshot) => | ||
| 248 | Ref.update(shellCache, (current) => { | ||
| 249 | const next = new Map(current); | ||
| 250 | next.set(environmentId, snapshot); | ||
| 251 | return next; | ||
| 252 | }), | ||
| 253 | loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()), | ||
| 254 | saveThread: (_environmentId, _thread) => Effect.void, | ||
| 255 | removeThread: (_environmentId, _threadId) => Effect.void, | ||
| 256 | clear: (environmentId) => | ||
| 257 | Ref.update(shellCache, (current) => { | ||
| 258 | const next = new Map(current); | ||
| 259 | next.delete(environmentId); | ||
| 260 | return next; | ||
| 261 | }).pipe( | ||
| 262 | Effect.andThen( | ||
| 263 | Ref.update(cacheClears, (environmentIds) => [...environmentIds, environmentId]), | ||
| 264 | ), | ||
| 265 | ), | ||
| 266 | }); | ||
| 267 | const ownedDataCleanup = EnvironmentOwnedDataCleanup.of({ | ||
| 268 | clear: (environmentId) => | ||
| 269 | Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), | ||
| 270 | }); | ||
| 271 | const networkStatus = yield* SubscriptionRef.make<"unknown" | "offline" | "online">("online"); | ||
| 272 | const connectivity = Connectivity.of({ | ||
| 273 | status: SubscriptionRef.get(networkStatus), | ||
| 274 | changes: SubscriptionRef.changes(networkStatus), | ||
| 275 | }); | ||
| 276 | const profileStore = ConnectionProfileStore.of({ | ||
| 277 | get: (connectionId) => | ||
| 278 | Ref.update(profileReadCount, (count) => count + 1).pipe( | ||
| 279 | Effect.andThen(Ref.get(storedProfiles)), | ||
| 280 | Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), | ||
| 281 | ), | ||
| 282 | put: (profile) => | ||
| 283 | Ref.update(storedProfiles, (current) => { | ||
| 284 | const next = new Map(current); | ||
| 285 | next.set(profile.connectionId, profile); | ||
| 286 | return next; | ||
| 287 | }), | ||
| 288 | remove: (connectionId) => | ||
| 289 | Ref.update(storedProfiles, (current) => { | ||
| 290 | const next = new Map(current); | ||
| 291 | next.delete(connectionId); | ||
| 292 | return next; | ||
| 293 | }), | ||
| 294 | }); | ||
| 295 | const credentialStore = ConnectionCredentialStore.of({ | ||
| 296 | get: (connectionId) => | ||
| 297 | Ref.get(storedCredentials).pipe( | ||
| 298 | Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), | ||
| 299 | ), | ||
| 300 | put: (connectionId, credential) => | ||
| 301 | Ref.update(storedCredentials, (current) => { | ||
| 302 | const next = new Map(current); | ||
| 303 | next.set(connectionId, credential); | ||
| 304 | return next; | ||
| 305 | }), | ||
| 306 | remove: (connectionId) => | ||
| 307 | Ref.update(storedCredentials, (current) => { | ||
| 308 | const next = new Map(current); | ||
| 309 | next.delete(connectionId); | ||
| 310 | return next; | ||
| 311 | }), | ||
| 312 | }); | ||
| 313 | const tokenStore = RemoteDpopAccessTokenStore.of({ | ||
| 314 | get: (environmentId) => | ||
| 315 | Ref.get(storedRemoteTokens).pipe( | ||
| 316 | Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), | ||
| 317 | ), | ||
| 318 | put: (token) => | ||
| 319 | Ref.update(storedRemoteTokens, (current) => { | ||
| 320 | const next = new Map(current); | ||
| 321 | next.set(token.environmentId, token); | ||
| 322 | return next; | ||
| 323 | }), | ||
| 324 | remove: (environmentId) => | ||
| 325 | Ref.update(storedRemoteTokens, (current) => { | ||
| 326 | const next = new Map(current); | ||
| 327 | next.delete(environmentId); | ||
| 328 | return next; | ||
| 329 | }), | ||
| 330 | }); | ||
| 331 | const sshGateway = SshEnvironmentGateway.of({ | ||
| 332 | provision: () => Effect.die(new Error("SSH provisioning is not used.")), | ||
| 333 | prepare: () => Effect.die(new Error("SSH preparation is not used.")), | ||
| 334 | disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), | ||
| 335 | }); | ||
| 336 | const driver = ConnectionDriver.of({ | ||
| 337 | connect: (entry, reportProgress) => | ||
| 338 | Effect.gen(function* () { | ||
| 339 | const target = entry.target; | ||
| 340 | const prepared = { | ||
| 341 | ...PREPARED, | ||
| 342 | environmentId: target.environmentId, | ||
| 343 | label: target.label, | ||
| 344 | target, | ||
| 345 | }; | ||
| 346 | yield* reportProgress({ stage: "preparing" }); | ||
| 347 | yield* reportProgress({ stage: "opening", prepared }); | ||
| 348 | yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void; | ||
| 349 | const closed = yield* Deferred.make<never, ConnectionTransientError>(); | ||
| 350 | yield* Ref.update(sessions, (current) => [...current, { closed }]); | ||
| 351 | const session = yield* Effect.acquireRelease( | ||
| 352 | Effect.succeed({ | ||
| 353 | client: {} as RpcSession["client"], | ||
| 354 | initialConfig: Effect.die(new Error("Config is not used by registry tests.")), | ||
| 355 | ready: Effect.void, | ||
| 356 | probe: Effect.void, | ||
| 357 | closed: Deferred.await(closed), | ||
| 358 | } satisfies RpcSession), | ||
| 359 | () => Ref.update(releasedSessions, (count) => count + 1), | ||
| 360 | ); | ||
| 361 | yield* reportProgress({ stage: "synchronizing", prepared }); | ||
| 362 | yield* session.ready; | ||
| 363 | return { prepared, session }; | ||
| 364 | }), | ||
| 365 | }); | ||
| 366 | |||
| 367 | const cacheLayer = Layer.succeed(EnvironmentCacheStore, cacheStore); | ||
| 368 | const layer = environmentRegistryLayer.pipe( | ||
| 369 | Layer.provide( | ||
| 370 | Layer.mergeAll( | ||
| 371 | Layer.succeed(ConnectionTargetStore, targetStore), | ||
| 372 | Layer.succeed(ConnectionRegistrationStore, registrationStore), | ||
| 373 | Layer.succeed(ConnectionProfileStore, profileStore), | ||
| 374 | Layer.succeed(ConnectionCredentialStore, credentialStore), | ||
| 375 | Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), | ||
| 376 | Layer.succeed(SshEnvironmentGateway, sshGateway), | ||
| 377 | Layer.succeed(Connectivity, connectivity), | ||
| 378 | Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), | ||
| 379 | Layer.succeed(ConnectionDriver, driver), | ||
| 380 | cacheLayer, | ||
| 381 | Layer.succeed(EnvironmentOwnedDataCleanup, ownedDataCleanup), | ||
| 382 | ), | ||
| 383 | ), | ||
| 384 | ); | ||
| 385 | |||
| 386 | return { | ||
| 387 | layer, | ||
| 388 | storedTargets, | ||
| 389 | shellCache, | ||
| 390 | cacheClears, | ||
| 391 | ownedDataClears, | ||
| 392 | sessions, | ||
| 393 | releasedSessions, | ||
| 394 | storedProfiles, | ||
| 395 | profileReadCount, | ||
| 396 | storedCredentials, | ||
| 397 | storedRemoteTokens, | ||
| 398 | disconnectedSshTargets, | ||
| 399 | networkStatus, | ||
| 400 | }; | ||
| 401 | }); | ||
| 402 | |||
| 403 | function awaitConnectionState( | ||
| 404 | registry: EnvironmentRegistry["Service"], | ||
| 405 | environmentId: EnvironmentId, | ||
| 406 | predicate: (state: SupervisorConnectionState) => boolean, | ||
| 407 | ) { | ||
| 408 | return Effect.gen(function* () { | ||
| 409 | const current = yield* registry.state(environmentId); | ||
| 410 | if (predicate(current)) { | ||
| 411 | return current; | ||
| 412 | } | ||
| 413 | return yield* registry | ||
| 414 | .stateChanges(environmentId) | ||
| 415 | .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); | ||
| 416 | }); | ||
| 417 | } | ||
| 418 | |||
| 419 | describe("EnvironmentRegistry", () => { | ||
| 420 | it.effect("hydrates connection profiles into catalog entries", () => | ||
| 421 | Effect.gen(function* () { | ||
| 422 | const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); | ||
| 423 | |||
| 424 | yield* Effect.gen(function* () { | ||
| 425 | const registry = yield* EnvironmentRegistry; | ||
| 426 | const entry = (yield* SubscriptionRef.get(registry.entries)).get( | ||
| 427 | SSH_CONNECTION.environmentId, | ||
| 428 | ); | ||
| 429 | |||
| 430 | expect(entry?.target).toEqual(SSH_CONNECTION); | ||
| 431 | expect(Option.getOrThrow(entry?.profile ?? Option.none())).toEqual(SSH_PROFILE); | ||
| 432 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 433 | }), | ||
| 434 | ); | ||
| 435 | |||
| 436 | it.effect("publishes network status changes independently of connection state", () => | ||
| 437 | Effect.gen(function* () { | ||
| 438 | const harness = yield* makeHarness([]); | ||
| 439 | |||
| 440 | yield* Effect.gen(function* () { | ||
| 441 | const registry = yield* EnvironmentRegistry; | ||
| 442 | const offline = yield* Effect.forkChild( | ||
| 443 | SubscriptionRef.changes(registry.networkStatus).pipe( | ||
| 444 | Stream.filter((status) => status === "offline"), | ||
| 445 | Stream.runHead, | ||
| 446 | Effect.map(Option.getOrThrow), | ||
| 447 | ), | ||
| 448 | ); | ||
| 449 | |||
| 450 | yield* SubscriptionRef.set(harness.networkStatus, "offline"); | ||
| 451 | |||
| 452 | expect(yield* Fiber.join(offline)).toBe("offline"); | ||
| 453 | expect(yield* SubscriptionRef.get(registry.networkStatus)).toBe("offline"); | ||
| 454 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 455 | }), | ||
| 456 | ); | ||
| 457 | |||
| 458 | it.effect("starts persisted environments independently", () => | ||
| 459 | Effect.gen(function* () { | ||
| 460 | const bothLoadsStarted = yield* Deferred.make<void>(); | ||
| 461 | const releaseLoads = yield* Deferred.make<void>(); | ||
| 462 | const loadCount = yield* Ref.make(0); | ||
| 463 | const harness = yield* makeHarness([TARGET, SECOND_TARGET], [], [], { | ||
| 464 | beforeSessionConnect: () => | ||
| 465 | Ref.updateAndGet(loadCount, (count) => count + 1).pipe( | ||
| 466 | Effect.tap((count) => | ||
| 467 | count === 2 ? Deferred.succeed(bothLoadsStarted, undefined) : Effect.void, | ||
| 468 | ), | ||
| 469 | Effect.andThen(Deferred.await(releaseLoads)), | ||
| 470 | ), | ||
| 471 | }); | ||
| 472 | |||
| 473 | yield* Effect.gen(function* () { | ||
| 474 | const registry = yield* EnvironmentRegistry; | ||
| 475 | const start = yield* Effect.forkChild(registry.start); | ||
| 476 | |||
| 477 | yield* Deferred.await(bothLoadsStarted).pipe(Effect.timeout("1 second")); | ||
| 478 | yield* Deferred.succeed(releaseLoads, undefined); | ||
| 479 | yield* Fiber.join(start); | ||
| 480 | |||
| 481 | expect(yield* Ref.get(loadCount)).toBe(2); | ||
| 482 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 483 | }), | ||
| 484 | ); | ||
| 485 | |||
| 486 | it.effect("exposes the current RPC generation to late query subscribers", () => | ||
| 487 | Effect.gen(function* () { | ||
| 488 | const harness = yield* makeHarness([TARGET]); | ||
| 489 | yield* Effect.gen(function* () { | ||
| 490 | const registry = yield* EnvironmentRegistry; | ||
| 491 | yield* registry.start; | ||
| 492 | yield* awaitConnectionState( | ||
| 493 | registry, | ||
| 494 | TARGET.environmentId, | ||
| 495 | (state) => state.phase === "connected", | ||
| 496 | ); | ||
| 497 | |||
| 498 | const generation = yield* registry | ||
| 499 | .runStream( | ||
| 500 | TARGET.environmentId, | ||
| 501 | Stream.unwrap( | ||
| 502 | EnvironmentSupervisor.pipe( | ||
| 503 | Effect.map((supervisor) => | ||
| 504 | Stream.concat( | ||
| 505 | Stream.fromEffect(SubscriptionRef.get(supervisor.state)), | ||
| 506 | SubscriptionRef.changes(supervisor.state), | ||
| 507 | ).pipe( | ||
| 508 | Stream.filterMap((state) => | ||
| 509 | state.phase === "connected" | ||
| 510 | ? Result.succeed(state.generation) | ||
| 511 | : Result.failVoid, | ||
| 512 | ), | ||
| 513 | Stream.changes, | ||
| 514 | ), | ||
| 515 | ), | ||
| 516 | ), | ||
| 517 | ), | ||
| 518 | ) | ||
| 519 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | ||
| 520 | |||
| 521 | expect(generation).toBe(1); | ||
| 522 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 523 | }), | ||
| 524 | ); | ||
| 525 | |||
| 526 | it.effect("preserves cached data on connection failure and clears it on explicit removal", () => | ||
| 527 | Effect.gen(function* () { | ||
| 528 | const harness = yield* makeHarness([TARGET]); | ||
| 529 | yield* Effect.gen(function* () { | ||
| 530 | const registry = yield* EnvironmentRegistry; | ||
| 531 | yield* registry.start; | ||
| 532 | yield* awaitConnectionState( | ||
| 533 | registry, | ||
| 534 | TARGET.environmentId, | ||
| 535 | (state) => state.phase === "connected", | ||
| 536 | ); | ||
| 537 | const controls = yield* Ref.get(harness.sessions); | ||
| 538 | expect(controls).toHaveLength(1); | ||
| 539 | const active = controls[0]; | ||
| 540 | expect(active).toBeDefined(); | ||
| 541 | expect((yield* Ref.get(harness.shellCache)).get(TARGET.environmentId)).toEqual( | ||
| 542 | CACHED_SNAPSHOT, | ||
| 543 | ); | ||
| 544 | |||
| 545 | const retryFiber = yield* Effect.forkChild( | ||
| 546 | awaitConnectionState( | ||
| 547 | registry, | ||
| 548 | TARGET.environmentId, | ||
| 549 | (state) => state.phase === "backoff", | ||
| 550 | ), | ||
| 551 | ); | ||
| 552 | yield* Effect.yieldNow; | ||
| 553 | yield* Deferred.fail( | ||
| 554 | active!.closed, | ||
| 555 | new ConnectionTransientError({ | ||
| 556 | reason: "transport", | ||
| 557 | message: "Disconnected.", | ||
| 558 | }), | ||
| 559 | ); | ||
| 560 | yield* Fiber.join(retryFiber); | ||
| 561 | expect((yield* Ref.get(harness.shellCache)).get(TARGET.environmentId)).toEqual( | ||
| 562 | CACHED_SNAPSHOT, | ||
| 563 | ); | ||
| 564 | |||
| 565 | yield* registry.remove(TARGET.environmentId); | ||
| 566 | expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); | ||
| 567 | expect((yield* Ref.get(harness.shellCache)).has(TARGET.environmentId)).toBe(false); | ||
| 568 | expect(yield* Ref.get(harness.cacheClears)).toEqual([TARGET.environmentId]); | ||
| 569 | expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe( | ||
| 570 | false, | ||
| 571 | ); | ||
| 572 | }).pipe(Effect.provide(harness.layer)); | ||
| 573 | }), | ||
| 574 | ); | ||
| 575 | |||
| 576 | it.effect("persists and starts a newly registered environment", () => | ||
| 577 | Effect.gen(function* () { | ||
| 578 | const harness = yield* makeHarness([]); | ||
| 579 | |||
| 580 | yield* Effect.gen(function* () { | ||
| 581 | const registry = yield* EnvironmentRegistry; | ||
| 582 | yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET })); | ||
| 583 | yield* awaitConnectionState( | ||
| 584 | registry, | ||
| 585 | RELAY_TARGET.environmentId, | ||
| 586 | (state) => state.phase === "connected", | ||
| 587 | ); | ||
| 588 | |||
| 589 | expect((yield* Ref.get(harness.storedTargets)).get(RELAY_TARGET.environmentId)).toEqual( | ||
| 590 | RELAY_TARGET, | ||
| 591 | ); | ||
| 592 | expect(yield* Ref.get(harness.sessions)).toHaveLength(1); | ||
| 593 | }).pipe(Effect.provide(harness.layer)); | ||
| 594 | }), | ||
| 595 | ); | ||
| 596 | |||
| 597 | it.effect("moves durable streams to a replacement supervisor", () => | ||
| 598 | Effect.gen(function* () { | ||
| 599 | const replacement = new RelayConnectionTarget({ | ||
| 600 | environmentId: RELAY_TARGET.environmentId, | ||
| 601 | label: "Replacement relay environment", | ||
| 602 | }); | ||
| 603 | const harness = yield* makeHarness([RELAY_TARGET]); | ||
| 604 | |||
| 605 | yield* Effect.gen(function* () { | ||
| 606 | const registry = yield* EnvironmentRegistry; | ||
| 607 | const firstObserved = yield* Deferred.make<void>(); | ||
| 608 | const secondObserved = yield* Deferred.make<void>(); | ||
| 609 | const labels = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 610 | yield* registry.start; | ||
| 611 | yield* awaitConnectionState( | ||
| 612 | registry, | ||
| 613 | RELAY_TARGET.environmentId, | ||
| 614 | (state) => state.phase === "connected", | ||
| 615 | ); | ||
| 616 | |||
| 617 | const subscription = yield* Effect.forkChild( | ||
| 618 | registry | ||
| 619 | .followStream( | ||
| 620 | RELAY_TARGET.environmentId, | ||
| 621 | Stream.unwrap( | ||
| 622 | EnvironmentSupervisor.pipe( | ||
| 623 | Effect.map((supervisor) => | ||
| 624 | Stream.concat(Stream.succeed(supervisor.target.label), Stream.never), | ||
| 625 | ), | ||
| 626 | ), | ||
| 627 | ), | ||
| 628 | ) | ||
| 629 | .pipe( | ||
| 630 | Stream.tap((label) => | ||
| 631 | Ref.updateAndGet(labels, (current) => [...current, label]).pipe( | ||
| 632 | Effect.flatMap((current) => | ||
| 633 | current.length === 1 | ||
| 634 | ? Deferred.succeed(firstObserved, undefined) | ||
| 635 | : Deferred.succeed(secondObserved, undefined), | ||
| 636 | ), | ||
| 637 | ), | ||
| 638 | ), | ||
| 639 | Stream.runDrain, | ||
| 640 | ), | ||
| 641 | ); | ||
| 642 | |||
| 643 | yield* Deferred.await(firstObserved).pipe(Effect.timeout("1 second")); | ||
| 644 | yield* registry.register(new RelayConnectionRegistration({ target: replacement })); | ||
| 645 | yield* Deferred.await(secondObserved).pipe(Effect.timeout("1 second")); | ||
| 646 | yield* Fiber.interrupt(subscription); | ||
| 647 | |||
| 648 | expect(yield* Ref.get(labels)).toEqual([RELAY_TARGET.label, replacement.label]); | ||
| 649 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 650 | }), | ||
| 651 | ); | ||
| 652 | |||
| 653 | it.effect("ignores retry signals for environments that are no longer registered", () => | ||
| 654 | Effect.gen(function* () { | ||
| 655 | const harness = yield* makeHarness([]); | ||
| 656 | |||
| 657 | yield* Effect.gen(function* () { | ||
| 658 | const registry = yield* EnvironmentRegistry; | ||
| 659 | yield* registry.retryNow(EnvironmentId.make("removed-environment")); | ||
| 660 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 661 | }), | ||
| 662 | ); | ||
| 663 | |||
| 664 | it.effect("removes all relay-owned data without touching non-cloud connections", () => | ||
| 665 | Effect.gen(function* () { | ||
| 666 | const harness = yield* makeHarness( | ||
| 667 | [RELAY_TARGET, SECOND_RELAY_TARGET, BEARER_TARGET], | ||
| 668 | [BEARER_PROFILE], | ||
| 669 | [[BEARER_TARGET.connectionId, BEARER_CREDENTIAL]], | ||
| 670 | ); | ||
| 671 | |||
| 672 | yield* Effect.gen(function* () { | ||
| 673 | const registry = yield* EnvironmentRegistry; | ||
| 674 | yield* registry.removeRelayEnvironments(); | ||
| 675 | |||
| 676 | const targets = yield* Ref.get(harness.storedTargets); | ||
| 677 | expect(targets.has(RELAY_TARGET.environmentId)).toBe(false); | ||
| 678 | expect(targets.has(SECOND_RELAY_TARGET.environmentId)).toBe(false); | ||
| 679 | expect(targets.get(BEARER_TARGET.environmentId)).toEqual(BEARER_TARGET); | ||
| 680 | expect(yield* Ref.get(harness.cacheClears)).toEqual( | ||
| 681 | expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]), | ||
| 682 | ); | ||
| 683 | expect(yield* Ref.get(harness.ownedDataClears)).toEqual( | ||
| 684 | expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]), | ||
| 685 | ); | ||
| 686 | expect( | ||
| 687 | (yield* SubscriptionRef.get(registry.entries)).has(BEARER_TARGET.environmentId), | ||
| 688 | ).toBe(true); | ||
| 689 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 690 | }), | ||
| 691 | ); | ||
| 692 | |||
| 693 | it.effect("keeps the runtime registered when durable removal fails", () => | ||
| 694 | Effect.gen(function* () { | ||
| 695 | const harness = yield* makeHarness([RELAY_TARGET], [], [], { | ||
| 696 | beforeRegistrationRemove: () => | ||
| 697 | Effect.fail( | ||
| 698 | new ConnectionPersistenceError({ | ||
| 699 | operation: "remove-connection", | ||
| 700 | message: "Storage is unavailable.", | ||
| 701 | }), | ||
| 702 | ), | ||
| 703 | }); | ||
| 704 | |||
| 705 | yield* Effect.gen(function* () { | ||
| 706 | const registry = yield* EnvironmentRegistry; | ||
| 707 | yield* registry.start; | ||
| 708 | yield* awaitConnectionState( | ||
| 709 | registry, | ||
| 710 | RELAY_TARGET.environmentId, | ||
| 711 | (state) => state.phase === "connected", | ||
| 712 | ); | ||
| 713 | |||
| 714 | const error = yield* Effect.flip(registry.removeRelayEnvironments()); | ||
| 715 | |||
| 716 | expect(error._tag).toBe("ConnectionPersistenceError"); | ||
| 717 | expect(yield* Ref.get(harness.releasedSessions)).toBe(0); | ||
| 718 | expect((yield* SubscriptionRef.get(registry.entries)).has(RELAY_TARGET.environmentId)).toBe( | ||
| 719 | true, | ||
| 720 | ); | ||
| 721 | expect((yield* Ref.get(harness.storedTargets)).has(RELAY_TARGET.environmentId)).toBe(true); | ||
| 722 | expect(yield* Ref.get(harness.cacheClears)).toEqual([]); | ||
| 723 | expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); | ||
| 724 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 725 | }), | ||
| 726 | ); | ||
| 727 | |||
| 728 | it.effect("starts a newly paired bearer environment without re-reading its profile", () => | ||
| 729 | Effect.gen(function* () { | ||
| 730 | const harness = yield* makeHarness([]); | ||
| 731 | |||
| 732 | yield* Effect.gen(function* () { | ||
| 733 | const registry = yield* EnvironmentRegistry; | ||
| 734 | yield* registry.register( | ||
| 735 | new BearerConnectionRegistration({ | ||
| 736 | target: BEARER_TARGET, | ||
| 737 | profile: BEARER_PROFILE, | ||
| 738 | credential: BEARER_CREDENTIAL, | ||
| 739 | }), | ||
| 740 | ); | ||
| 741 | yield* awaitConnectionState( | ||
| 742 | registry, | ||
| 743 | BEARER_TARGET.environmentId, | ||
| 744 | (state) => state.phase === "connected", | ||
| 745 | ); | ||
| 746 | |||
| 747 | expect(yield* Ref.get(harness.profileReadCount)).toBe(0); | ||
| 748 | expect( | ||
| 749 | Option.getOrThrow( | ||
| 750 | (yield* SubscriptionRef.get(registry.entries)).get(BEARER_TARGET.environmentId) | ||
| 751 | ?.profile ?? Option.none(), | ||
| 752 | ), | ||
| 753 | ).toEqual(BEARER_PROFILE); | ||
| 754 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 755 | }), | ||
| 756 | ); | ||
| 757 | |||
| 758 | it.effect("starts platform environments without persisting or removing them", () => | ||
| 759 | Effect.gen(function* () { | ||
| 760 | const harness = yield* makeHarness([]); | ||
| 761 | |||
| 762 | yield* Effect.gen(function* () { | ||
| 763 | const registry = yield* EnvironmentRegistry; | ||
| 764 | yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); | ||
| 765 | yield* awaitConnectionState( | ||
| 766 | registry, | ||
| 767 | TARGET.environmentId, | ||
| 768 | (state) => state.phase === "connected", | ||
| 769 | ); | ||
| 770 | |||
| 771 | expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); | ||
| 772 | expect( | ||
| 773 | (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, | ||
| 774 | ).toEqual(TARGET); | ||
| 775 | |||
| 776 | const error = yield* Effect.flip(registry.remove(TARGET.environmentId)); | ||
| 777 | expect(error._tag).toBe("PlatformEnvironmentRemovalError"); | ||
| 778 | expect( | ||
| 779 | (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, | ||
| 780 | ).toEqual(TARGET); | ||
| 781 | }).pipe(Effect.provide(harness.layer)); | ||
| 782 | }), | ||
| 783 | ); | ||
| 784 | |||
| 785 | it.effect("gives a primary platform registration precedence over persisted registrations", () => | ||
| 786 | Effect.gen(function* () { | ||
| 787 | const shadowedTarget = new RelayConnectionTarget({ | ||
| 788 | environmentId: TARGET.environmentId, | ||
| 789 | label: "Shadowed relay environment", | ||
| 790 | }); | ||
| 791 | const harness = yield* makeHarness([shadowedTarget]); | ||
| 792 | |||
| 793 | yield* Effect.gen(function* () { | ||
| 794 | const registry = yield* EnvironmentRegistry; | ||
| 795 | yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); | ||
| 796 | |||
| 797 | expect( | ||
| 798 | (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, | ||
| 799 | ).toEqual(TARGET); | ||
| 800 | expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); | ||
| 801 | |||
| 802 | yield* registry.register(new RelayConnectionRegistration({ target: shadowedTarget })); | ||
| 803 | |||
| 804 | expect( | ||
| 805 | (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, | ||
| 806 | ).toEqual(TARGET); | ||
| 807 | expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); | ||
| 808 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 809 | }), | ||
| 810 | ); | ||
| 811 | |||
| 812 | it.effect("rechecks platform ownership after waiting for the environment lease", () => | ||
| 813 | Effect.gen(function* () { | ||
| 814 | const registrationStarted = yield* Deferred.make<void>(); | ||
| 815 | const continueRegistration = yield* Deferred.make<void>(); | ||
| 816 | const shadowedTarget = new RelayConnectionTarget({ | ||
| 817 | environmentId: TARGET.environmentId, | ||
| 818 | label: "Shadowed relay environment", | ||
| 819 | }); | ||
| 820 | const harness = yield* makeHarness([], [], [], { | ||
| 821 | beforeRegistrationRegister: () => | ||
| 822 | Deferred.succeed(registrationStarted, undefined).pipe( | ||
| 823 | Effect.andThen(Deferred.await(continueRegistration)), | ||
| 824 | ), | ||
| 825 | }); | ||
| 826 | |||
| 827 | yield* Effect.gen(function* () { | ||
| 828 | const registry = yield* EnvironmentRegistry; | ||
| 829 | const persistedRegistration = yield* registry | ||
| 830 | .register(new RelayConnectionRegistration({ target: shadowedTarget })) | ||
| 831 | .pipe(Effect.forkChild({ startImmediately: true })); | ||
| 832 | yield* Deferred.await(registrationStarted); | ||
| 833 | |||
| 834 | const platformRegistration = yield* registry | ||
| 835 | .registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })) | ||
| 836 | .pipe(Effect.forkChild({ startImmediately: true })); | ||
| 837 | yield* Effect.yieldNow; | ||
| 838 | const removal = yield* Effect.flip(registry.remove(TARGET.environmentId)).pipe( | ||
| 839 | Effect.forkChild({ startImmediately: true }), | ||
| 840 | ); | ||
| 841 | |||
| 842 | yield* Deferred.succeed(continueRegistration, undefined); | ||
| 843 | yield* Fiber.join(persistedRegistration); | ||
| 844 | yield* Fiber.join(platformRegistration); | ||
| 845 | const error = yield* Fiber.join(removal); | ||
| 846 | |||
| 847 | expect(error._tag).toBe("PlatformEnvironmentRemovalError"); | ||
| 848 | expect( | ||
| 849 | (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, | ||
| 850 | ).toEqual(TARGET); | ||
| 851 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 852 | }), | ||
| 853 | ); | ||
| 854 | |||
| 855 | it.effect("does not reacquire a runtime while its registration is being removed", () => | ||
| 856 | Effect.gen(function* () { | ||
| 857 | const removalStarted = yield* Deferred.make<void>(); | ||
| 858 | const continueRemoval = yield* Deferred.make<void>(); | ||
| 859 | const harness = yield* makeHarness([TARGET], [], [], { | ||
| 860 | beforeRegistrationRemove: () => | ||
| 861 | Deferred.succeed(removalStarted, undefined).pipe( | ||
| 862 | Effect.andThen(Deferred.await(continueRemoval)), | ||
| 863 | ), | ||
| 864 | }); | ||
| 865 | |||
| 866 | yield* Effect.gen(function* () { | ||
| 867 | const registry = yield* EnvironmentRegistry; | ||
| 868 | yield* registry.start; | ||
| 869 | yield* awaitConnectionState( | ||
| 870 | registry, | ||
| 871 | TARGET.environmentId, | ||
| 872 | (state) => state.phase === "connected", | ||
| 873 | ); | ||
| 874 | |||
| 875 | const removal = yield* Effect.forkChild(registry.remove(TARGET.environmentId)); | ||
| 876 | yield* Deferred.await(removalStarted); | ||
| 877 | |||
| 878 | const stateLookup = yield* Effect.forkChild( | ||
| 879 | Effect.flip(registry.state(TARGET.environmentId)), | ||
| 880 | ); | ||
| 881 | yield* Effect.yieldNow; | ||
| 882 | expect(yield* Ref.get(harness.sessions)).toHaveLength(1); | ||
| 883 | |||
| 884 | yield* Deferred.succeed(continueRemoval, undefined); | ||
| 885 | yield* Fiber.join(removal); | ||
| 886 | const error = yield* Fiber.join(stateLookup); | ||
| 887 | expect(error._tag).toBe("EnvironmentNotRegisteredError"); | ||
| 888 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 889 | }), | ||
| 890 | ); | ||
| 891 | |||
| 892 | it.effect("retains a healthy runtime when the platform repeats an identical registration", () => | ||
| 893 | Effect.gen(function* () { | ||
| 894 | const harness = yield* makeHarness([]); | ||
| 895 | |||
| 896 | yield* Effect.gen(function* () { | ||
| 897 | const registry = yield* EnvironmentRegistry; | ||
| 898 | const registration = new PrimaryConnectionRegistration({ target: TARGET }); | ||
| 899 | yield* registry.registerPlatform(registration); | ||
| 900 | yield* awaitConnectionState( | ||
| 901 | registry, | ||
| 902 | TARGET.environmentId, | ||
| 903 | (state) => state.phase === "connected", | ||
| 904 | ); | ||
| 905 | |||
| 906 | yield* registry.registerPlatform(registration); | ||
| 907 | |||
| 908 | expect(yield* Ref.get(harness.sessions)).toHaveLength(1); | ||
| 909 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 910 | }), | ||
| 911 | ); | ||
| 912 | |||
| 913 | it.effect("removes all owned SSH state only on explicit removal", () => | ||
| 914 | Effect.gen(function* () { | ||
| 915 | const harness = yield* makeHarness( | ||
| 916 | [SSH_CONNECTION], | ||
| 917 | [SSH_PROFILE], | ||
| 918 | [ | ||
| 919 | [ | ||
| 920 | SSH_CONNECTION.connectionId, | ||
| 921 | new BearerConnectionCredential({ token: "temporary-token" }), | ||
| 922 | ], | ||
| 923 | ], | ||
| 924 | ); | ||
| 925 | |||
| 926 | yield* Effect.gen(function* () { | ||
| 927 | const registry = yield* EnvironmentRegistry; | ||
| 928 | yield* registry.start; | ||
| 929 | yield* registry.remove(SSH_CONNECTION.environmentId); | ||
| 930 | |||
| 931 | expect((yield* Ref.get(harness.storedProfiles)).has(SSH_CONNECTION.connectionId)).toBe( | ||
| 932 | false, | ||
| 933 | ); | ||
| 934 | expect((yield* Ref.get(harness.storedCredentials)).has(SSH_CONNECTION.connectionId)).toBe( | ||
| 935 | false, | ||
| 936 | ); | ||
| 937 | expect((yield* Ref.get(harness.storedRemoteTokens)).has(SSH_CONNECTION.environmentId)).toBe( | ||
| 938 | false, | ||
| 939 | ); | ||
| 940 | expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([SSH_TARGET]); | ||
| 941 | }).pipe(Effect.provide(harness.layer)); | ||
| 942 | }), | ||
| 943 | ); | ||
| 944 | }); | ||
packages/client-runtime/src/connection/registry.ts created+576| ... | @@ -0,0 +1,576 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Equal from "effect/Equal"; | ||
| 5 | import * as Exit from "effect/Exit"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Ref from "effect/Ref"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import * as Scope from "effect/Scope"; | ||
| 11 | import * as Semaphore from "effect/Semaphore"; | ||
| 12 | import * as Stream from "effect/Stream"; | ||
| 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 14 | |||
| 15 | import { SshEnvironmentGateway } from "../platform/capabilities.ts"; | ||
| 16 | import { | ||
| 17 | type ConnectionCatalogEntry, | ||
| 18 | type ConnectionRegistration, | ||
| 19 | ConnectionProfileStore, | ||
| 20 | type PrimaryConnectionRegistration, | ||
| 21 | SshConnectionProfile, | ||
| 22 | connectionRegistrationCatalogEntry, | ||
| 23 | } from "./catalog.ts"; | ||
| 24 | import { Connectivity } from "./connectivity.ts"; | ||
| 25 | import type { | ||
| 26 | ConnectionAttemptError, | ||
| 27 | ConnectionTarget, | ||
| 28 | NetworkStatus, | ||
| 29 | SupervisorConnectionState, | ||
| 30 | } from "./model.ts"; | ||
| 31 | import { | ||
| 32 | type ConnectionPersistenceError, | ||
| 33 | ConnectionRegistrationStore, | ||
| 34 | ConnectionTargetStore, | ||
| 35 | EnvironmentCacheStore, | ||
| 36 | EnvironmentOwnedDataCleanup, | ||
| 37 | } from "../platform/persistence.ts"; | ||
| 38 | import { | ||
| 39 | EnvironmentSupervisor, | ||
| 40 | type EnvironmentSupervisorService, | ||
| 41 | makeEnvironmentSupervisor, | ||
| 42 | } from "./supervisor.ts"; | ||
| 43 | import { ConnectionDriver } from "./driver.ts"; | ||
| 44 | import { ConnectionWakeups } from "./wakeups.ts"; | ||
| 45 | |||
| 46 | const isSshConnectionProfile = Schema.is(SshConnectionProfile); | ||
| 47 | |||
| 48 | export class EnvironmentNotRegisteredError extends Schema.TaggedErrorClass<EnvironmentNotRegisteredError>()( | ||
| 49 | "EnvironmentNotRegisteredError", | ||
| 50 | { | ||
| 51 | environmentId: Schema.String, | ||
| 52 | message: Schema.String, | ||
| 53 | }, | ||
| 54 | ) {} | ||
| 55 | |||
| 56 | export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass<PlatformEnvironmentRemovalError>()( | ||
| 57 | "PlatformEnvironmentRemovalError", | ||
| 58 | { | ||
| 59 | environmentId: Schema.String, | ||
| 60 | message: Schema.String, | ||
| 61 | }, | ||
| 62 | ) {} | ||
| 63 | |||
| 64 | export interface EnvironmentRegistryService { | ||
| 65 | readonly entries: SubscriptionRef.SubscriptionRef< | ||
| 66 | ReadonlyMap<EnvironmentId, ConnectionCatalogEntry> | ||
| 67 | >; | ||
| 68 | readonly networkStatus: SubscriptionRef.SubscriptionRef<NetworkStatus>; | ||
| 69 | readonly start: Effect.Effect<void>; | ||
| 70 | readonly register: ( | ||
| 71 | registration: ConnectionRegistration, | ||
| 72 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 73 | readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect<void>; | ||
| 74 | readonly remove: ( | ||
| 75 | environmentId: EnvironmentId, | ||
| 76 | ) => Effect.Effect< | ||
| 77 | void, | ||
| 78 | | ConnectionPersistenceError | ||
| 79 | | ConnectionAttemptError | ||
| 80 | | EnvironmentNotRegisteredError | ||
| 81 | | PlatformEnvironmentRemovalError | ||
| 82 | >; | ||
| 83 | readonly removeRelayEnvironments: () => Effect.Effect< | ||
| 84 | void, | ||
| 85 | ConnectionPersistenceError | ConnectionAttemptError | PlatformEnvironmentRemovalError | ||
| 86 | >; | ||
| 87 | readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect<void>; | ||
| 88 | readonly state: ( | ||
| 89 | environmentId: EnvironmentId, | ||
| 90 | ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>; | ||
| 91 | readonly stateChanges: ( | ||
| 92 | environmentId: EnvironmentId, | ||
| 93 | ) => Stream.Stream<SupervisorConnectionState, EnvironmentNotRegisteredError>; | ||
| 94 | readonly run: <A, E, R>( | ||
| 95 | environmentId: EnvironmentId, | ||
| 96 | effect: Effect.Effect<A, E, R>, | ||
| 97 | ) => Effect.Effect<A, E | EnvironmentNotRegisteredError, Exclude<R, EnvironmentSupervisor>>; | ||
| 98 | readonly runStream: <A, E, R>( | ||
| 99 | environmentId: EnvironmentId, | ||
| 100 | stream: Stream.Stream<A, E, R>, | ||
| 101 | ) => Stream.Stream<A, E | EnvironmentNotRegisteredError, Exclude<R, EnvironmentSupervisor>>; | ||
| 102 | readonly followStream: <A, E, R>( | ||
| 103 | environmentId: EnvironmentId, | ||
| 104 | stream: Stream.Stream<A, E, R>, | ||
| 105 | ) => Stream.Stream<A, E, Exclude<R, EnvironmentSupervisor>>; | ||
| 106 | } | ||
| 107 | |||
| 108 | export class EnvironmentRegistry extends Context.Service< | ||
| 109 | EnvironmentRegistry, | ||
| 110 | EnvironmentRegistryService | ||
| 111 | >()("@t3tools/client-runtime/connection/registry/EnvironmentRegistry") {} | ||
| 112 | |||
| 113 | interface EnvironmentServiceScope { | ||
| 114 | readonly entry: ConnectionCatalogEntry; | ||
| 115 | readonly supervisor: EnvironmentSupervisorService; | ||
| 116 | readonly scope: Scope.Closeable; | ||
| 117 | } | ||
| 118 | |||
| 119 | const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* () { | ||
| 120 | const storage = yield* ConnectionTargetStore; | ||
| 121 | const registrations = yield* ConnectionRegistrationStore; | ||
| 122 | const cache = yield* EnvironmentCacheStore; | ||
| 123 | const ownedDataCleanup = yield* EnvironmentOwnedDataCleanup; | ||
| 124 | const profiles = yield* ConnectionProfileStore; | ||
| 125 | const connectivity = yield* Connectivity; | ||
| 126 | const driver = yield* ConnectionDriver; | ||
| 127 | const wakeups = yield* ConnectionWakeups; | ||
| 128 | const ssh = yield* SshEnvironmentGateway; | ||
| 129 | const persistedTargets = yield* storage.list; | ||
| 130 | const initialEntries = new Map( | ||
| 131 | yield* Effect.forEach( | ||
| 132 | persistedTargets, | ||
| 133 | Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* (target) { | ||
| 134 | const profile = | ||
| 135 | target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" | ||
| 136 | ? yield* profiles.get(target.connectionId) | ||
| 137 | : Option.none(); | ||
| 138 | return [ | ||
| 139 | target.environmentId, | ||
| 140 | { target, profile } satisfies ConnectionCatalogEntry, | ||
| 141 | ] as const; | ||
| 142 | }), | ||
| 143 | { concurrency: "unbounded" }, | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | const entries = | ||
| 147 | yield* SubscriptionRef.make<ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>>(initialEntries); | ||
| 148 | const networkStatus = yield* SubscriptionRef.make(yield* connectivity.status); | ||
| 149 | const serviceScopes = yield* SubscriptionRef.make< | ||
| 150 | ReadonlyMap<EnvironmentId, EnvironmentServiceScope> | ||
| 151 | >(new Map()); | ||
| 152 | const platformEnvironmentIds = yield* Ref.make<ReadonlySet<EnvironmentId>>(new Set()); | ||
| 153 | const persistedTargetsByEnvironment = yield* Ref.make< | ||
| 154 | ReadonlyMap<EnvironmentId, ConnectionTarget> | ||
| 155 | >(new Map(persistedTargets.map((target) => [target.environmentId, target]))); | ||
| 156 | interface LeaseLock { | ||
| 157 | readonly semaphore: Semaphore.Semaphore; | ||
| 158 | readonly users: number; | ||
| 159 | } | ||
| 160 | |||
| 161 | const leaseLocks = yield* Ref.make<ReadonlyMap<EnvironmentId, LeaseLock>>(new Map()); | ||
| 162 | const leaseLocksGuard = yield* Semaphore.make(1); | ||
| 163 | const started = yield* Ref.make(false); | ||
| 164 | |||
| 165 | const withLeaseLock = <A, E, R>( | ||
| 166 | environmentId: EnvironmentId, | ||
| 167 | effect: Effect.Effect<A, E, R>, | ||
| 168 | ): Effect.Effect<A, E, R> => | ||
| 169 | Effect.acquireUseRelease( | ||
| 170 | leaseLocksGuard.withPermits(1)( | ||
| 171 | Effect.gen(function* () { | ||
| 172 | const current = yield* Ref.get(leaseLocks); | ||
| 173 | const existing = current.get(environmentId); | ||
| 174 | if (existing !== undefined) { | ||
| 175 | yield* Ref.set( | ||
| 176 | leaseLocks, | ||
| 177 | new Map(current).set(environmentId, { | ||
| 178 | semaphore: existing.semaphore, | ||
| 179 | users: existing.users + 1, | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | return existing.semaphore; | ||
| 183 | } | ||
| 184 | const semaphore = yield* Semaphore.make(1); | ||
| 185 | yield* Ref.set(leaseLocks, new Map(current).set(environmentId, { semaphore, users: 1 })); | ||
| 186 | return semaphore; | ||
| 187 | }), | ||
| 188 | ), | ||
| 189 | (semaphore) => semaphore.withPermits(1)(effect), | ||
| 190 | (semaphore) => | ||
| 191 | leaseLocksGuard.withPermits(1)( | ||
| 192 | Ref.update(leaseLocks, (current) => { | ||
| 193 | const existing = current.get(environmentId); | ||
| 194 | if (existing === undefined || existing.semaphore !== semaphore) { | ||
| 195 | return current; | ||
| 196 | } | ||
| 197 | const next = new Map(current); | ||
| 198 | if (existing.users === 1) { | ||
| 199 | next.delete(environmentId); | ||
| 200 | } else { | ||
| 201 | next.set(environmentId, { | ||
| 202 | semaphore, | ||
| 203 | users: existing.users - 1, | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | return next; | ||
| 207 | }), | ||
| 208 | ), | ||
| 209 | ).pipe(Effect.withSpan("EnvironmentRegistry.withLeaseLock")); | ||
| 210 | |||
| 211 | const getEntry = Effect.fn("EnvironmentRegistry.getEntry")(function* ( | ||
| 212 | environmentId: EnvironmentId, | ||
| 213 | ) { | ||
| 214 | const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); | ||
| 215 | if (entry === undefined) { | ||
| 216 | return yield* new EnvironmentNotRegisteredError({ | ||
| 217 | environmentId, | ||
| 218 | message: `Environment ${environmentId} is not registered.`, | ||
| 219 | }); | ||
| 220 | } | ||
| 221 | return entry; | ||
| 222 | }); | ||
| 223 | |||
| 224 | const closeServiceScope = Effect.fn("EnvironmentRegistry.closeServiceScope")(function* ( | ||
| 225 | environmentId: EnvironmentId, | ||
| 226 | ) { | ||
| 227 | const current = yield* SubscriptionRef.get(serviceScopes); | ||
| 228 | const lease = current.get(environmentId); | ||
| 229 | if (lease === undefined) { | ||
| 230 | return; | ||
| 231 | } | ||
| 232 | const next = new Map(current); | ||
| 233 | next.delete(environmentId); | ||
| 234 | yield* SubscriptionRef.set(serviceScopes, next); | ||
| 235 | yield* Scope.close(lease.scope, Exit.void); | ||
| 236 | }); | ||
| 237 | |||
| 238 | const createServiceScope = Effect.fn("EnvironmentRegistry.createServiceScope")( | ||
| 239 | (entry: ConnectionCatalogEntry) => | ||
| 240 | Effect.uninterruptible( | ||
| 241 | Effect.gen(function* () { | ||
| 242 | const environmentId = entry.target.environmentId; | ||
| 243 | const scope = yield* Scope.make(); | ||
| 244 | const supervisor = yield* makeEnvironmentSupervisor(entry, { | ||
| 245 | initiallyDesired: false, | ||
| 246 | }).pipe( | ||
| 247 | Effect.provideService(Connectivity, connectivity), | ||
| 248 | Effect.provideService(ConnectionDriver, driver), | ||
| 249 | Effect.provideService(ConnectionWakeups, wakeups), | ||
| 250 | Scope.provide(scope), | ||
| 251 | Effect.onError(() => Scope.close(scope, Exit.void)), | ||
| 252 | ); | ||
| 253 | yield* supervisor.connect; | ||
| 254 | yield* SubscriptionRef.update(serviceScopes, (current) => { | ||
| 255 | const next = new Map(current); | ||
| 256 | next.set(environmentId, { entry, supervisor, scope }); | ||
| 257 | return next; | ||
| 258 | }); | ||
| 259 | return supervisor; | ||
| 260 | }), | ||
| 261 | ), | ||
| 262 | ); | ||
| 263 | |||
| 264 | const acquireSupervisor = Effect.fn("EnvironmentRegistry.acquireSupervisor")(function* ( | ||
| 265 | environmentId: EnvironmentId, | ||
| 266 | ) { | ||
| 267 | return yield* withLeaseLock( | ||
| 268 | environmentId, | ||
| 269 | Effect.gen(function* () { | ||
| 270 | const entry = yield* getEntry(environmentId); | ||
| 271 | const existing = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); | ||
| 272 | if (existing !== undefined) { | ||
| 273 | if (Equal.equals(existing.entry, entry)) { | ||
| 274 | return existing.supervisor; | ||
| 275 | } | ||
| 276 | yield* closeServiceScope(environmentId); | ||
| 277 | } | ||
| 278 | return yield* createServiceScope(entry); | ||
| 279 | }), | ||
| 280 | ); | ||
| 281 | }); | ||
| 282 | |||
| 283 | const run: EnvironmentRegistryService["run"] = Effect.fn("EnvironmentRegistry.run")(function* < | ||
| 284 | A, | ||
| 285 | E, | ||
| 286 | R, | ||
| 287 | >(environmentId: EnvironmentId, effect: Effect.Effect<A, E, R>) { | ||
| 288 | const supervisor = yield* acquireSupervisor(environmentId); | ||
| 289 | return yield* Effect.provideService(effect, EnvironmentSupervisor, supervisor); | ||
| 290 | }); | ||
| 291 | |||
| 292 | const runStream: EnvironmentRegistryService["runStream"] = <A, E, R>( | ||
| 293 | environmentId: EnvironmentId, | ||
| 294 | stream: Stream.Stream<A, E, R>, | ||
| 295 | ) => | ||
| 296 | Stream.unwrap( | ||
| 297 | acquireSupervisor(environmentId).pipe( | ||
| 298 | Effect.map((supervisor) => | ||
| 299 | Stream.provideService(stream, EnvironmentSupervisor, supervisor), | ||
| 300 | ), | ||
| 301 | ), | ||
| 302 | ); | ||
| 303 | |||
| 304 | const followStream: EnvironmentRegistryService["followStream"] = <A, E, R>( | ||
| 305 | environmentId: EnvironmentId, | ||
| 306 | stream: Stream.Stream<A, E, R>, | ||
| 307 | ) => | ||
| 308 | Stream.concat( | ||
| 309 | Stream.fromEffect(SubscriptionRef.get(entries)), | ||
| 310 | SubscriptionRef.changes(entries), | ||
| 311 | ).pipe( | ||
| 312 | Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))), | ||
| 313 | Stream.changes, | ||
| 314 | Stream.switchMap( | ||
| 315 | Option.match({ | ||
| 316 | onNone: () => Stream.empty, | ||
| 317 | onSome: () => | ||
| 318 | Stream.unwrap( | ||
| 319 | acquireSupervisor(environmentId).pipe( | ||
| 320 | Effect.match({ | ||
| 321 | onFailure: () => Stream.empty, | ||
| 322 | onSuccess: (supervisor) => | ||
| 323 | Stream.provideService(stream, EnvironmentSupervisor, supervisor), | ||
| 324 | }), | ||
| 325 | ), | ||
| 326 | ), | ||
| 327 | }), | ||
| 328 | ), | ||
| 329 | ); | ||
| 330 | |||
| 331 | const start = Effect.gen(function* () { | ||
| 332 | if (yield* Ref.getAndSet(started, true)) { | ||
| 333 | return; | ||
| 334 | } | ||
| 335 | yield* Effect.forEach( | ||
| 336 | persistedTargets, | ||
| 337 | (target) => | ||
| 338 | acquireSupervisor(target.environmentId).pipe( | ||
| 339 | Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), | ||
| 340 | ), | ||
| 341 | { | ||
| 342 | concurrency: "unbounded", | ||
| 343 | discard: true, | ||
| 344 | }, | ||
| 345 | ); | ||
| 346 | }).pipe(Effect.withSpan("EnvironmentRegistry.start")); | ||
| 347 | |||
| 348 | const installEntryLocked = Effect.fn("EnvironmentRegistry.installEntryLocked")(function* ( | ||
| 349 | entry: ConnectionCatalogEntry, | ||
| 350 | options?: { readonly retainEquivalentRuntime?: boolean }, | ||
| 351 | ) { | ||
| 352 | const target = entry.target; | ||
| 353 | const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId); | ||
| 354 | const existingScope = (yield* SubscriptionRef.get(serviceScopes)).get(target.environmentId); | ||
| 355 | if ( | ||
| 356 | options?.retainEquivalentRuntime === true && | ||
| 357 | previous !== undefined && | ||
| 358 | Equal.equals(previous, entry) && | ||
| 359 | existingScope !== undefined && | ||
| 360 | Equal.equals(existingScope.entry, entry) | ||
| 361 | ) { | ||
| 362 | return; | ||
| 363 | } | ||
| 364 | |||
| 365 | yield* closeServiceScope(target.environmentId); | ||
| 366 | yield* SubscriptionRef.update(entries, (current) => { | ||
| 367 | const next = new Map(current); | ||
| 368 | next.set(target.environmentId, entry); | ||
| 369 | return next; | ||
| 370 | }); | ||
| 371 | yield* createServiceScope(entry); | ||
| 372 | }); | ||
| 373 | |||
| 374 | const register = Effect.fn("EnvironmentRegistry.register")(function* ( | ||
| 375 | registration: ConnectionRegistration, | ||
| 376 | ) { | ||
| 377 | const entry = connectionRegistrationCatalogEntry(registration); | ||
| 378 | const environmentId = entry.target.environmentId; | ||
| 379 | yield* withLeaseLock( | ||
| 380 | environmentId, | ||
| 381 | Effect.gen(function* () { | ||
| 382 | if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { | ||
| 383 | return; | ||
| 384 | } | ||
| 385 | yield* registrations.register(registration); | ||
| 386 | yield* Ref.update(persistedTargetsByEnvironment, (current) => { | ||
| 387 | const next = new Map(current); | ||
| 388 | next.set(environmentId, registration.target); | ||
| 389 | return next; | ||
| 390 | }); | ||
| 391 | yield* installEntryLocked(entry); | ||
| 392 | }), | ||
| 393 | ); | ||
| 394 | }); | ||
| 395 | |||
| 396 | const registerPlatform = Effect.fn("EnvironmentRegistry.registerPlatform")(function* ( | ||
| 397 | registration: PrimaryConnectionRegistration, | ||
| 398 | ) { | ||
| 399 | const entry = connectionRegistrationCatalogEntry(registration); | ||
| 400 | const target = entry.target; | ||
| 401 | yield* withLeaseLock( | ||
| 402 | target.environmentId, | ||
| 403 | Effect.gen(function* () { | ||
| 404 | yield* Ref.update(platformEnvironmentIds, (current) => { | ||
| 405 | const next = new Set(current); | ||
| 406 | next.add(target.environmentId); | ||
| 407 | return next; | ||
| 408 | }); | ||
| 409 | |||
| 410 | const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( | ||
| 411 | target.environmentId, | ||
| 412 | ); | ||
| 413 | if (persistedTarget !== undefined) { | ||
| 414 | yield* registrations.remove(persistedTarget).pipe( | ||
| 415 | Effect.tap(() => | ||
| 416 | Ref.update(persistedTargetsByEnvironment, (current) => { | ||
| 417 | const next = new Map(current); | ||
| 418 | next.delete(target.environmentId); | ||
| 419 | return next; | ||
| 420 | }), | ||
| 421 | ), | ||
| 422 | Effect.catch((error) => | ||
| 423 | Effect.logWarning( | ||
| 424 | "Could not remove a persisted registration shadowed by the primary environment.", | ||
| 425 | { | ||
| 426 | environmentId: target.environmentId, | ||
| 427 | error, | ||
| 428 | }, | ||
| 429 | ), | ||
| 430 | ), | ||
| 431 | ); | ||
| 432 | } | ||
| 433 | |||
| 434 | yield* installEntryLocked(entry, { retainEquivalentRuntime: true }); | ||
| 435 | }), | ||
| 436 | ); | ||
| 437 | }); | ||
| 438 | |||
| 439 | const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) { | ||
| 440 | return yield* withLeaseLock( | ||
| 441 | environmentId, | ||
| 442 | Effect.gen(function* () { | ||
| 443 | if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { | ||
| 444 | return yield* new PlatformEnvironmentRemovalError({ | ||
| 445 | environmentId, | ||
| 446 | message: "Platform-managed environments cannot be removed.", | ||
| 447 | }); | ||
| 448 | } | ||
| 449 | const target = (yield* getEntry(environmentId)).target; | ||
| 450 | const profile = | ||
| 451 | target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" | ||
| 452 | ? yield* profiles.get(target.connectionId) | ||
| 453 | : Option.none(); | ||
| 454 | |||
| 455 | yield* registrations.remove(target); | ||
| 456 | yield* Ref.update(persistedTargetsByEnvironment, (current) => { | ||
| 457 | const next = new Map(current); | ||
| 458 | next.delete(environmentId); | ||
| 459 | return next; | ||
| 460 | }); | ||
| 461 | yield* closeServiceScope(environmentId); | ||
| 462 | yield* SubscriptionRef.update(entries, (current) => { | ||
| 463 | const next = new Map(current); | ||
| 464 | next.delete(environmentId); | ||
| 465 | return next; | ||
| 466 | }); | ||
| 467 | yield* Effect.all( | ||
| 468 | [ | ||
| 469 | cache.clear(environmentId).pipe( | ||
| 470 | Effect.catch((error) => | ||
| 471 | Effect.logWarning("Could not clear cached environment data after removal.", { | ||
| 472 | environmentId, | ||
| 473 | error, | ||
| 474 | }), | ||
| 475 | ), | ||
| 476 | ), | ||
| 477 | ownedDataCleanup.clear(environmentId), | ||
| 478 | ], | ||
| 479 | { concurrency: "unbounded", discard: true }, | ||
| 480 | ); | ||
| 481 | |||
| 482 | if ( | ||
| 483 | target._tag === "SshConnectionTarget" && | ||
| 484 | Option.isSome(profile) && | ||
| 485 | isSshConnectionProfile(profile.value) | ||
| 486 | ) { | ||
| 487 | yield* ssh.disconnect(profile.value.target).pipe( | ||
| 488 | Effect.tapError((error) => | ||
| 489 | Effect.logWarning("Could not disconnect the managed SSH environment.", { | ||
| 490 | environmentId, | ||
| 491 | error, | ||
| 492 | }), | ||
| 493 | ), | ||
| 494 | Effect.ignore, | ||
| 495 | ); | ||
| 496 | } | ||
| 497 | }), | ||
| 498 | ); | ||
| 499 | }); | ||
| 500 | |||
| 501 | const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")( | ||
| 502 | function* () { | ||
| 503 | const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()] | ||
| 504 | .filter((entry) => entry.target._tag === "RelayConnectionTarget") | ||
| 505 | .map((entry) => entry.target.environmentId); | ||
| 506 | |||
| 507 | yield* Effect.forEach( | ||
| 508 | relayEnvironmentIds, | ||
| 509 | (environmentId) => | ||
| 510 | remove(environmentId).pipe( | ||
| 511 | Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), | ||
| 512 | ), | ||
| 513 | { | ||
| 514 | concurrency: "unbounded", | ||
| 515 | discard: true, | ||
| 516 | }, | ||
| 517 | ); | ||
| 518 | }, | ||
| 519 | ); | ||
| 520 | |||
| 521 | const retryNow = (environmentId: EnvironmentId) => | ||
| 522 | acquireSupervisor(environmentId).pipe( | ||
| 523 | Effect.flatMap((supervisor) => supervisor.retryNow), | ||
| 524 | Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), | ||
| 525 | Effect.withSpan("EnvironmentRegistry.retryNow"), | ||
| 526 | ); | ||
| 527 | const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) { | ||
| 528 | const supervisor = yield* acquireSupervisor(environmentId); | ||
| 529 | return yield* SubscriptionRef.get(supervisor.state); | ||
| 530 | }); | ||
| 531 | const stateChanges = (environmentId: EnvironmentId) => | ||
| 532 | followStream( | ||
| 533 | environmentId, | ||
| 534 | Stream.unwrap( | ||
| 535 | EnvironmentSupervisor.pipe( | ||
| 536 | Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), | ||
| 537 | ), | ||
| 538 | ), | ||
| 539 | ); | ||
| 540 | |||
| 541 | yield* Effect.addFinalizer(() => | ||
| 542 | SubscriptionRef.get(serviceScopes).pipe( | ||
| 543 | Effect.flatMap((current) => | ||
| 544 | Effect.forEach(current.values(), (lease) => Scope.close(lease.scope, Exit.void), { | ||
| 545 | concurrency: "unbounded", | ||
| 546 | discard: true, | ||
| 547 | }), | ||
| 548 | ), | ||
| 549 | ), | ||
| 550 | ); | ||
| 551 | yield* connectivity.changes.pipe( | ||
| 552 | Stream.runForEach((status) => SubscriptionRef.set(networkStatus, status)), | ||
| 553 | Effect.forkScoped, | ||
| 554 | ); | ||
| 555 | |||
| 556 | return EnvironmentRegistry.of({ | ||
| 557 | entries, | ||
| 558 | networkStatus, | ||
| 559 | start, | ||
| 560 | register, | ||
| 561 | registerPlatform, | ||
| 562 | remove, | ||
| 563 | removeRelayEnvironments, | ||
| 564 | retryNow, | ||
| 565 | state, | ||
| 566 | stateChanges, | ||
| 567 | run, | ||
| 568 | runStream, | ||
| 569 | followStream, | ||
| 570 | }); | ||
| 571 | }); | ||
| 572 | |||
| 573 | export const environmentRegistryLayer = Layer.effect( | ||
| 574 | EnvironmentRegistry, | ||
| 575 | makeEnvironmentRegistry(), | ||
| 576 | ); | ||
packages/client-runtime/src/connection/resolver.test.ts created+423| ... | @@ -0,0 +1,423 @@ | ||
| 1 | import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts"; | ||
| 2 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; | ||
| 3 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Ref from "effect/Ref"; | ||
| 9 | import * as Tracer from "effect/Tracer"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | ManagedRelayClient, | ||
| 13 | ManagedRelayClientError, | ||
| 14 | ManagedRelayRequestTimeoutError, | ||
| 15 | } from "../relay/managedRelay.ts"; | ||
| 16 | import { ConnectionResolver } from "./resolver.ts"; | ||
| 17 | import { connectionResolverLayer } from "./resolver.ts"; | ||
| 18 | import { | ||
| 19 | CloudSession, | ||
| 20 | RelayDeviceIdentity, | ||
| 21 | SshEnvironmentGateway, | ||
| 22 | } from "../platform/capabilities.ts"; | ||
| 23 | import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | ||
| 24 | import { | ||
| 25 | BearerConnectionCredential, | ||
| 26 | BearerConnectionProfile, | ||
| 27 | type ConnectionCatalogEntry, | ||
| 28 | ConnectionCredentialStore, | ||
| 29 | ConnectionProfileStore, | ||
| 30 | SshConnectionProfile, | ||
| 31 | type ConnectionCredential, | ||
| 32 | type ConnectionProfile, | ||
| 33 | } from "./catalog.ts"; | ||
| 34 | import { | ||
| 35 | BearerConnectionTarget, | ||
| 36 | ConnectionTransientError, | ||
| 37 | PrimaryConnectionTarget, | ||
| 38 | RelayConnectionTarget, | ||
| 39 | SshConnectionTarget, | ||
| 40 | type ConnectionTarget, | ||
| 41 | } from "./model.ts"; | ||
| 42 | |||
| 43 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | ||
| 44 | const ENDPOINT = { | ||
| 45 | httpBaseUrl: "https://environment.example.test", | ||
| 46 | wsBaseUrl: "wss://environment.example.test", | ||
| 47 | providerKind: "cloudflare_tunnel" as const, | ||
| 48 | }; | ||
| 49 | const SSH_TARGET: DesktopSshEnvironmentTarget = { | ||
| 50 | alias: "development", | ||
| 51 | hostname: "development.example.test", | ||
| 52 | username: "developer", | ||
| 53 | port: 22, | ||
| 54 | }; | ||
| 55 | |||
| 56 | function catalogEntry( | ||
| 57 | target: ConnectionTarget, | ||
| 58 | profile: Option.Option<ConnectionProfile> = Option.none(), | ||
| 59 | ): ConnectionCatalogEntry { | ||
| 60 | return { target, profile }; | ||
| 61 | } | ||
| 62 | |||
| 63 | function unsupported<A>(name: string): Effect.Effect<A> { | ||
| 64 | return Effect.die(new Error(`Unexpected relay call: ${name}`)); | ||
| 65 | } | ||
| 66 | |||
| 67 | function collectingTracer(spans: Array<string>): Tracer.Tracer { | ||
| 68 | return Tracer.make({ | ||
| 69 | span: (options) => { | ||
| 70 | const span = new Tracer.NativeSpan(options); | ||
| 71 | const end = span.end.bind(span); | ||
| 72 | span.end = (endTime, exit) => { | ||
| 73 | end(endTime, exit); | ||
| 74 | spans.push(span.name); | ||
| 75 | }; | ||
| 76 | return span; | ||
| 77 | }, | ||
| 78 | }); | ||
| 79 | } | ||
| 80 | |||
| 81 | function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectEnvironment"]) { | ||
| 82 | return ManagedRelayClient.of({ | ||
| 83 | relayUrl: "https://relay.example.test", | ||
| 84 | listEnvironments: () => unsupported("listEnvironments"), | ||
| 85 | listDevices: () => unsupported("listDevices"), | ||
| 86 | createEnvironmentLinkChallenge: () => unsupported("createEnvironmentLinkChallenge"), | ||
| 87 | linkEnvironment: () => unsupported("linkEnvironment"), | ||
| 88 | unlinkEnvironment: () => unsupported("unlinkEnvironment"), | ||
| 89 | getEnvironmentStatus: () => unsupported("getEnvironmentStatus"), | ||
| 90 | connectEnvironment, | ||
| 91 | registerDevice: () => unsupported("registerDevice"), | ||
| 92 | unregisterDevice: () => unsupported("unregisterDevice"), | ||
| 93 | registerLiveActivity: () => unsupported("registerLiveActivity"), | ||
| 94 | resetTokenCache: Effect.void, | ||
| 95 | }); | ||
| 96 | } | ||
| 97 | |||
| 98 | const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: { | ||
| 99 | readonly profiles?: ReadonlyArray<ConnectionProfile>; | ||
| 100 | readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>; | ||
| 101 | readonly connectEnvironment?: ManagedRelayClient["Service"]["connectEnvironment"]; | ||
| 102 | readonly authorizeBearer?: RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; | ||
| 103 | readonly authorizeDpop?: RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; | ||
| 104 | readonly prepareSsh?: SshEnvironmentGateway["Service"]["prepare"]; | ||
| 105 | }) => { | ||
| 106 | const profiles = new Map( | ||
| 107 | (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), | ||
| 108 | ); | ||
| 109 | const credentials = new Map(options?.credentials ?? []); | ||
| 110 | |||
| 111 | const profileStore = ConnectionProfileStore.of({ | ||
| 112 | get: (connectionId) => Effect.succeed(Option.fromNullishOr(profiles.get(connectionId))), | ||
| 113 | put: (profile) => Effect.sync(() => void profiles.set(profile.connectionId, profile)), | ||
| 114 | remove: (connectionId) => Effect.sync(() => void profiles.delete(connectionId)), | ||
| 115 | }); | ||
| 116 | const credentialStore = ConnectionCredentialStore.of({ | ||
| 117 | get: (connectionId) => Effect.succeed(Option.fromNullishOr(credentials.get(connectionId))), | ||
| 118 | put: (connectionId, credential) => | ||
| 119 | Effect.sync(() => void credentials.set(connectionId, credential)), | ||
| 120 | remove: (connectionId) => Effect.sync(() => void credentials.delete(connectionId)), | ||
| 121 | }); | ||
| 122 | const remote = RemoteEnvironmentAuthorization.of({ | ||
| 123 | authorizeBearer: | ||
| 124 | options?.authorizeBearer ?? | ||
| 125 | ((input) => | ||
| 126 | Effect.succeed({ | ||
| 127 | environmentId: input.expectedEnvironmentId, | ||
| 128 | label: "Authorized bearer environment", | ||
| 129 | httpBaseUrl: input.httpBaseUrl, | ||
| 130 | socketUrl: "wss://authorized.example.test/ws?wsTicket=bearer", | ||
| 131 | httpAuthorization: { | ||
| 132 | _tag: "Bearer" as const, | ||
| 133 | token: input.bearerToken, | ||
| 134 | }, | ||
| 135 | })), | ||
| 136 | authorizeDpop: | ||
| 137 | options?.authorizeDpop ?? | ||
| 138 | ((input) => | ||
| 139 | input.obtainBootstrap.pipe( | ||
| 140 | Effect.as({ | ||
| 141 | environmentId: input.expectedEnvironmentId, | ||
| 142 | label: "Authorized relay environment", | ||
| 143 | httpBaseUrl: ENDPOINT.httpBaseUrl, | ||
| 144 | socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop", | ||
| 145 | httpAuthorization: { | ||
| 146 | _tag: "Dpop" as const, | ||
| 147 | accessToken: "dpop-access-token", | ||
| 148 | }, | ||
| 149 | }), | ||
| 150 | )), | ||
| 151 | }); | ||
| 152 | const ssh = SshEnvironmentGateway.of({ | ||
| 153 | provision: () => Effect.die("unused"), | ||
| 154 | prepare: | ||
| 155 | options?.prepareSsh ?? | ||
| 156 | (() => | ||
| 157 | Effect.succeed({ | ||
| 158 | bootstrap: { | ||
| 159 | target: SSH_TARGET, | ||
| 160 | httpBaseUrl: "http://127.0.0.1:4010", | ||
| 161 | wsBaseUrl: "ws://127.0.0.1:4010", | ||
| 162 | pairingToken: null, | ||
| 163 | }, | ||
| 164 | bearerToken: "ssh-bearer", | ||
| 165 | })), | ||
| 166 | disconnect: () => Effect.void, | ||
| 167 | }); | ||
| 168 | |||
| 169 | const dependencies = Layer.mergeAll( | ||
| 170 | Layer.succeed(ConnectionProfileStore, profileStore), | ||
| 171 | Layer.succeed(ConnectionCredentialStore, credentialStore), | ||
| 172 | Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-session") })), | ||
| 173 | Layer.succeed( | ||
| 174 | RelayDeviceIdentity, | ||
| 175 | RelayDeviceIdentity.of({ deviceId: Effect.succeed(Option.some("device-1")) }), | ||
| 176 | ), | ||
| 177 | Layer.succeed(RemoteEnvironmentAuthorization, remote), | ||
| 178 | Layer.succeed(SshEnvironmentGateway, ssh), | ||
| 179 | Layer.succeed( | ||
| 180 | ManagedRelayClient, | ||
| 181 | relayClient( | ||
| 182 | options?.connectEnvironment ?? | ||
| 183 | ((input) => | ||
| 184 | Effect.succeed({ | ||
| 185 | environmentId: input.environmentId, | ||
| 186 | endpoint: ENDPOINT, | ||
| 187 | credential: "relay-bootstrap", | ||
| 188 | expiresAt: "2026-06-06T00:00:00.000Z", | ||
| 189 | })), | ||
| 190 | ), | ||
| 191 | ), | ||
| 192 | ); | ||
| 193 | |||
| 194 | return Effect.succeed(connectionResolverLayer.pipe(Layer.provide(dependencies))); | ||
| 195 | }); | ||
| 196 | |||
| 197 | describe("ConnectionResolver", () => { | ||
| 198 | it.effect("prepares a primary environment without remote capabilities", () => | ||
| 199 | Effect.gen(function* () { | ||
| 200 | const brokerLayer = yield* makeDependencies(); | ||
| 201 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | ||
| 202 | const target = new PrimaryConnectionTarget({ | ||
| 203 | environmentId: ENVIRONMENT_ID, | ||
| 204 | label: "Primary", | ||
| 205 | httpBaseUrl: "http://127.0.0.1:3777", | ||
| 206 | wsBaseUrl: "ws://127.0.0.1:3777", | ||
| 207 | }); | ||
| 208 | |||
| 209 | expect(yield* broker.prepare(catalogEntry(target))).toEqual({ | ||
| 210 | environmentId: ENVIRONMENT_ID, | ||
| 211 | label: "Primary", | ||
| 212 | httpBaseUrl: "http://127.0.0.1:3777", | ||
| 213 | socketUrl: "ws://127.0.0.1:3777/ws", | ||
| 214 | httpAuthorization: null, | ||
| 215 | target, | ||
| 216 | }); | ||
| 217 | }), | ||
| 218 | ); | ||
| 219 | |||
| 220 | it.effect("uses the registered bearer profile without re-reading the profile store", () => | ||
| 221 | Effect.gen(function* () { | ||
| 222 | const bearerInputs = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 223 | const target = new BearerConnectionTarget({ | ||
| 224 | environmentId: ENVIRONMENT_ID, | ||
| 225 | label: "Saved", | ||
| 226 | connectionId: "saved-1", | ||
| 227 | }); | ||
| 228 | const profile = new BearerConnectionProfile({ | ||
| 229 | connectionId: "saved-1", | ||
| 230 | environmentId: ENVIRONMENT_ID, | ||
| 231 | label: "Saved", | ||
| 232 | httpBaseUrl: ENDPOINT.httpBaseUrl, | ||
| 233 | wsBaseUrl: ENDPOINT.wsBaseUrl, | ||
| 234 | }); | ||
| 235 | const brokerLayer = yield* makeDependencies({ | ||
| 236 | credentials: [["saved-1", new BearerConnectionCredential({ token: "secret-bearer" })]], | ||
| 237 | authorizeBearer: (input) => | ||
| 238 | Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( | ||
| 239 | Effect.as({ | ||
| 240 | environmentId: input.expectedEnvironmentId, | ||
| 241 | label: "Saved", | ||
| 242 | httpBaseUrl: input.httpBaseUrl, | ||
| 243 | socketUrl: "wss://environment.example.test/ws?wsTicket=ticket", | ||
| 244 | httpAuthorization: { | ||
| 245 | _tag: "Bearer" as const, | ||
| 246 | token: input.bearerToken, | ||
| 247 | }, | ||
| 248 | }), | ||
| 249 | ), | ||
| 250 | }); | ||
| 251 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | ||
| 252 | |||
| 253 | expect( | ||
| 254 | (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, | ||
| 255 | ).toContain("wsTicket=ticket"); | ||
| 256 | expect(yield* Ref.get(bearerInputs)).toEqual(["secret-bearer"]); | ||
| 257 | }), | ||
| 258 | ); | ||
| 259 | |||
| 260 | it.effect("brokers relay credentials with the current cloud session and device identity", () => | ||
| 261 | Effect.gen(function* () { | ||
| 262 | const relayInputs = yield* Ref.make< | ||
| 263 | ReadonlyArray<{ | ||
| 264 | readonly clerkToken: string; | ||
| 265 | readonly scopes: ReadonlyArray<string>; | ||
| 266 | readonly deviceId?: string; | ||
| 267 | }> | ||
| 268 | >([]); | ||
| 269 | const bootstrapCredentials = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 270 | const target = new RelayConnectionTarget({ | ||
| 271 | environmentId: ENVIRONMENT_ID, | ||
| 272 | label: "Cloud", | ||
| 273 | }); | ||
| 274 | const brokerLayer = yield* makeDependencies({ | ||
| 275 | connectEnvironment: (input) => | ||
| 276 | Ref.update(relayInputs, (values) => [ | ||
| 277 | ...values, | ||
| 278 | { | ||
| 279 | clerkToken: input.clerkToken, | ||
| 280 | scopes: input.scopes, | ||
| 281 | ...(input.deviceId ? { deviceId: input.deviceId } : {}), | ||
| 282 | }, | ||
| 283 | ]).pipe( | ||
| 284 | Effect.as({ | ||
| 285 | environmentId: input.environmentId, | ||
| 286 | endpoint: ENDPOINT, | ||
| 287 | credential: "relay-bootstrap", | ||
| 288 | expiresAt: "2026-06-06T00:00:00.000Z", | ||
| 289 | }), | ||
| 290 | ), | ||
| 291 | authorizeDpop: (input) => | ||
| 292 | input.obtainBootstrap.pipe( | ||
| 293 | Effect.tap((bootstrap) => | ||
| 294 | Ref.update(bootstrapCredentials, (values) => [...values, bootstrap.credential]), | ||
| 295 | ), | ||
| 296 | Effect.as({ | ||
| 297 | environmentId: input.expectedEnvironmentId, | ||
| 298 | label: "Cloud", | ||
| 299 | httpBaseUrl: ENDPOINT.httpBaseUrl, | ||
| 300 | socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", | ||
| 301 | httpAuthorization: { | ||
| 302 | _tag: "Dpop" as const, | ||
| 303 | accessToken: "dpop-access-token", | ||
| 304 | }, | ||
| 305 | }), | ||
| 306 | ), | ||
| 307 | }); | ||
| 308 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | ||
| 309 | |||
| 310 | expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop"); | ||
| 311 | expect(yield* Ref.get(relayInputs)).toEqual([ | ||
| 312 | { | ||
| 313 | clerkToken: "clerk-session", | ||
| 314 | scopes: [RelayEnvironmentConnectScope], | ||
| 315 | deviceId: "device-1", | ||
| 316 | }, | ||
| 317 | ]); | ||
| 318 | expect(yield* Ref.get(bootstrapCredentials)).toEqual(["relay-bootstrap"]); | ||
| 319 | }), | ||
| 320 | ); | ||
| 321 | |||
| 322 | it.effect("exports the complete relay authorization flow through the product tracer", () => | ||
| 323 | Effect.gen(function* () { | ||
| 324 | const userSpans: Array<string> = []; | ||
| 325 | const productSpans: Array<string> = []; | ||
| 326 | const target = new RelayConnectionTarget({ | ||
| 327 | environmentId: ENVIRONMENT_ID, | ||
| 328 | label: "Cloud", | ||
| 329 | }); | ||
| 330 | const brokerLayer = yield* makeDependencies({ | ||
| 331 | authorizeDpop: (input) => | ||
| 332 | input.obtainBootstrap.pipe( | ||
| 333 | Effect.as({ | ||
| 334 | environmentId: input.expectedEnvironmentId, | ||
| 335 | label: "Cloud", | ||
| 336 | httpBaseUrl: ENDPOINT.httpBaseUrl, | ||
| 337 | socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", | ||
| 338 | httpAuthorization: { | ||
| 339 | _tag: "Dpop" as const, | ||
| 340 | accessToken: "dpop-access-token", | ||
| 341 | }, | ||
| 342 | }), | ||
| 343 | Effect.withSpan("test.remote.authorizeDpop"), | ||
| 344 | ), | ||
| 345 | }); | ||
| 346 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | ||
| 347 | |||
| 348 | yield* broker | ||
| 349 | .prepare(catalogEntry(target)) | ||
| 350 | .pipe( | ||
| 351 | Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), | ||
| 352 | Effect.withTracer(collectingTracer(userSpans)), | ||
| 353 | ); | ||
| 354 | |||
| 355 | expect(productSpans).toContain("clientRuntime.connection.broker.relay"); | ||
| 356 | expect(productSpans).toContain("test.remote.authorizeDpop"); | ||
| 357 | expect(userSpans).toContain("clientRuntime.connection.broker.prepare"); | ||
| 358 | expect(userSpans).not.toContain("test.remote.authorizeDpop"); | ||
| 359 | }), | ||
| 360 | ); | ||
| 361 | |||
| 362 | it.effect("delegates SSH launch to the platform gateway before remote authorization", () => | ||
| 363 | Effect.gen(function* () { | ||
| 364 | const preparedTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]); | ||
| 365 | const target = new SshConnectionTarget({ | ||
| 366 | environmentId: ENVIRONMENT_ID, | ||
| 367 | label: "SSH", | ||
| 368 | connectionId: "ssh-1", | ||
| 369 | }); | ||
| 370 | const profile = new SshConnectionProfile({ | ||
| 371 | connectionId: "ssh-1", | ||
| 372 | environmentId: ENVIRONMENT_ID, | ||
| 373 | label: "SSH", | ||
| 374 | target: SSH_TARGET, | ||
| 375 | }); | ||
| 376 | const brokerLayer = yield* makeDependencies({ | ||
| 377 | prepareSsh: (input) => | ||
| 378 | Ref.update(preparedTargets, (values) => [...values, input.target]).pipe( | ||
| 379 | Effect.as({ | ||
| 380 | bootstrap: { | ||
| 381 | target: input.target, | ||
| 382 | httpBaseUrl: "http://127.0.0.1:4010", | ||
| 383 | wsBaseUrl: "ws://127.0.0.1:4010", | ||
| 384 | pairingToken: null, | ||
| 385 | }, | ||
| 386 | bearerToken: "ssh-bearer", | ||
| 387 | }), | ||
| 388 | ), | ||
| 389 | }); | ||
| 390 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | ||
| 391 | |||
| 392 | expect( | ||
| 393 | (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, | ||
| 394 | ).toContain("wsTicket=bearer"); | ||
| 395 | expect(yield* Ref.get(preparedTargets)).toEqual([SSH_TARGET]); | ||
| 396 | }), | ||
| 397 | ); | ||
| 398 | |||
| 399 | it.effect("classifies relay request timeouts as retryable connection failures", () => | ||
| 400 | Effect.gen(function* () { | ||
| 401 | const target = new RelayConnectionTarget({ | ||
| 402 | environmentId: ENVIRONMENT_ID, | ||
| 403 | label: "Cloud", | ||
| 404 | }); | ||
| 405 | const brokerLayer = yield* makeDependencies({ | ||
| 406 | connectEnvironment: () => | ||
| 407 | Effect.fail( | ||
| 408 | new ManagedRelayClientError({ | ||
| 409 | message: "Relay timed out.", | ||
| 410 | cause: new ManagedRelayRequestTimeoutError({ | ||
| 411 | message: "Relay timed out.", | ||
| 412 | }), | ||
| 413 | }), | ||
| 414 | ), | ||
| 415 | }); | ||
| 416 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | ||
| 417 | const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); | ||
| 418 | |||
| 419 | expect(error).toBeInstanceOf(ConnectionTransientError); | ||
| 420 | expect(error).toMatchObject({ reason: "timeout" }); | ||
| 421 | }), | ||
| 422 | ); | ||
| 423 | }); | ||
packages/client-runtime/src/connection/resolver.ts created+257| ... | @@ -0,0 +1,257 @@ | ||
| 1 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; | ||
| 2 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | |||
| 9 | import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | ||
| 10 | import { ManagedRelayClient } from "../relay/managedRelay.ts"; | ||
| 11 | import { | ||
| 12 | CloudSession, | ||
| 13 | RelayDeviceIdentity, | ||
| 14 | SshEnvironmentGateway, | ||
| 15 | } from "../platform/capabilities.ts"; | ||
| 16 | import { | ||
| 17 | BearerConnectionCredential, | ||
| 18 | BearerConnectionProfile, | ||
| 19 | type ConnectionCatalogEntry, | ||
| 20 | ConnectionCredentialStore, | ||
| 21 | ConnectionProfileStore, | ||
| 22 | SshConnectionProfile, | ||
| 23 | } from "./catalog.ts"; | ||
| 24 | import { | ||
| 25 | credentialMissingError, | ||
| 26 | environmentMismatchError, | ||
| 27 | mapManagedRelayError, | ||
| 28 | profileMissingError, | ||
| 29 | } from "./errors.ts"; | ||
| 30 | import type { | ||
| 31 | BearerConnectionTarget, | ||
| 32 | ConnectionTarget, | ||
| 33 | PreparedConnection, | ||
| 34 | PrimaryConnectionTarget, | ||
| 35 | RelayConnectionTarget, | ||
| 36 | SshConnectionTarget, | ||
| 37 | } from "./model.ts"; | ||
| 38 | import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; | ||
| 39 | |||
| 40 | export class ConnectionResolver extends Context.Service< | ||
| 41 | ConnectionResolver, | ||
| 42 | { | ||
| 43 | readonly prepare: ( | ||
| 44 | entry: ConnectionCatalogEntry, | ||
| 45 | ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>; | ||
| 46 | } | ||
| 47 | >()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {} | ||
| 48 | |||
| 49 | const isBearerProfile = Schema.is(BearerConnectionProfile); | ||
| 50 | const isSshProfile = Schema.is(SshConnectionProfile); | ||
| 51 | const isBearerCredential = Schema.is(BearerConnectionCredential); | ||
| 52 | |||
| 53 | function primarySocketUrl(target: PrimaryConnectionTarget): string { | ||
| 54 | const url = new URL(target.wsBaseUrl); | ||
| 55 | if (url.pathname === "" || url.pathname === "/") { | ||
| 56 | url.pathname = "/ws"; | ||
| 57 | } | ||
| 58 | return url.toString(); | ||
| 59 | } | ||
| 60 | |||
| 61 | const primaryBroker = Effect.fn("clientRuntime.connection.broker.primary")( | ||
| 62 | (target: PrimaryConnectionTarget) => | ||
| 63 | Effect.succeed({ | ||
| 64 | environmentId: target.environmentId, | ||
| 65 | label: target.label, | ||
| 66 | httpBaseUrl: target.httpBaseUrl, | ||
| 67 | socketUrl: primarySocketUrl(target), | ||
| 68 | httpAuthorization: null, | ||
| 69 | target, | ||
| 70 | } satisfies PreparedConnection), | ||
| 71 | ); | ||
| 72 | |||
| 73 | const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { | ||
| 74 | const credentials = yield* ConnectionCredentialStore; | ||
| 75 | const remote = yield* RemoteEnvironmentAuthorization; | ||
| 76 | |||
| 77 | return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( | ||
| 78 | entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, | ||
| 79 | ) { | ||
| 80 | const target = entry.target; | ||
| 81 | const profile = yield* Option.match(entry.profile, { | ||
| 82 | onNone: () => Effect.fail(profileMissingError(target.connectionId)), | ||
| 83 | onSome: Effect.succeed, | ||
| 84 | }); | ||
| 85 | if (!isBearerProfile(profile)) { | ||
| 86 | return yield* new ConnectionBlockedError({ | ||
| 87 | reason: "configuration", | ||
| 88 | message: `Connection profile ${target.connectionId} is not a bearer connection.`, | ||
| 89 | }); | ||
| 90 | } | ||
| 91 | if (profile.environmentId !== target.environmentId) { | ||
| 92 | return yield* environmentMismatchError({ | ||
| 93 | expected: target.environmentId, | ||
| 94 | actual: profile.environmentId, | ||
| 95 | }); | ||
| 96 | } | ||
| 97 | const credential = yield* credentials.get(target.connectionId).pipe( | ||
| 98 | Effect.flatMap( | ||
| 99 | Option.match({ | ||
| 100 | onNone: () => Effect.fail(credentialMissingError(target.connectionId)), | ||
| 101 | onSome: Effect.succeed, | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | ); | ||
| 105 | if (!isBearerCredential(credential)) { | ||
| 106 | return yield* credentialMissingError(target.connectionId); | ||
| 107 | } | ||
| 108 | const authorized = yield* remote.authorizeBearer({ | ||
| 109 | expectedEnvironmentId: target.environmentId, | ||
| 110 | httpBaseUrl: profile.httpBaseUrl, | ||
| 111 | wsBaseUrl: profile.wsBaseUrl, | ||
| 112 | bearerToken: credential.token, | ||
| 113 | }); | ||
| 114 | return { | ||
| 115 | environmentId: authorized.environmentId, | ||
| 116 | label: authorized.label, | ||
| 117 | httpBaseUrl: authorized.httpBaseUrl, | ||
| 118 | socketUrl: authorized.socketUrl, | ||
| 119 | httpAuthorization: authorized.httpAuthorization, | ||
| 120 | target, | ||
| 121 | } satisfies PreparedConnection; | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { | ||
| 126 | const relay = yield* ManagedRelayClient; | ||
| 127 | const session = yield* CloudSession; | ||
| 128 | const identity = yield* RelayDeviceIdentity; | ||
| 129 | const remote = yield* RemoteEnvironmentAuthorization; | ||
| 130 | |||
| 131 | return Effect.fnUntraced( | ||
| 132 | function* (target: RelayConnectionTarget) { | ||
| 133 | const authorized = yield* remote.authorizeDpop({ | ||
| 134 | expectedEnvironmentId: target.environmentId, | ||
| 135 | obtainBootstrap: Effect.gen(function* () { | ||
| 136 | const clerkToken = yield* session.clerkToken.pipe( | ||
| 137 | Effect.withSpan("relay.connection.cloudSessionToken.resolve"), | ||
| 138 | ); | ||
| 139 | const deviceId = yield* identity.deviceId.pipe( | ||
| 140 | Effect.withSpan("relay.connection.deviceIdentity.resolve"), | ||
| 141 | ); | ||
| 142 | const connected = yield* relay | ||
| 143 | .connectEnvironment({ | ||
| 144 | clerkToken, | ||
| 145 | scopes: [RelayEnvironmentConnectScope], | ||
| 146 | environmentId: target.environmentId, | ||
| 147 | ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}), | ||
| 148 | }) | ||
| 149 | .pipe(Effect.mapError(mapManagedRelayError)); | ||
| 150 | if (connected.environmentId !== target.environmentId) { | ||
| 151 | return yield* environmentMismatchError({ | ||
| 152 | expected: target.environmentId, | ||
| 153 | actual: connected.environmentId, | ||
| 154 | }); | ||
| 155 | } | ||
| 156 | return connected; | ||
| 157 | }).pipe(Effect.withSpan("relay.connection.bootstrap.obtain")), | ||
| 158 | }); | ||
| 159 | return { | ||
| 160 | environmentId: authorized.environmentId, | ||
| 161 | label: authorized.label, | ||
| 162 | httpBaseUrl: authorized.httpBaseUrl, | ||
| 163 | socketUrl: authorized.socketUrl, | ||
| 164 | httpAuthorization: authorized.httpAuthorization, | ||
| 165 | target, | ||
| 166 | } satisfies PreparedConnection; | ||
| 167 | }, | ||
| 168 | Effect.withSpan("clientRuntime.connection.broker.relay"), | ||
| 169 | withRelayClientTracing, | ||
| 170 | ); | ||
| 171 | }); | ||
| 172 | |||
| 173 | const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { | ||
| 174 | const profiles = yield* ConnectionProfileStore; | ||
| 175 | const ssh = yield* SshEnvironmentGateway; | ||
| 176 | const remote = yield* RemoteEnvironmentAuthorization; | ||
| 177 | |||
| 178 | return Effect.fn("clientRuntime.connection.broker.ssh")(function* ( | ||
| 179 | entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget }, | ||
| 180 | ) { | ||
| 181 | const target = entry.target; | ||
| 182 | const profile = yield* Option.match(entry.profile, { | ||
| 183 | onNone: () => Effect.fail(profileMissingError(target.connectionId)), | ||
| 184 | onSome: Effect.succeed, | ||
| 185 | }); | ||
| 186 | if (!isSshProfile(profile)) { | ||
| 187 | return yield* new ConnectionBlockedError({ | ||
| 188 | reason: "configuration", | ||
| 189 | message: `Connection profile ${target.connectionId} is not an SSH connection.`, | ||
| 190 | }); | ||
| 191 | } | ||
| 192 | if (profile.environmentId !== target.environmentId) { | ||
| 193 | return yield* environmentMismatchError({ | ||
| 194 | expected: target.environmentId, | ||
| 195 | actual: profile.environmentId, | ||
| 196 | }); | ||
| 197 | } | ||
| 198 | const prepared = yield* ssh.prepare({ | ||
| 199 | connectionId: target.connectionId, | ||
| 200 | expectedEnvironmentId: target.environmentId, | ||
| 201 | target: profile.target, | ||
| 202 | }); | ||
| 203 | yield* profiles.put( | ||
| 204 | new SshConnectionProfile({ | ||
| 205 | connectionId: profile.connectionId, | ||
| 206 | environmentId: profile.environmentId, | ||
| 207 | label: profile.label, | ||
| 208 | target: prepared.bootstrap.target, | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | const authorized = yield* remote.authorizeBearer({ | ||
| 212 | expectedEnvironmentId: target.environmentId, | ||
| 213 | httpBaseUrl: prepared.bootstrap.httpBaseUrl, | ||
| 214 | wsBaseUrl: prepared.bootstrap.wsBaseUrl, | ||
| 215 | bearerToken: prepared.bearerToken, | ||
| 216 | }); | ||
| 217 | return { | ||
| 218 | environmentId: authorized.environmentId, | ||
| 219 | label: authorized.label, | ||
| 220 | httpBaseUrl: authorized.httpBaseUrl, | ||
| 221 | socketUrl: authorized.socketUrl, | ||
| 222 | httpAuthorization: authorized.httpAuthorization, | ||
| 223 | target, | ||
| 224 | } satisfies PreparedConnection; | ||
| 225 | }); | ||
| 226 | }); | ||
| 227 | |||
| 228 | export const connectionResolverLayer = Layer.effect( | ||
| 229 | ConnectionResolver, | ||
| 230 | Effect.gen(function* () { | ||
| 231 | const bearer = yield* makeBearerBroker(); | ||
| 232 | const relay = yield* makeRelayBroker(); | ||
| 233 | const ssh = yield* makeSshBroker(); | ||
| 234 | |||
| 235 | const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( | ||
| 236 | entry: ConnectionCatalogEntry, | ||
| 237 | ) { | ||
| 238 | const target: ConnectionTarget = entry.target; | ||
| 239 | yield* Effect.annotateCurrentSpan({ | ||
| 240 | "connection.environment.id": target.environmentId, | ||
| 241 | "connection.target.kind": target._tag, | ||
| 242 | }); | ||
| 243 | switch (target._tag) { | ||
| 244 | case "PrimaryConnectionTarget": | ||
| 245 | return yield* primaryBroker(target); | ||
| 246 | case "BearerConnectionTarget": | ||
| 247 | return yield* bearer({ ...entry, target }); | ||
| 248 | case "RelayConnectionTarget": | ||
| 249 | return yield* relay(target); | ||
| 250 | case "SshConnectionTarget": | ||
| 251 | return yield* ssh({ ...entry, target }); | ||
| 252 | } | ||
| 253 | }); | ||
| 254 | |||
| 255 | return ConnectionResolver.of({ prepare }); | ||
| 256 | }), | ||
| 257 | ); | ||
packages/client-runtime/src/connection/supervisor.test.ts created+847| ... | @@ -0,0 +1,847 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Ref from "effect/Ref"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 11 | import * as TestClock from "effect/testing/TestClock"; | ||
| 12 | import * as Tracer from "effect/Tracer"; | ||
| 13 | |||
| 14 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | ||
| 15 | import type { ConnectionCatalogEntry } from "./catalog.ts"; | ||
| 16 | import { Connectivity } from "./connectivity.ts"; | ||
| 17 | import { | ||
| 18 | ConnectionDriver, | ||
| 19 | type ConnectionDriverProgress, | ||
| 20 | type EnvironmentConnectionLease, | ||
| 21 | } from "./driver.ts"; | ||
| 22 | import { | ||
| 23 | ConnectionBlockedError, | ||
| 24 | ConnectionTransientError, | ||
| 25 | PrimaryConnectionTarget, | ||
| 26 | RelayConnectionTarget, | ||
| 27 | type ConnectionAttemptError, | ||
| 28 | type ConnectionTarget, | ||
| 29 | type NetworkStatus, | ||
| 30 | type PreparedConnection, | ||
| 31 | type SupervisorConnectionState, | ||
| 32 | } from "./model.ts"; | ||
| 33 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 34 | import { makeEnvironmentSupervisor } from "./supervisor.ts"; | ||
| 35 | import { ConnectionWakeups } from "./wakeups.ts"; | ||
| 36 | |||
| 37 | const TARGET = new PrimaryConnectionTarget({ | ||
| 38 | environmentId: EnvironmentId.make("environment-1"), | ||
| 39 | label: "Test environment", | ||
| 40 | httpBaseUrl: "https://environment.example.test", | ||
| 41 | wsBaseUrl: "wss://environment.example.test", | ||
| 42 | }); | ||
| 43 | |||
| 44 | const RELAY_TARGET = new RelayConnectionTarget({ | ||
| 45 | environmentId: TARGET.environmentId, | ||
| 46 | label: TARGET.label, | ||
| 47 | }); | ||
| 48 | |||
| 49 | const TARGET_ENTRY: ConnectionCatalogEntry = { | ||
| 50 | target: TARGET, | ||
| 51 | profile: Option.none(), | ||
| 52 | }; | ||
| 53 | |||
| 54 | const RELAY_ENTRY: ConnectionCatalogEntry = { | ||
| 55 | target: RELAY_TARGET, | ||
| 56 | profile: Option.none(), | ||
| 57 | }; | ||
| 58 | |||
| 59 | const PREPARED_CONNECTION: PreparedConnection = { | ||
| 60 | environmentId: TARGET.environmentId, | ||
| 61 | label: TARGET.label, | ||
| 62 | httpBaseUrl: TARGET.httpBaseUrl, | ||
| 63 | socketUrl: "wss://environment.example.test/ws", | ||
| 64 | httpAuthorization: null, | ||
| 65 | target: TARGET, | ||
| 66 | }; | ||
| 67 | |||
| 68 | const TEST_RPC_CLIENT = {} as WsRpcProtocolClient; | ||
| 69 | |||
| 70 | function transient(message = "Connection failed.") { | ||
| 71 | return new ConnectionTransientError({ | ||
| 72 | reason: "transport", | ||
| 73 | message, | ||
| 74 | }); | ||
| 75 | } | ||
| 76 | |||
| 77 | function blocked(message = "Authentication required.") { | ||
| 78 | return new ConnectionBlockedError({ | ||
| 79 | reason: "authentication", | ||
| 80 | message, | ||
| 81 | }); | ||
| 82 | } | ||
| 83 | |||
| 84 | function awaitState( | ||
| 85 | state: SubscriptionRef.SubscriptionRef<SupervisorConnectionState>, | ||
| 86 | predicate: (value: SupervisorConnectionState) => boolean, | ||
| 87 | ) { | ||
| 88 | return SubscriptionRef.changes(state).pipe( | ||
| 89 | Stream.filter(predicate), | ||
| 90 | Stream.runHead, | ||
| 91 | Effect.map(Option.getOrThrow), | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | |||
| 95 | const eventuallyState = Effect.fn("TestConnectionHarness.eventuallyState")(function* ( | ||
| 96 | state: SubscriptionRef.SubscriptionRef<SupervisorConnectionState>, | ||
| 97 | predicate: (value: SupervisorConnectionState) => boolean, | ||
| 98 | ) { | ||
| 99 | let lastState = yield* SubscriptionRef.get(state); | ||
| 100 | for (let iteration = 0; iteration < 100; iteration += 1) { | ||
| 101 | lastState = yield* SubscriptionRef.get(state); | ||
| 102 | if (predicate(lastState)) { | ||
| 103 | return lastState; | ||
| 104 | } | ||
| 105 | yield* Effect.yieldNow; | ||
| 106 | } | ||
| 107 | return yield* Effect.die( | ||
| 108 | new Error( | ||
| 109 | `Expected supervisor state was not observed. Last state: phase=${lastState.phase}, stage=${lastState.stage ?? "none"}, attempt=${lastState.attempt}, generation=${lastState.generation}`, | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | }); | ||
| 113 | |||
| 114 | const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: { | ||
| 115 | readonly networkStatus?: NetworkStatus; | ||
| 116 | readonly prepare?: ( | ||
| 117 | attempt: number, | ||
| 118 | target: ConnectionTarget, | ||
| 119 | ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>; | ||
| 120 | readonly ready?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 121 | readonly probe?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 122 | }) { | ||
| 123 | const networkStatus = yield* SubscriptionRef.make<NetworkStatus>( | ||
| 124 | options?.networkStatus ?? "online", | ||
| 125 | ); | ||
| 126 | const prepareCount = yield* Ref.make(0); | ||
| 127 | const sessionCount = yield* Ref.make(0); | ||
| 128 | const releaseCount = yield* Ref.make(0); | ||
| 129 | const wakeups = yield* SubscriptionRef.make<{ | ||
| 130 | readonly sequence: number; | ||
| 131 | readonly reason: "application-active" | "credentials-changed"; | ||
| 132 | }>({ | ||
| 133 | sequence: 0, | ||
| 134 | reason: "application-active", | ||
| 135 | }); | ||
| 136 | const closedSessions = yield* Ref.make< | ||
| 137 | ReadonlyArray<Deferred.Deferred<never, ConnectionTransientError>> | ||
| 138 | >([]); | ||
| 139 | |||
| 140 | const connectivity = Connectivity.of({ | ||
| 141 | status: SubscriptionRef.get(networkStatus), | ||
| 142 | changes: SubscriptionRef.changes(networkStatus), | ||
| 143 | }); | ||
| 144 | |||
| 145 | const prepare = Effect.fn("TestConnectionDriver.prepare")(function* (target: ConnectionTarget) { | ||
| 146 | const attempt = yield* Ref.updateAndGet(prepareCount, (count) => count + 1); | ||
| 147 | if (options?.prepare) { | ||
| 148 | return yield* options.prepare(attempt, target); | ||
| 149 | } | ||
| 150 | return PREPARED_CONNECTION; | ||
| 151 | }); | ||
| 152 | |||
| 153 | const connect = Effect.fn("TestConnectionDriver.connect")(function* ( | ||
| 154 | entry: ConnectionCatalogEntry, | ||
| 155 | reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>, | ||
| 156 | ) { | ||
| 157 | const target = entry.target; | ||
| 158 | yield* reportProgress({ stage: "preparing" }); | ||
| 159 | const prepared = yield* prepare(target); | ||
| 160 | yield* reportProgress({ stage: "opening", prepared }); | ||
| 161 | |||
| 162 | const attempt = yield* Ref.updateAndGet(sessionCount, (count) => count + 1); | ||
| 163 | const closed = yield* Deferred.make<never, ConnectionTransientError>(); | ||
| 164 | yield* Ref.update(closedSessions, (sessions) => [...sessions, closed]); | ||
| 165 | |||
| 166 | const session = yield* Effect.acquireRelease( | ||
| 167 | Effect.succeed({ | ||
| 168 | client: TEST_RPC_CLIENT, | ||
| 169 | initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), | ||
| 170 | ready: options?.ready?.(attempt) ?? Effect.void, | ||
| 171 | probe: options?.probe?.(attempt) ?? Effect.void, | ||
| 172 | closed: Deferred.await(closed), | ||
| 173 | } satisfies RpcSession), | ||
| 174 | () => Ref.update(releaseCount, (count) => count + 1), | ||
| 175 | ); | ||
| 176 | |||
| 177 | yield* reportProgress({ stage: "synchronizing", prepared }); | ||
| 178 | yield* session.ready; | ||
| 179 | return { prepared, session } satisfies EnvironmentConnectionLease; | ||
| 180 | }); | ||
| 181 | |||
| 182 | const dependencies = Layer.mergeAll( | ||
| 183 | Layer.succeed(Connectivity, connectivity), | ||
| 184 | Layer.succeed( | ||
| 185 | ConnectionWakeups, | ||
| 186 | ConnectionWakeups.of({ | ||
| 187 | changes: SubscriptionRef.changes(wakeups).pipe( | ||
| 188 | Stream.drop(1), | ||
| 189 | Stream.map((event) => event.reason), | ||
| 190 | ), | ||
| 191 | }), | ||
| 192 | ), | ||
| 193 | Layer.succeed(ConnectionDriver, ConnectionDriver.of({ connect })), | ||
| 194 | ); | ||
| 195 | |||
| 196 | return { | ||
| 197 | dependencies, | ||
| 198 | prepareCount, | ||
| 199 | sessionCount, | ||
| 200 | releaseCount, | ||
| 201 | setNetworkStatus: (status: NetworkStatus) => SubscriptionRef.set(networkStatus, status), | ||
| 202 | wake: (reason: "application-active" | "credentials-changed") => | ||
| 203 | SubscriptionRef.update(wakeups, (event) => ({ | ||
| 204 | sequence: event.sequence + 1, | ||
| 205 | reason, | ||
| 206 | })), | ||
| 207 | closeLatestSession: Effect.fn("TestConnectionHarness.closeLatestSession")(function* ( | ||
| 208 | error = transient("Session closed."), | ||
| 209 | ) { | ||
| 210 | const sessions = yield* Ref.get(closedSessions); | ||
| 211 | const latest = sessions.at(-1); | ||
| 212 | if (latest) { | ||
| 213 | yield* Deferred.fail(latest, error); | ||
| 214 | } | ||
| 215 | }), | ||
| 216 | }; | ||
| 217 | }); | ||
| 218 | |||
| 219 | describe("EnvironmentSupervisor", () => { | ||
| 220 | it.effect("exports each relay setup as a standalone linked trace that ends at readiness", () => | ||
| 221 | Effect.gen(function* () { | ||
| 222 | const spans: Array<Tracer.NativeSpan> = []; | ||
| 223 | const tracer = Tracer.make({ | ||
| 224 | span: (options) => { | ||
| 225 | const span = new Tracer.NativeSpan(options); | ||
| 226 | const end = span.end.bind(span); | ||
| 227 | span.end = (endTime, exit) => { | ||
| 228 | end(endTime, exit); | ||
| 229 | spans.push(span); | ||
| 230 | }; | ||
| 231 | return span; | ||
| 232 | }, | ||
| 233 | }); | ||
| 234 | const harness = yield* makeHarness({ | ||
| 235 | prepare: (attempt) => | ||
| 236 | attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), | ||
| 237 | }); | ||
| 238 | const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { | ||
| 239 | initiallyDesired: true, | ||
| 240 | }).pipe( | ||
| 241 | Effect.provide(harness.dependencies), | ||
| 242 | Effect.provideService(RelayClientTracer, Option.some(tracer)), | ||
| 243 | ); | ||
| 244 | |||
| 245 | yield* awaitState( | ||
| 246 | supervisor.state, | ||
| 247 | (state) => state.phase === "backoff" && state.attempt === 1, | ||
| 248 | ); | ||
| 249 | const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt"); | ||
| 250 | expect(firstAttempt).toBeDefined(); | ||
| 251 | |||
| 252 | yield* TestClock.adjust("1 second"); | ||
| 253 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 254 | |||
| 255 | const attempts = spans.filter((span) => span.name === "relay.connection.attempt"); | ||
| 256 | expect(attempts).toHaveLength(2); | ||
| 257 | expect(attempts[0]?.traceId).not.toBe(attempts[1]?.traceId); | ||
| 258 | expect(attempts[1]?.links.map((link) => link.span.spanId)).toContain(attempts[0]?.spanId); | ||
| 259 | expect(yield* Ref.get(harness.releaseCount)).toBe(0); | ||
| 260 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 261 | ); | ||
| 262 | |||
| 263 | it.effect("does not attempt a connection until it is desired", () => | ||
| 264 | Effect.gen(function* () { | ||
| 265 | const harness = yield* makeHarness(); | ||
| 266 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( | ||
| 267 | Effect.provide(harness.dependencies), | ||
| 268 | ); | ||
| 269 | |||
| 270 | expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("available"); | ||
| 271 | expect(yield* Ref.get(harness.prepareCount)).toBe(0); | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | |||
| 275 | it.effect("does not let the initial connect signal cancel the first attempt", () => | ||
| 276 | Effect.gen(function* () { | ||
| 277 | const harness = yield* makeHarness(); | ||
| 278 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( | ||
| 279 | Effect.provide(harness.dependencies), | ||
| 280 | ); | ||
| 281 | |||
| 282 | yield* supervisor.connect; | ||
| 283 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 284 | |||
| 285 | expect(yield* Ref.get(harness.sessionCount)).toBe(1); | ||
| 286 | expect(yield* Ref.get(harness.releaseCount)).toBe(0); | ||
| 287 | }), | ||
| 288 | ); | ||
| 289 | |||
| 290 | it.effect("waits while offline and connects immediately when the network returns", () => | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const harness = yield* makeHarness({ networkStatus: "offline" }); | ||
| 293 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 294 | initiallyDesired: true, | ||
| 295 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 296 | |||
| 297 | yield* awaitState(supervisor.state, (state) => state.phase === "offline"); | ||
| 298 | expect(yield* Ref.get(harness.prepareCount)).toBe(0); | ||
| 299 | |||
| 300 | yield* harness.setNetworkStatus("online"); | ||
| 301 | const ready = yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 302 | |||
| 303 | expect(ready).toMatchObject({ | ||
| 304 | desired: true, | ||
| 305 | network: "online", | ||
| 306 | phase: "connected", | ||
| 307 | attempt: 1, | ||
| 308 | generation: 1, | ||
| 309 | lastFailure: null, | ||
| 310 | }); | ||
| 311 | expect(yield* Ref.get(harness.prepareCount)).toBe(1); | ||
| 312 | }), | ||
| 313 | ); | ||
| 314 | |||
| 315 | it.effect("retries forever with exponential backoff capped at sixteen seconds", () => | ||
| 316 | Effect.gen(function* () { | ||
| 317 | const harness = yield* makeHarness({ | ||
| 318 | prepare: () => Effect.fail(transient()), | ||
| 319 | }); | ||
| 320 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 321 | initiallyDesired: true, | ||
| 322 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 323 | |||
| 324 | yield* awaitState( | ||
| 325 | supervisor.state, | ||
| 326 | (state) => state.phase === "backoff" && state.attempt === 1, | ||
| 327 | ); | ||
| 328 | expect(yield* Ref.get(harness.prepareCount)).toBe(1); | ||
| 329 | |||
| 330 | for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { | ||
| 331 | yield* TestClock.adjust(delay); | ||
| 332 | yield* eventuallyState( | ||
| 333 | supervisor.state, | ||
| 334 | (state) => state.phase === "backoff" && state.attempt === index + 2, | ||
| 335 | ); | ||
| 336 | } | ||
| 337 | |||
| 338 | expect(yield* Ref.get(harness.prepareCount)).toBe(7); | ||
| 339 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 340 | ); | ||
| 341 | |||
| 342 | it.effect("keeps the latest failure visible throughout the next connection attempt", () => | ||
| 343 | Effect.gen(function* () { | ||
| 344 | const harness = yield* makeHarness({ | ||
| 345 | prepare: (attempt) => | ||
| 346 | attempt === 1 ? Effect.fail(transient("Relay connection timed out.")) : Effect.never, | ||
| 347 | }); | ||
| 348 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 349 | initiallyDesired: true, | ||
| 350 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 351 | |||
| 352 | yield* awaitState( | ||
| 353 | supervisor.state, | ||
| 354 | (state) => state.phase === "backoff" && state.attempt === 1, | ||
| 355 | ); | ||
| 356 | yield* TestClock.adjust("1 second"); | ||
| 357 | |||
| 358 | const retrying = yield* awaitState( | ||
| 359 | supervisor.state, | ||
| 360 | (state) => | ||
| 361 | state.phase === "connecting" && state.stage === "preparing" && state.attempt === 2, | ||
| 362 | ); | ||
| 363 | expect(retrying).toMatchObject({ | ||
| 364 | phase: "connecting", | ||
| 365 | stage: "preparing", | ||
| 366 | attempt: 2, | ||
| 367 | lastFailure: { | ||
| 368 | _tag: "ConnectionTransientError", | ||
| 369 | reason: "transport", | ||
| 370 | message: "Relay connection timed out.", | ||
| 371 | }, | ||
| 372 | }); | ||
| 373 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 374 | ); | ||
| 375 | |||
| 376 | it.effect("retries when a session never becomes ready", () => | ||
| 377 | Effect.gen(function* () { | ||
| 378 | const harness = yield* makeHarness({ | ||
| 379 | ready: () => Effect.never, | ||
| 380 | }); | ||
| 381 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 382 | initiallyDesired: true, | ||
| 383 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 384 | |||
| 385 | yield* awaitState( | ||
| 386 | supervisor.state, | ||
| 387 | (state) => state.phase === "connecting" && state.stage === "synchronizing", | ||
| 388 | ); | ||
| 389 | yield* TestClock.adjust("14 seconds"); | ||
| 390 | expect((yield* SubscriptionRef.get(supervisor.state)).stage).toBe("synchronizing"); | ||
| 391 | |||
| 392 | yield* TestClock.adjust("1 second"); | ||
| 393 | const retrying = yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); | ||
| 394 | |||
| 395 | expect(retrying).toMatchObject({ | ||
| 396 | phase: "backoff", | ||
| 397 | lastFailure: { | ||
| 398 | _tag: "ConnectionTransientError", | ||
| 399 | reason: "timeout", | ||
| 400 | message: "Test environment did not respond during connection setup.", | ||
| 401 | }, | ||
| 402 | }); | ||
| 403 | expect(yield* Ref.get(harness.releaseCount)).toBe(1); | ||
| 404 | expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); | ||
| 405 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 406 | ); | ||
| 407 | |||
| 408 | it.effect("interrupts and releases a connection attempt when setup times out", () => | ||
| 409 | Effect.gen(function* () { | ||
| 410 | const harness = yield* makeHarness({ | ||
| 411 | prepare: () => Effect.never, | ||
| 412 | }); | ||
| 413 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 414 | initiallyDesired: true, | ||
| 415 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 416 | |||
| 417 | yield* awaitState( | ||
| 418 | supervisor.state, | ||
| 419 | (state) => state.phase === "connecting" && state.stage === "preparing", | ||
| 420 | ); | ||
| 421 | yield* TestClock.adjust("15 seconds"); | ||
| 422 | const retrying = yield* eventuallyState( | ||
| 423 | supervisor.state, | ||
| 424 | (state) => state.phase === "backoff" && state.attempt === 1, | ||
| 425 | ); | ||
| 426 | |||
| 427 | expect(retrying).toMatchObject({ | ||
| 428 | lastFailure: { | ||
| 429 | _tag: "ConnectionTransientError", | ||
| 430 | reason: "timeout", | ||
| 431 | message: "Test environment did not respond during connection setup.", | ||
| 432 | }, | ||
| 433 | }); | ||
| 434 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 435 | ); | ||
| 436 | |||
| 437 | it.effect("converts unexpected driver defects into retryable failures", () => | ||
| 438 | Effect.gen(function* () { | ||
| 439 | const harness = yield* makeHarness({ | ||
| 440 | prepare: (attempt) => | ||
| 441 | attempt === 1 | ||
| 442 | ? Effect.die(new Error("Native transport defect.")) | ||
| 443 | : Effect.succeed(PREPARED_CONNECTION), | ||
| 444 | }); | ||
| 445 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 446 | initiallyDesired: true, | ||
| 447 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 448 | |||
| 449 | const failed = yield* awaitState( | ||
| 450 | supervisor.state, | ||
| 451 | (state) => state.phase === "backoff" && state.attempt === 1, | ||
| 452 | ); | ||
| 453 | expect(failed).toMatchObject({ | ||
| 454 | lastFailure: { | ||
| 455 | _tag: "ConnectionTransientError", | ||
| 456 | reason: "transport", | ||
| 457 | message: "Test environment connection failed unexpectedly.", | ||
| 458 | }, | ||
| 459 | }); | ||
| 460 | |||
| 461 | yield* TestClock.adjust("1 second"); | ||
| 462 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 463 | expect(yield* Ref.get(harness.prepareCount)).toBe(2); | ||
| 464 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 465 | ); | ||
| 466 | |||
| 467 | it.effect("explicit retry interrupts the current backoff", () => | ||
| 468 | Effect.gen(function* () { | ||
| 469 | const harness = yield* makeHarness({ | ||
| 470 | prepare: (attempt) => | ||
| 471 | attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), | ||
| 472 | }); | ||
| 473 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 474 | initiallyDesired: true, | ||
| 475 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 476 | |||
| 477 | yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); | ||
| 478 | yield* supervisor.retryNow; | ||
| 479 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 480 | |||
| 481 | expect(yield* Ref.get(harness.prepareCount)).toBe(2); | ||
| 482 | }), | ||
| 483 | ); | ||
| 484 | |||
| 485 | it.effect("keeps blocked failures idle until an external signal requests another attempt", () => | ||
| 486 | Effect.gen(function* () { | ||
| 487 | const harness = yield* makeHarness({ | ||
| 488 | prepare: (attempt) => | ||
| 489 | attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), | ||
| 490 | }); | ||
| 491 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 492 | initiallyDesired: true, | ||
| 493 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 494 | |||
| 495 | yield* awaitState(supervisor.state, (state) => state.phase === "blocked"); | ||
| 496 | yield* TestClock.adjust("1 hour"); | ||
| 497 | expect(yield* Ref.get(harness.prepareCount)).toBe(1); | ||
| 498 | |||
| 499 | yield* supervisor.retryNow; | ||
| 500 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 501 | expect(yield* Ref.get(harness.prepareCount)).toBe(2); | ||
| 502 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 503 | ); | ||
| 504 | |||
| 505 | it.effect("releases a live session while offline and starts a new generation when online", () => | ||
| 506 | Effect.gen(function* () { | ||
| 507 | const harness = yield* makeHarness(); | ||
| 508 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 509 | initiallyDesired: true, | ||
| 510 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 511 | |||
| 512 | yield* awaitState( | ||
| 513 | supervisor.state, | ||
| 514 | (state) => state.phase === "connected" && state.generation === 1, | ||
| 515 | ); | ||
| 516 | yield* harness.setNetworkStatus("offline"); | ||
| 517 | yield* awaitState(supervisor.state, (state) => state.phase === "offline"); | ||
| 518 | |||
| 519 | expect(yield* Ref.get(harness.releaseCount)).toBe(1); | ||
| 520 | expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); | ||
| 521 | |||
| 522 | yield* harness.setNetworkStatus("online"); | ||
| 523 | yield* awaitState( | ||
| 524 | supervisor.state, | ||
| 525 | (state) => state.phase === "connected" && state.generation === 2, | ||
| 526 | ); | ||
| 527 | expect(yield* Ref.get(harness.sessionCount)).toBe(2); | ||
| 528 | }), | ||
| 529 | ); | ||
| 530 | |||
| 531 | it.effect("retries a blocked connection when platform credentials change", () => | ||
| 532 | Effect.gen(function* () { | ||
| 533 | const harness = yield* makeHarness({ | ||
| 534 | prepare: (attempt) => | ||
| 535 | attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), | ||
| 536 | }); | ||
| 537 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 538 | initiallyDesired: true, | ||
| 539 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 540 | |||
| 541 | yield* awaitState(supervisor.state, (state) => state.phase === "blocked"); | ||
| 542 | yield* harness.wake("credentials-changed"); | ||
| 543 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 544 | |||
| 545 | expect(yield* Ref.get(harness.prepareCount)).toBe(2); | ||
| 546 | }), | ||
| 547 | ); | ||
| 548 | |||
| 549 | it.effect("does not let platform wakeups reset an in-flight attempt", () => | ||
| 550 | Effect.gen(function* () { | ||
| 551 | const firstAttemptStarted = yield* Deferred.make<void>(); | ||
| 552 | const harness = yield* makeHarness({ | ||
| 553 | prepare: () => | ||
| 554 | Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)), | ||
| 555 | }); | ||
| 556 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 557 | initiallyDesired: true, | ||
| 558 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 559 | |||
| 560 | yield* Deferred.await(firstAttemptStarted); | ||
| 561 | yield* Effect.all( | ||
| 562 | [ | ||
| 563 | harness.wake("credentials-changed"), | ||
| 564 | harness.wake("application-active"), | ||
| 565 | harness.wake("credentials-changed"), | ||
| 566 | ], | ||
| 567 | { concurrency: "unbounded" }, | ||
| 568 | ); | ||
| 569 | yield* Effect.yieldNow; | ||
| 570 | |||
| 571 | expect(yield* Ref.get(harness.prepareCount)).toBe(1); | ||
| 572 | |||
| 573 | yield* TestClock.adjust("15 seconds"); | ||
| 574 | const retrying = yield* eventuallyState( | ||
| 575 | supervisor.state, | ||
| 576 | (state) => state.phase === "backoff" && state.attempt === 1, | ||
| 577 | ); | ||
| 578 | |||
| 579 | expect(retrying).toMatchObject({ | ||
| 580 | lastFailure: { | ||
| 581 | _tag: "ConnectionTransientError", | ||
| 582 | reason: "timeout", | ||
| 583 | message: "Test environment did not respond during connection setup.", | ||
| 584 | }, | ||
| 585 | }); | ||
| 586 | expect(yield* Ref.get(harness.prepareCount)).toBe(1); | ||
| 587 | expect(yield* Ref.get(harness.sessionCount)).toBe(0); | ||
| 588 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 589 | ); | ||
| 590 | |||
| 591 | it.effect("treats an involuntary session close as transient and reconnects", () => | ||
| 592 | Effect.gen(function* () { | ||
| 593 | const harness = yield* makeHarness(); | ||
| 594 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 595 | initiallyDesired: true, | ||
| 596 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 597 | |||
| 598 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 599 | yield* harness.closeLatestSession(); | ||
| 600 | yield* awaitState( | ||
| 601 | supervisor.state, | ||
| 602 | (state) => state.phase === "backoff" && state.attempt === 1, | ||
| 603 | ); | ||
| 604 | expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); | ||
| 605 | |||
| 606 | yield* TestClock.adjust("1 second"); | ||
| 607 | yield* awaitState( | ||
| 608 | supervisor.state, | ||
| 609 | (state) => state.phase === "connected" && state.generation === 2, | ||
| 610 | ); | ||
| 611 | |||
| 612 | expect(yield* Ref.get(harness.sessionCount)).toBe(2); | ||
| 613 | expect(Option.isSome(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); | ||
| 614 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 615 | ); | ||
| 616 | |||
| 617 | it.effect("keeps escalating backoff when a newly opened session flaps", () => | ||
| 618 | Effect.gen(function* () { | ||
| 619 | const harness = yield* makeHarness(); | ||
| 620 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 621 | initiallyDesired: true, | ||
| 622 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 623 | |||
| 624 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 625 | yield* harness.closeLatestSession(); | ||
| 626 | yield* awaitState( | ||
| 627 | supervisor.state, | ||
| 628 | (state) => state.phase === "backoff" && state.attempt === 1, | ||
| 629 | ); | ||
| 630 | |||
| 631 | yield* TestClock.adjust("1 second"); | ||
| 632 | yield* awaitState( | ||
| 633 | supervisor.state, | ||
| 634 | (state) => state.phase === "connected" && state.generation === 2, | ||
| 635 | ); | ||
| 636 | yield* harness.closeLatestSession(); | ||
| 637 | const secondFailure = yield* awaitState( | ||
| 638 | supervisor.state, | ||
| 639 | (state) => state.phase === "backoff" && state.attempt === 2, | ||
| 640 | ); | ||
| 641 | |||
| 642 | expect(secondFailure.retryAt).not.toBeNull(); | ||
| 643 | |||
| 644 | yield* TestClock.adjust("1 second"); | ||
| 645 | expect(yield* Ref.get(harness.sessionCount)).toBe(2); | ||
| 646 | |||
| 647 | yield* TestClock.adjust("1 second"); | ||
| 648 | yield* awaitState( | ||
| 649 | supervisor.state, | ||
| 650 | (state) => state.phase === "connected" && state.generation === 3, | ||
| 651 | ); | ||
| 652 | expect(yield* Ref.get(harness.sessionCount)).toBe(3); | ||
| 653 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 654 | ); | ||
| 655 | |||
| 656 | it.effect("keeps a healthy session when the application becomes active", () => | ||
| 657 | Effect.gen(function* () { | ||
| 658 | const probeCount = yield* Ref.make(0); | ||
| 659 | const probeCalled = yield* Deferred.make<void>(); | ||
| 660 | const harness = yield* makeHarness({ | ||
| 661 | probe: () => | ||
| 662 | Ref.update(probeCount, (count) => count + 1).pipe( | ||
| 663 | Effect.andThen(Deferred.succeed(probeCalled, undefined)), | ||
| 664 | ), | ||
| 665 | }); | ||
| 666 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 667 | initiallyDesired: true, | ||
| 668 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 669 | |||
| 670 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 671 | yield* harness.wake("application-active"); | ||
| 672 | yield* Deferred.await(probeCalled); | ||
| 673 | |||
| 674 | expect(yield* Ref.get(probeCount)).toBe(1); | ||
| 675 | expect(yield* Ref.get(harness.sessionCount)).toBe(1); | ||
| 676 | expect(yield* Ref.get(harness.releaseCount)).toBe(0); | ||
| 677 | expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); | ||
| 678 | }), | ||
| 679 | ); | ||
| 680 | |||
| 681 | it.effect("reconnects when the foreground liveness probe fails", () => | ||
| 682 | Effect.gen(function* () { | ||
| 683 | const harness = yield* makeHarness({ | ||
| 684 | probe: (attempt) => | ||
| 685 | attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, | ||
| 686 | }); | ||
| 687 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 688 | initiallyDesired: true, | ||
| 689 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 690 | |||
| 691 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 692 | yield* harness.wake("application-active"); | ||
| 693 | yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); | ||
| 694 | yield* TestClock.adjust("1 second"); | ||
| 695 | yield* eventuallyState( | ||
| 696 | supervisor.state, | ||
| 697 | (state) => state.phase === "connected" && state.generation === 2, | ||
| 698 | ); | ||
| 699 | |||
| 700 | expect(yield* Ref.get(harness.sessionCount)).toBe(2); | ||
| 701 | expect(yield* Ref.get(harness.releaseCount)).toBe(1); | ||
| 702 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 703 | ); | ||
| 704 | |||
| 705 | it.effect("times out a stalled foreground liveness probe and reconnects", () => | ||
| 706 | Effect.gen(function* () { | ||
| 707 | const harness = yield* makeHarness({ | ||
| 708 | probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), | ||
| 709 | }); | ||
| 710 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 711 | initiallyDesired: true, | ||
| 712 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 713 | |||
| 714 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 715 | yield* harness.wake("application-active"); | ||
| 716 | yield* TestClock.adjust("15 seconds"); | ||
| 717 | yield* awaitState( | ||
| 718 | supervisor.state, | ||
| 719 | (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout", | ||
| 720 | ); | ||
| 721 | yield* TestClock.adjust("1 second"); | ||
| 722 | yield* eventuallyState( | ||
| 723 | supervisor.state, | ||
| 724 | (state) => state.phase === "connected" && state.generation === 2, | ||
| 725 | ); | ||
| 726 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 727 | ); | ||
| 728 | |||
| 729 | it.effect("honors an explicit disconnect while a foreground probe is stalled", () => | ||
| 730 | Effect.gen(function* () { | ||
| 731 | const probeStarted = yield* Deferred.make<void>(); | ||
| 732 | const harness = yield* makeHarness({ | ||
| 733 | probe: () => Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), | ||
| 734 | }); | ||
| 735 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 736 | initiallyDesired: true, | ||
| 737 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 738 | |||
| 739 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 740 | yield* harness.wake("application-active"); | ||
| 741 | yield* Deferred.await(probeStarted); | ||
| 742 | yield* supervisor.disconnect; | ||
| 743 | yield* awaitState(supervisor.state, (state) => state.phase === "available"); | ||
| 744 | |||
| 745 | expect(yield* Ref.get(harness.releaseCount)).toBe(1); | ||
| 746 | }), | ||
| 747 | ); | ||
| 748 | |||
| 749 | it.effect("does not churn a healthy session when credentials change", () => | ||
| 750 | Effect.gen(function* () { | ||
| 751 | const harness = yield* makeHarness(); | ||
| 752 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 753 | initiallyDesired: true, | ||
| 754 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 755 | |||
| 756 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 757 | yield* harness.wake("credentials-changed"); | ||
| 758 | yield* Effect.yieldNow; | ||
| 759 | |||
| 760 | expect(yield* Ref.get(harness.sessionCount)).toBe(1); | ||
| 761 | expect(yield* Ref.get(harness.releaseCount)).toBe(0); | ||
| 762 | expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); | ||
| 763 | }), | ||
| 764 | ); | ||
| 765 | |||
| 766 | it.effect("releases and reconnects a relay session when credentials change", () => | ||
| 767 | Effect.gen(function* () { | ||
| 768 | const harness = yield* makeHarness(); | ||
| 769 | const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { | ||
| 770 | initiallyDesired: true, | ||
| 771 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 772 | |||
| 773 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 774 | yield* harness.wake("credentials-changed"); | ||
| 775 | yield* awaitState( | ||
| 776 | supervisor.state, | ||
| 777 | (state) => state.phase === "connected" && state.generation === 2, | ||
| 778 | ); | ||
| 779 | |||
| 780 | expect(yield* Ref.get(harness.sessionCount)).toBe(2); | ||
| 781 | expect(yield* Ref.get(harness.releaseCount)).toBe(1); | ||
| 782 | }), | ||
| 783 | ); | ||
| 784 | |||
| 785 | it.effect("interrupts relay setup when credentials change", () => | ||
| 786 | Effect.gen(function* () { | ||
| 787 | const firstAttemptStarted = yield* Deferred.make<void>(); | ||
| 788 | const harness = yield* makeHarness({ | ||
| 789 | prepare: (attempt) => | ||
| 790 | attempt === 1 | ||
| 791 | ? Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)) | ||
| 792 | : Effect.succeed(PREPARED_CONNECTION), | ||
| 793 | }); | ||
| 794 | const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { | ||
| 795 | initiallyDesired: true, | ||
| 796 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 797 | |||
| 798 | yield* Deferred.await(firstAttemptStarted); | ||
| 799 | yield* harness.wake("credentials-changed"); | ||
| 800 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 801 | |||
| 802 | expect(yield* Ref.get(harness.prepareCount)).toBe(2); | ||
| 803 | expect(yield* Ref.get(harness.sessionCount)).toBe(1); | ||
| 804 | }), | ||
| 805 | ); | ||
| 806 | |||
| 807 | it.effect("explicit disconnect releases the session and returns to available", () => | ||
| 808 | Effect.gen(function* () { | ||
| 809 | const harness = yield* makeHarness(); | ||
| 810 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 811 | initiallyDesired: true, | ||
| 812 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 813 | |||
| 814 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 815 | yield* supervisor.disconnect; | ||
| 816 | yield* awaitState(supervisor.state, (state) => state.phase === "available"); | ||
| 817 | |||
| 818 | expect(yield* Ref.get(harness.releaseCount)).toBe(1); | ||
| 819 | expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); | ||
| 820 | expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); | ||
| 821 | }), | ||
| 822 | ); | ||
| 823 | |||
| 824 | it.effect("does not lose an explicit disconnect among concurrent wakeup signals", () => | ||
| 825 | Effect.gen(function* () { | ||
| 826 | const harness = yield* makeHarness(); | ||
| 827 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | ||
| 828 | initiallyDesired: true, | ||
| 829 | }).pipe(Effect.provide(harness.dependencies)); | ||
| 830 | |||
| 831 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | ||
| 832 | yield* Effect.all( | ||
| 833 | [ | ||
| 834 | supervisor.disconnect, | ||
| 835 | harness.wake("credentials-changed"), | ||
| 836 | harness.wake("application-active"), | ||
| 837 | harness.wake("credentials-changed"), | ||
| 838 | ], | ||
| 839 | { concurrency: "unbounded" }, | ||
| 840 | ); | ||
| 841 | yield* awaitState(supervisor.state, (state) => state.phase === "available"); | ||
| 842 | |||
| 843 | expect(yield* Ref.get(harness.releaseCount)).toBe(1); | ||
| 844 | expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); | ||
| 845 | }), | ||
| 846 | ); | ||
| 847 | }); | ||
packages/client-runtime/src/connection/supervisor.ts created+724| ... | @@ -0,0 +1,724 @@ | ||
| 1 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Clock from "effect/Clock"; | ||
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import * as Fiber from "effect/Fiber"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Queue from "effect/Queue"; | ||
| 11 | import * as Ref from "effect/Ref"; | ||
| 12 | import * as Scope from "effect/Scope"; | ||
| 13 | import * as Stream from "effect/Stream"; | ||
| 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 15 | import * as Tracer from "effect/Tracer"; | ||
| 16 | |||
| 17 | import type { ConnectionCatalogEntry } from "./catalog.ts"; | ||
| 18 | import { Connectivity } from "./connectivity.ts"; | ||
| 19 | import { | ||
| 20 | ConnectionDriver, | ||
| 21 | type ConnectionDriverProgress, | ||
| 22 | type EnvironmentConnectionLease, | ||
| 23 | } from "./driver.ts"; | ||
| 24 | import { | ||
| 25 | type ConnectionAttemptError, | ||
| 26 | type ConnectionTarget, | ||
| 27 | ConnectionTransientError, | ||
| 28 | type NetworkStatus, | ||
| 29 | type PreparedConnection, | ||
| 30 | type SupervisorConnectionState, | ||
| 31 | } from "./model.ts"; | ||
| 32 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 33 | import { type ConnectionWakeup, ConnectionWakeups } from "./wakeups.ts"; | ||
| 34 | |||
| 35 | const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; | ||
| 36 | const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; | ||
| 37 | const CONNECTION_PROBE_TIMEOUT = "15 seconds"; | ||
| 38 | const BACKOFF_RESET_AFTER_MS = 30_000; | ||
| 39 | |||
| 40 | interface SupervisorIntent { | ||
| 41 | readonly desired: boolean; | ||
| 42 | readonly network: NetworkStatus; | ||
| 43 | } | ||
| 44 | |||
| 45 | type SupervisorSignal = | ||
| 46 | | { readonly _tag: "ConnectRequested" } | ||
| 47 | | { readonly _tag: "DisconnectRequested" } | ||
| 48 | | { readonly _tag: "RetryRequested" } | ||
| 49 | | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus } | ||
| 50 | | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeup }; | ||
| 51 | |||
| 52 | interface PendingRetryTrace { | ||
| 53 | readonly previousAttempt: Tracer.Span; | ||
| 54 | readonly failureCount: number; | ||
| 55 | readonly delayMs: number; | ||
| 56 | readonly reason: ConnectionAttemptError["reason"]; | ||
| 57 | } | ||
| 58 | |||
| 59 | interface TracedAttemptFailure { | ||
| 60 | readonly error: ConnectionAttemptError; | ||
| 61 | readonly attemptSpan: Option.Option<Tracer.Span>; | ||
| 62 | } | ||
| 63 | |||
| 64 | type AttemptOutcome = | ||
| 65 | | { | ||
| 66 | readonly _tag: "Interrupted"; | ||
| 67 | readonly established: boolean; | ||
| 68 | readonly stable: boolean; | ||
| 69 | } | ||
| 70 | | { | ||
| 71 | readonly _tag: "Failure"; | ||
| 72 | readonly established: boolean; | ||
| 73 | readonly stable: boolean; | ||
| 74 | readonly failure: TracedAttemptFailure; | ||
| 75 | }; | ||
| 76 | |||
| 77 | type EstablishmentEvent = | ||
| 78 | | { | ||
| 79 | readonly _tag: "Completed"; | ||
| 80 | readonly exit: Exit.Exit< | ||
| 81 | { | ||
| 82 | readonly attemptSpan: Option.Option<Tracer.Span>; | ||
| 83 | readonly lease: EnvironmentConnectionLease; | ||
| 84 | }, | ||
| 85 | TracedAttemptFailure | ||
| 86 | >; | ||
| 87 | } | ||
| 88 | | { readonly _tag: "Interrupted" } | ||
| 89 | | { readonly _tag: "TimedOut" }; | ||
| 90 | |||
| 91 | function exitUnlessInterrupted<A, E, R>( | ||
| 92 | effect: Effect.Effect<A, E, R>, | ||
| 93 | ): Effect.Effect<Exit.Exit<A, E>, never, R> { | ||
| 94 | return Effect.matchCauseEffect(effect, { | ||
| 95 | onFailure: (cause) => | ||
| 96 | Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(Exit.failCause(cause)), | ||
| 97 | onSuccess: (value) => Effect.succeed(Exit.succeed(value)), | ||
| 98 | }); | ||
| 99 | } | ||
| 100 | |||
| 101 | export interface EnvironmentSupervisorOptions { | ||
| 102 | readonly initiallyDesired?: boolean; | ||
| 103 | } | ||
| 104 | |||
| 105 | export interface EnvironmentSupervisorService { | ||
| 106 | readonly target: ConnectionTarget; | ||
| 107 | readonly state: SubscriptionRef.SubscriptionRef<SupervisorConnectionState>; | ||
| 108 | readonly session: SubscriptionRef.SubscriptionRef<Option.Option<RpcSession>>; | ||
| 109 | readonly prepared: SubscriptionRef.SubscriptionRef<Option.Option<PreparedConnection>>; | ||
| 110 | readonly connect: Effect.Effect<void>; | ||
| 111 | readonly disconnect: Effect.Effect<void>; | ||
| 112 | readonly retryNow: Effect.Effect<void>; | ||
| 113 | } | ||
| 114 | |||
| 115 | function retryDelayMs(failureCount: number): number { | ||
| 116 | return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; | ||
| 117 | } | ||
| 118 | |||
| 119 | function annotateTarget(target: ConnectionTarget) { | ||
| 120 | return Effect.annotateCurrentSpan({ | ||
| 121 | "environment.id": target.environmentId, | ||
| 122 | "environment.label": target.label, | ||
| 123 | "environment.target.kind": target._tag, | ||
| 124 | }); | ||
| 125 | } | ||
| 126 | |||
| 127 | function availableState(intent: SupervisorIntent, generation: number): SupervisorConnectionState { | ||
| 128 | return { | ||
| 129 | desired: false, | ||
| 130 | network: intent.network, | ||
| 131 | phase: "available", | ||
| 132 | stage: null, | ||
| 133 | attempt: 0, | ||
| 134 | generation, | ||
| 135 | lastFailure: null, | ||
| 136 | retryAt: null, | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | |||
| 140 | function offlineState( | ||
| 141 | intent: SupervisorIntent, | ||
| 142 | generation: number, | ||
| 143 | attempt: number, | ||
| 144 | lastFailure: ConnectionAttemptError | null, | ||
| 145 | ): SupervisorConnectionState { | ||
| 146 | return { | ||
| 147 | desired: true, | ||
| 148 | network: intent.network, | ||
| 149 | phase: "offline", | ||
| 150 | stage: null, | ||
| 151 | attempt, | ||
| 152 | generation, | ||
| 153 | lastFailure, | ||
| 154 | retryAt: null, | ||
| 155 | }; | ||
| 156 | } | ||
| 157 | |||
| 158 | function connectingState( | ||
| 159 | intent: SupervisorIntent, | ||
| 160 | generation: number, | ||
| 161 | attempt: number, | ||
| 162 | lastFailure: ConnectionAttemptError | null, | ||
| 163 | stage: SupervisorConnectionState["stage"] = "preparing", | ||
| 164 | ): SupervisorConnectionState { | ||
| 165 | return { | ||
| 166 | desired: true, | ||
| 167 | network: intent.network, | ||
| 168 | phase: "connecting", | ||
| 169 | stage, | ||
| 170 | attempt, | ||
| 171 | generation, | ||
| 172 | lastFailure, | ||
| 173 | retryAt: null, | ||
| 174 | }; | ||
| 175 | } | ||
| 176 | |||
| 177 | function failureFromExit<A>( | ||
| 178 | target: ConnectionTarget, | ||
| 179 | exit: Exit.Exit<A, TracedAttemptFailure>, | ||
| 180 | established: boolean, | ||
| 181 | stable: boolean, | ||
| 182 | ): AttemptOutcome { | ||
| 183 | if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) { | ||
| 184 | return { _tag: "Interrupted", established, stable }; | ||
| 185 | } | ||
| 186 | const typedFailure = exit.cause.reasons.find(Cause.isFailReason); | ||
| 187 | if (typedFailure) { | ||
| 188 | return { | ||
| 189 | _tag: "Failure", | ||
| 190 | established, | ||
| 191 | stable, | ||
| 192 | failure: typedFailure.error, | ||
| 193 | }; | ||
| 194 | } | ||
| 195 | return { | ||
| 196 | _tag: "Failure", | ||
| 197 | established, | ||
| 198 | stable, | ||
| 199 | failure: { | ||
| 200 | error: new ConnectionTransientError({ | ||
| 201 | reason: "transport", | ||
| 202 | message: `${target.label} connection failed unexpectedly.`, | ||
| 203 | }), | ||
| 204 | attemptSpan: Option.none(), | ||
| 205 | }, | ||
| 206 | }; | ||
| 207 | } | ||
| 208 | |||
| 209 | export class EnvironmentSupervisor extends Context.Service< | ||
| 210 | EnvironmentSupervisor, | ||
| 211 | EnvironmentSupervisorService | ||
| 212 | >()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") { | ||
| 213 | static layer( | ||
| 214 | entry: ConnectionCatalogEntry, | ||
| 215 | options?: EnvironmentSupervisorOptions, | ||
| 216 | ): Layer.Layer< | ||
| 217 | EnvironmentSupervisor, | ||
| 218 | never, | ||
| 219 | Connectivity | ConnectionDriver | ConnectionWakeups | ||
| 220 | > { | ||
| 221 | return Layer.effect(EnvironmentSupervisor, makeEnvironmentSupervisor(entry, options)); | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")(function* ( | ||
| 226 | entry: ConnectionCatalogEntry, | ||
| 227 | options?: EnvironmentSupervisorOptions, | ||
| 228 | ): Effect.fn.Return< | ||
| 229 | EnvironmentSupervisorService, | ||
| 230 | never, | ||
| 231 | Connectivity | ConnectionDriver | Scope.Scope | ConnectionWakeups | ||
| 232 | > { | ||
| 233 | const target = entry.target; | ||
| 234 | yield* annotateTarget(target); | ||
| 235 | |||
| 236 | const connectivity = yield* Connectivity; | ||
| 237 | const driver = yield* ConnectionDriver; | ||
| 238 | const wakeups = yield* ConnectionWakeups; | ||
| 239 | const initialIntent: SupervisorIntent = { | ||
| 240 | desired: options?.initiallyDesired ?? false, | ||
| 241 | network: yield* connectivity.status, | ||
| 242 | }; | ||
| 243 | const intent = yield* Ref.make(initialIntent); | ||
| 244 | const signals = yield* Queue.unbounded<SupervisorSignal>(); | ||
| 245 | const state = yield* SubscriptionRef.make<SupervisorConnectionState>( | ||
| 246 | !initialIntent.desired | ||
| 247 | ? availableState(initialIntent, 0) | ||
| 248 | : initialIntent.network === "offline" | ||
| 249 | ? offlineState(initialIntent, 0, 0, null) | ||
| 250 | : connectingState(initialIntent, 0, 1, null), | ||
| 251 | ); | ||
| 252 | const session = yield* SubscriptionRef.make<Option.Option<RpcSession>>(Option.none()); | ||
| 253 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none()); | ||
| 254 | |||
| 255 | const clearLease = Effect.all( | ||
| 256 | [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())], | ||
| 257 | { discard: true }, | ||
| 258 | ); | ||
| 259 | |||
| 260 | const setState = Effect.fn("EnvironmentSupervisor.setState")(function* ( | ||
| 261 | next: SupervisorConnectionState, | ||
| 262 | ) { | ||
| 263 | yield* SubscriptionRef.set(state, next); | ||
| 264 | }); | ||
| 265 | |||
| 266 | const signal = Effect.fn("EnvironmentSupervisor.signal")(function* (next: SupervisorSignal) { | ||
| 267 | yield* Queue.offer(signals, next); | ||
| 268 | }); | ||
| 269 | |||
| 270 | const logManagedRelayAccountChange = Effect.logInfo( | ||
| 271 | "Managed relay account changed; restarting the environment connection.", | ||
| 272 | ).pipe( | ||
| 273 | Effect.annotateLogs({ | ||
| 274 | "environment.id": target.environmentId, | ||
| 275 | "environment.label": target.label, | ||
| 276 | }), | ||
| 277 | ); | ||
| 278 | |||
| 279 | const reportProgress = Effect.fn("EnvironmentSupervisor.reportProgress")(function* ( | ||
| 280 | attempt: number, | ||
| 281 | generation: number, | ||
| 282 | lastFailure: ConnectionAttemptError | null, | ||
| 283 | progress: ConnectionDriverProgress, | ||
| 284 | ) { | ||
| 285 | if ("prepared" in progress) { | ||
| 286 | yield* SubscriptionRef.set(prepared, Option.some(progress.prepared)); | ||
| 287 | } | ||
| 288 | yield* setState( | ||
| 289 | connectingState(yield* Ref.get(intent), generation, attempt, lastFailure, progress.stage), | ||
| 290 | ); | ||
| 291 | }); | ||
| 292 | |||
| 293 | const establishConnection = Effect.fnUntraced(function* ( | ||
| 294 | attempt: number, | ||
| 295 | generation: number, | ||
| 296 | lastFailure: ConnectionAttemptError | null, | ||
| 297 | ) { | ||
| 298 | return yield* driver.connect(entry, (progress) => | ||
| 299 | reportProgress(attempt, generation, lastFailure, progress), | ||
| 300 | ); | ||
| 301 | }); | ||
| 302 | |||
| 303 | const traceRelayEstablishment = ( | ||
| 304 | effect: Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>, | ||
| 305 | attempt: number, | ||
| 306 | generation: number, | ||
| 307 | pendingRetry: Option.Option<PendingRetryTrace>, | ||
| 308 | ) => { | ||
| 309 | const traced = Effect.gen(function* () { | ||
| 310 | const attemptSpan = yield* Effect.currentSpan.pipe(Effect.orDie); | ||
| 311 | yield* annotateTarget(target); | ||
| 312 | yield* Effect.annotateCurrentSpan({ | ||
| 313 | "connection.attempt": attempt, | ||
| 314 | "connection.generation": generation, | ||
| 315 | "connection.retry.failure_count": Option.match(pendingRetry, { | ||
| 316 | onNone: () => 0, | ||
| 317 | onSome: (retry) => retry.failureCount, | ||
| 318 | }), | ||
| 319 | }); | ||
| 320 | const lease = yield* effect.pipe( | ||
| 321 | Effect.mapError( | ||
| 322 | (error): TracedAttemptFailure => ({ | ||
| 323 | error, | ||
| 324 | attemptSpan: Option.some(attemptSpan), | ||
| 325 | }), | ||
| 326 | ), | ||
| 327 | ); | ||
| 328 | return { attemptSpan: Option.some(attemptSpan), lease }; | ||
| 329 | }).pipe(Effect.withSpan("relay.connection.attempt", { root: true })); | ||
| 330 | |||
| 331 | return Option.match(pendingRetry, { | ||
| 332 | onNone: () => traced, | ||
| 333 | onSome: (retry) => | ||
| 334 | traced.pipe( | ||
| 335 | Effect.linkSpans(retry.previousAttempt, { | ||
| 336 | "connection.retry.delay_ms": retry.delayMs, | ||
| 337 | "connection.retry.reason": retry.reason, | ||
| 338 | }), | ||
| 339 | ), | ||
| 340 | }).pipe(withRelayClientTracing); | ||
| 341 | }; | ||
| 342 | |||
| 343 | const establishTracedConnection = Effect.fnUntraced(function* ( | ||
| 344 | attempt: number, | ||
| 345 | generation: number, | ||
| 346 | lastFailure: ConnectionAttemptError | null, | ||
| 347 | pendingRetry: Option.Option<PendingRetryTrace>, | ||
| 348 | ) { | ||
| 349 | if (target._tag === "RelayConnectionTarget") { | ||
| 350 | return yield* traceRelayEstablishment( | ||
| 351 | establishConnection(attempt, generation, lastFailure), | ||
| 352 | attempt, | ||
| 353 | generation, | ||
| 354 | pendingRetry, | ||
| 355 | ); | ||
| 356 | } | ||
| 357 | return yield* establishConnection(attempt, generation, lastFailure).pipe( | ||
| 358 | Effect.map((lease) => ({ | ||
| 359 | attemptSpan: Option.none<Tracer.Span>(), | ||
| 360 | lease, | ||
| 361 | })), | ||
| 362 | Effect.mapError( | ||
| 363 | (error): TracedAttemptFailure => ({ | ||
| 364 | error, | ||
| 365 | attemptSpan: Option.none(), | ||
| 366 | }), | ||
| 367 | ), | ||
| 368 | ); | ||
| 369 | }); | ||
| 370 | |||
| 371 | const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () { | ||
| 372 | for (;;) { | ||
| 373 | const next = yield* Queue.take(signals); | ||
| 374 | switch (next._tag) { | ||
| 375 | case "DisconnectRequested": | ||
| 376 | case "RetryRequested": | ||
| 377 | return; | ||
| 378 | case "NetworkChanged": | ||
| 379 | if (next.network === "offline") { | ||
| 380 | return; | ||
| 381 | } | ||
| 382 | break; | ||
| 383 | case "ConnectRequested": | ||
| 384 | break; | ||
| 385 | case "Wakeup": | ||
| 386 | if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { | ||
| 387 | yield* logManagedRelayAccountChange; | ||
| 388 | return; | ||
| 389 | } | ||
| 390 | break; | ||
| 391 | } | ||
| 392 | } | ||
| 393 | }); | ||
| 394 | |||
| 395 | const monitorConnectedLease = Effect.fnUntraced(function* (lease: EnvironmentConnectionLease) { | ||
| 396 | for (;;) { | ||
| 397 | const next = yield* Queue.take(signals); | ||
| 398 | switch (next._tag) { | ||
| 399 | case "DisconnectRequested": | ||
| 400 | case "RetryRequested": | ||
| 401 | return; | ||
| 402 | case "NetworkChanged": | ||
| 403 | if (next.network === "offline") { | ||
| 404 | return; | ||
| 405 | } | ||
| 406 | break; | ||
| 407 | case "Wakeup": | ||
| 408 | if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { | ||
| 409 | yield* logManagedRelayAccountChange; | ||
| 410 | return; | ||
| 411 | } | ||
| 412 | if (next.reason === "application-active") { | ||
| 413 | const probe = yield* lease.session.probe.pipe( | ||
| 414 | Effect.timeoutOrElse({ | ||
| 415 | duration: CONNECTION_PROBE_TIMEOUT, | ||
| 416 | orElse: () => | ||
| 417 | Effect.fail( | ||
| 418 | new ConnectionTransientError({ | ||
| 419 | reason: "timeout", | ||
| 420 | message: `${target.label} did not respond to a connection health check.`, | ||
| 421 | }), | ||
| 422 | ), | ||
| 423 | }), | ||
| 424 | Effect.forkChild, | ||
| 425 | ); | ||
| 426 | for (;;) { | ||
| 427 | const probeEvent = yield* Effect.raceFirst( | ||
| 428 | Fiber.await(probe).pipe( | ||
| 429 | Effect.map((exit) => ({ _tag: "ProbeCompleted" as const, exit })), | ||
| 430 | ), | ||
| 431 | Queue.take(signals).pipe( | ||
| 432 | Effect.map((signal) => ({ _tag: "Signal" as const, signal })), | ||
| 433 | ), | ||
| 434 | ); | ||
| 435 | if (probeEvent._tag === "ProbeCompleted") { | ||
| 436 | yield* probeEvent.exit; | ||
| 437 | break; | ||
| 438 | } | ||
| 439 | switch (probeEvent.signal._tag) { | ||
| 440 | case "DisconnectRequested": | ||
| 441 | case "RetryRequested": | ||
| 442 | yield* Fiber.interrupt(probe); | ||
| 443 | return; | ||
| 444 | case "NetworkChanged": | ||
| 445 | if (probeEvent.signal.network === "offline") { | ||
| 446 | yield* Fiber.interrupt(probe); | ||
| 447 | return; | ||
| 448 | } | ||
| 449 | break; | ||
| 450 | case "ConnectRequested": | ||
| 451 | case "Wakeup": | ||
| 452 | break; | ||
| 453 | } | ||
| 454 | } | ||
| 455 | } | ||
| 456 | break; | ||
| 457 | case "ConnectRequested": | ||
| 458 | break; | ||
| 459 | } | ||
| 460 | } | ||
| 461 | }); | ||
| 462 | |||
| 463 | const runAttempt = Effect.fnUntraced(function* ( | ||
| 464 | attempt: number, | ||
| 465 | generation: number, | ||
| 466 | lastFailure: ConnectionAttemptError | null, | ||
| 467 | pendingRetry: Option.Option<PendingRetryTrace>, | ||
| 468 | ) { | ||
| 469 | yield* SubscriptionRef.set(prepared, Option.none()); | ||
| 470 | const establishment = yield* Effect.raceAllFirst([ | ||
| 471 | exitUnlessInterrupted( | ||
| 472 | establishTracedConnection(attempt, generation, lastFailure, pendingRetry), | ||
| 473 | ).pipe( | ||
| 474 | Effect.map( | ||
| 475 | (exit): EstablishmentEvent => ({ | ||
| 476 | _tag: "Completed", | ||
| 477 | exit, | ||
| 478 | }), | ||
| 479 | ), | ||
| 480 | ), | ||
| 481 | waitForEstablishmentInterrupt().pipe(Effect.as<EstablishmentEvent>({ _tag: "Interrupted" })), | ||
| 482 | Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe( | ||
| 483 | Effect.as<EstablishmentEvent>({ _tag: "TimedOut" }), | ||
| 484 | ), | ||
| 485 | ]); | ||
| 486 | |||
| 487 | if (establishment._tag === "Interrupted") { | ||
| 488 | return { | ||
| 489 | _tag: "Interrupted", | ||
| 490 | established: false, | ||
| 491 | stable: false, | ||
| 492 | } satisfies AttemptOutcome; | ||
| 493 | } | ||
| 494 | if (establishment._tag === "TimedOut") { | ||
| 495 | return { | ||
| 496 | _tag: "Failure", | ||
| 497 | established: false, | ||
| 498 | stable: false, | ||
| 499 | failure: { | ||
| 500 | error: new ConnectionTransientError({ | ||
| 501 | reason: "timeout", | ||
| 502 | message: `${target.label} did not respond during connection setup.`, | ||
| 503 | }), | ||
| 504 | attemptSpan: Option.none(), | ||
| 505 | }, | ||
| 506 | } satisfies AttemptOutcome; | ||
| 507 | } | ||
| 508 | if (Exit.isFailure(establishment.exit)) { | ||
| 509 | const isUnexpectedDefect = | ||
| 510 | !Cause.hasInterruptsOnly(establishment.exit.cause) && | ||
| 511 | !establishment.exit.cause.reasons.some(Cause.isFailReason); | ||
| 512 | const outcome = failureFromExit(target, establishment.exit, false, false); | ||
| 513 | if (isUnexpectedDefect) { | ||
| 514 | yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe( | ||
| 515 | Effect.annotateLogs({ | ||
| 516 | "environment.id": target.environmentId, | ||
| 517 | "environment.label": target.label, | ||
| 518 | cause: Cause.pretty(establishment.exit.cause), | ||
| 519 | }), | ||
| 520 | ); | ||
| 521 | } | ||
| 522 | return outcome; | ||
| 523 | } | ||
| 524 | |||
| 525 | const active = establishment.exit.value; | ||
| 526 | const currentIntent = yield* Ref.get(intent); | ||
| 527 | if (!currentIntent.desired || currentIntent.network === "offline") { | ||
| 528 | return { | ||
| 529 | _tag: "Interrupted", | ||
| 530 | established: false, | ||
| 531 | stable: false, | ||
| 532 | } satisfies AttemptOutcome; | ||
| 533 | } | ||
| 534 | |||
| 535 | const connectedAt = yield* Clock.currentTimeMillis; | ||
| 536 | yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); | ||
| 537 | yield* SubscriptionRef.set(session, Option.some(active.lease.session)); | ||
| 538 | yield* setState({ | ||
| 539 | desired: true, | ||
| 540 | network: currentIntent.network, | ||
| 541 | phase: "connected", | ||
| 542 | stage: null, | ||
| 543 | attempt, | ||
| 544 | generation, | ||
| 545 | lastFailure: null, | ||
| 546 | retryAt: null, | ||
| 547 | }); | ||
| 548 | |||
| 549 | const connectedExit = yield* Effect.raceFirst( | ||
| 550 | active.lease.session.closed.pipe( | ||
| 551 | Effect.mapError( | ||
| 552 | (error): TracedAttemptFailure => ({ | ||
| 553 | error, | ||
| 554 | attemptSpan: active.attemptSpan, | ||
| 555 | }), | ||
| 556 | ), | ||
| 557 | ), | ||
| 558 | monitorConnectedLease(active.lease).pipe( | ||
| 559 | Effect.mapError( | ||
| 560 | (error): TracedAttemptFailure => ({ | ||
| 561 | error, | ||
| 562 | attemptSpan: active.attemptSpan, | ||
| 563 | }), | ||
| 564 | ), | ||
| 565 | ), | ||
| 566 | ).pipe(exitUnlessInterrupted); | ||
| 567 | const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt; | ||
| 568 | return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS); | ||
| 569 | }, Effect.ensuring(clearLease)); | ||
| 570 | |||
| 571 | const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) { | ||
| 572 | return yield* Effect.raceFirst( | ||
| 573 | Effect.sleep(delayMs), | ||
| 574 | Effect.gen(function* () { | ||
| 575 | for (;;) { | ||
| 576 | const next = yield* Queue.take(signals); | ||
| 577 | switch (next._tag) { | ||
| 578 | case "ConnectRequested": | ||
| 579 | case "DisconnectRequested": | ||
| 580 | case "RetryRequested": | ||
| 581 | case "NetworkChanged": | ||
| 582 | case "Wakeup": | ||
| 583 | return; | ||
| 584 | } | ||
| 585 | } | ||
| 586 | }), | ||
| 587 | ); | ||
| 588 | }); | ||
| 589 | |||
| 590 | const waitForSignal = Queue.take(signals); | ||
| 591 | |||
| 592 | const run = Effect.fnUntraced(function* () { | ||
| 593 | let failureCount = 0; | ||
| 594 | let generation = 0; | ||
| 595 | let latestFailure: ConnectionAttemptError | null = null; | ||
| 596 | let pendingRetry = Option.none<PendingRetryTrace>(); | ||
| 597 | |||
| 598 | for (;;) { | ||
| 599 | const currentIntent = yield* Ref.get(intent); | ||
| 600 | if (!currentIntent.desired) { | ||
| 601 | failureCount = 0; | ||
| 602 | latestFailure = null; | ||
| 603 | pendingRetry = Option.none(); | ||
| 604 | yield* clearLease; | ||
| 605 | yield* setState(availableState(currentIntent, generation)); | ||
| 606 | yield* waitForSignal; | ||
| 607 | continue; | ||
| 608 | } | ||
| 609 | if (currentIntent.network === "offline") { | ||
| 610 | yield* clearLease; | ||
| 611 | yield* setState(offlineState(currentIntent, generation, failureCount + 1, latestFailure)); | ||
| 612 | yield* waitForSignal; | ||
| 613 | continue; | ||
| 614 | } | ||
| 615 | |||
| 616 | const attempt = failureCount + 1; | ||
| 617 | const nextGeneration = generation + 1; | ||
| 618 | const outcome: AttemptOutcome = yield* Effect.scoped( | ||
| 619 | runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), | ||
| 620 | ); | ||
| 621 | if (outcome.established) { | ||
| 622 | generation = nextGeneration; | ||
| 623 | if (outcome.stable) { | ||
| 624 | failureCount = 0; | ||
| 625 | latestFailure = null; | ||
| 626 | pendingRetry = Option.none(); | ||
| 627 | } | ||
| 628 | } | ||
| 629 | if (outcome._tag === "Interrupted") { | ||
| 630 | continue; | ||
| 631 | } | ||
| 632 | |||
| 633 | const attemptSpan: Option.Option<Tracer.Span> = outcome.failure.attemptSpan; | ||
| 634 | const error: ConnectionAttemptError = outcome.failure.error; | ||
| 635 | latestFailure = error; | ||
| 636 | if (error._tag === "ConnectionBlockedError") { | ||
| 637 | const blockedIntent = yield* Ref.get(intent); | ||
| 638 | yield* setState({ | ||
| 639 | desired: blockedIntent.desired, | ||
| 640 | network: blockedIntent.network, | ||
| 641 | phase: "blocked", | ||
| 642 | stage: null, | ||
| 643 | attempt, | ||
| 644 | generation, | ||
| 645 | lastFailure: error, | ||
| 646 | retryAt: null, | ||
| 647 | }); | ||
| 648 | yield* waitForSignal; | ||
| 649 | continue; | ||
| 650 | } | ||
| 651 | |||
| 652 | failureCount += 1; | ||
| 653 | const delayMs = retryDelayMs(failureCount - 1); | ||
| 654 | pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ | ||
| 655 | previousAttempt, | ||
| 656 | failureCount, | ||
| 657 | delayMs, | ||
| 658 | reason: error.reason, | ||
| 659 | })); | ||
| 660 | const failedIntent = yield* Ref.get(intent); | ||
| 661 | yield* setState({ | ||
| 662 | desired: failedIntent.desired, | ||
| 663 | network: failedIntent.network, | ||
| 664 | phase: "backoff", | ||
| 665 | stage: null, | ||
| 666 | attempt, | ||
| 667 | generation, | ||
| 668 | lastFailure: error, | ||
| 669 | retryAt: (yield* Clock.currentTimeMillis) + delayMs, | ||
| 670 | }); | ||
| 671 | yield* waitForRetrySignal(delayMs); | ||
| 672 | } | ||
| 673 | }); | ||
| 674 | |||
| 675 | yield* connectivity.changes.pipe( | ||
| 676 | Stream.runForEach((network) => | ||
| 677 | Ref.modify(intent, (current) => | ||
| 678 | current.network === network ? [false, current] : ([true, { ...current, network }] as const), | ||
| 679 | ).pipe( | ||
| 680 | Effect.flatMap((changed) => | ||
| 681 | changed ? signal({ _tag: "NetworkChanged", network }) : Effect.void, | ||
| 682 | ), | ||
| 683 | ), | ||
| 684 | ), | ||
| 685 | Effect.forkScoped, | ||
| 686 | ); | ||
| 687 | yield* wakeups.changes.pipe( | ||
| 688 | Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })), | ||
| 689 | Effect.forkScoped, | ||
| 690 | ); | ||
| 691 | yield* run().pipe(Effect.forkScoped); | ||
| 692 | |||
| 693 | const connect = Ref.update(intent, (current) => ({ | ||
| 694 | ...current, | ||
| 695 | desired: true, | ||
| 696 | })).pipe( | ||
| 697 | Effect.andThen(signal({ _tag: "ConnectRequested" })), | ||
| 698 | Effect.withSpan("EnvironmentSupervisor.connect"), | ||
| 699 | ); | ||
| 700 | |||
| 701 | const disconnect = Ref.update(intent, (current) => ({ | ||
| 702 | ...current, | ||
| 703 | desired: false, | ||
| 704 | })).pipe( | ||
| 705 | Effect.andThen(signal({ _tag: "DisconnectRequested" })), | ||
| 706 | Effect.withSpan("EnvironmentSupervisor.disconnect"), | ||
| 707 | ); | ||
| 708 | |||
| 709 | const retryNow = signal({ _tag: "RetryRequested" }).pipe( | ||
| 710 | Effect.withSpan("EnvironmentSupervisor.retryNow"), | ||
| 711 | ); | ||
| 712 | |||
| 713 | yield* Effect.addFinalizer(() => Queue.shutdown(signals).pipe(Effect.andThen(clearLease))); | ||
| 714 | |||
| 715 | return EnvironmentSupervisor.of({ | ||
| 716 | target, | ||
| 717 | state, | ||
| 718 | session, | ||
| 719 | prepared, | ||
| 720 | connect, | ||
| 721 | disconnect, | ||
| 722 | retryNow, | ||
| 723 | }); | ||
| 724 | }); | ||
packages/client-runtime/src/connection/wakeups.ts created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import type * as Stream from "effect/Stream"; | ||
| 3 | |||
| 4 | export type ConnectionWakeup = "application-active" | "credentials-changed"; | ||
| 5 | |||
| 6 | export class ConnectionWakeups extends Context.Service< | ||
| 7 | ConnectionWakeups, | ||
| 8 | { | ||
| 9 | readonly changes: Stream.Stream<ConnectionWakeup>; | ||
| 10 | } | ||
| 11 | >()("@t3tools/client-runtime/connection/wakeups/ConnectionWakeups") {} | ||
packages/client-runtime/src/environment/descriptor.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | |||
| 3 | import { environmentEndpointUrl } from "./endpoint.ts"; | ||
| 4 | import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; | ||
| 5 | |||
| 6 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; | ||
| 7 | |||
| 8 | export const fetchRemoteEnvironmentDescriptor = Effect.fn( | ||
| 9 | "clientRuntime.environment.fetchRemoteEnvironmentDescriptor", | ||
| 10 | )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { | ||
| 11 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 12 | return yield* executeEnvironmentHttpRequest( | ||
| 13 | environmentEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), | ||
| 14 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 15 | client.metadata.descriptor(), | ||
| 16 | ); | ||
| 17 | }); | ||
packages/client-runtime/src/environment/endpoint.test.ts created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | classifyHostedHttpsCompatibility, | ||
| 5 | createAdvertisedEndpoint, | ||
| 6 | deriveWsBaseUrl, | ||
| 7 | normalizeHttpBaseUrl, | ||
| 8 | } from "./endpoint.ts"; | ||
| 9 | |||
| 10 | const coreProvider = { | ||
| 11 | id: "desktop-core", | ||
| 12 | label: "Desktop", | ||
| 13 | kind: "core", | ||
| 14 | isAddon: false, | ||
| 15 | } as const; | ||
| 16 | |||
| 17 | describe("advertised endpoint helpers", () => { | ||
| 18 | it("normalizes HTTP and WebSocket base URLs", () => { | ||
| 19 | expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe("https://example.com/"); | ||
| 20 | expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/"); | ||
| 21 | expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/"); | ||
| 22 | expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/"); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => { | ||
| 26 | expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe( | ||
| 27 | "mixed-content-blocked", | ||
| 28 | ); | ||
| 29 | expect(classifyHostedHttpsCompatibility("https://desktop.example.com", "compatible")).toBe( | ||
| 30 | "compatible", | ||
| 31 | ); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("creates provider-neutral endpoint records", () => { | ||
| 35 | expect( | ||
| 36 | createAdvertisedEndpoint({ | ||
| 37 | id: "lan:http://192.168.1.44:3773", | ||
| 38 | label: "LAN", | ||
| 39 | provider: coreProvider, | ||
| 40 | httpBaseUrl: "http://192.168.1.44:3773", | ||
| 41 | reachability: "lan", | ||
| 42 | source: "desktop-core", | ||
| 43 | isDefault: true, | ||
| 44 | }), | ||
| 45 | ).toEqual({ | ||
| 46 | id: "lan:http://192.168.1.44:3773", | ||
| 47 | label: "LAN", | ||
| 48 | provider: coreProvider, | ||
| 49 | httpBaseUrl: "http://192.168.1.44:3773/", | ||
| 50 | wsBaseUrl: "ws://192.168.1.44:3773/", | ||
| 51 | reachability: "lan", | ||
| 52 | compatibility: { | ||
| 53 | hostedHttpsApp: "mixed-content-blocked", | ||
| 54 | desktopApp: "compatible", | ||
| 55 | }, | ||
| 56 | source: "desktop-core", | ||
| 57 | status: "available", | ||
| 58 | isDefault: true, | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | }); | ||
packages/client-runtime/src/environment/endpoint.ts created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | export * from "@t3tools/shared/advertisedEndpoint"; | ||
| 2 | |||
| 3 | export const environmentEndpointUrl = (httpBaseUrl: string, pathname: string): string => { | ||
| 4 | const url = new URL(httpBaseUrl); | ||
| 5 | url.pathname = pathname; | ||
| 6 | url.search = ""; | ||
| 7 | url.hash = ""; | ||
| 8 | return url.toString(); | ||
| 9 | }; | ||
packages/client-runtime/src/environment/index.ts created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | export * from "./descriptor.ts"; | ||
| 2 | export * from "./endpoint.ts"; | ||
| 3 | export * from "./knownEnvironment.ts"; | ||
| 4 | export * from "./scoped.ts"; | ||
packages/client-runtime/src/environment/knownEnvironment.test.ts created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { createKnownEnvironment } from "./knownEnvironment.ts"; | ||
| 5 | import { | ||
| 6 | parseScopedProjectKey, | ||
| 7 | parseScopedThreadKey, | ||
| 8 | scopedProjectKey, | ||
| 9 | scopedRefKey, | ||
| 10 | scopedThreadKey, | ||
| 11 | scopeProjectRef, | ||
| 12 | scopeThreadRef, | ||
| 13 | } from "./scoped.ts"; | ||
| 14 | |||
| 15 | describe("known environment bootstrap helpers", () => { | ||
| 16 | it("creates known environments from explicit server base urls", () => { | ||
| 17 | expect( | ||
| 18 | createKnownEnvironment({ | ||
| 19 | label: "Remote environment", | ||
| 20 | target: { | ||
| 21 | httpBaseUrl: "https://remote.example.com", | ||
| 22 | wsBaseUrl: "wss://remote.example.com", | ||
| 23 | }, | ||
| 24 | }), | ||
| 25 | ).toEqual({ | ||
| 26 | id: "ws:Remote environment", | ||
| 27 | label: "Remote environment", | ||
| 28 | source: "manual", | ||
| 29 | target: { | ||
| 30 | httpBaseUrl: "https://remote.example.com", | ||
| 31 | wsBaseUrl: "wss://remote.example.com", | ||
| 32 | }, | ||
| 33 | }); | ||
| 34 | }); | ||
| 35 | }); | ||
| 36 | |||
| 37 | describe("scoped refs", () => { | ||
| 38 | const environmentId = EnvironmentId.make("environment-test"); | ||
| 39 | const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); | ||
| 40 | const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1")); | ||
| 41 | |||
| 42 | it("builds stable scoped project and thread keys", () => { | ||
| 43 | expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); | ||
| 44 | expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); | ||
| 45 | expect(scopedProjectKey(projectRef)).toBe("environment-test:project-1"); | ||
| 46 | expect(scopedThreadKey(threadRef)).toBe("environment-test:thread-1"); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("returns typed scoped refs", () => { | ||
| 50 | expect(projectRef).toEqual({ | ||
| 51 | environmentId, | ||
| 52 | projectId: ProjectId.make("project-1"), | ||
| 53 | }); | ||
| 54 | expect(threadRef).toEqual({ | ||
| 55 | environmentId, | ||
| 56 | threadId: ThreadId.make("thread-1"), | ||
| 57 | }); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("parses scoped project and thread keys back into refs", () => { | ||
| 61 | expect(parseScopedProjectKey("environment-test:project-1")).toEqual(projectRef); | ||
| 62 | expect(parseScopedThreadKey("environment-test:thread-1")).toEqual(threadRef); | ||
| 63 | expect(parseScopedProjectKey("bad-key")).toBeNull(); | ||
| 64 | expect(parseScopedThreadKey("bad-key")).toBeNull(); | ||
| 65 | }); | ||
| 66 | }); | ||
packages/client-runtime/src/environment/knownEnvironment.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface KnownEnvironmentConnectionTarget { | ||
| 4 | readonly httpBaseUrl: string; | ||
| 5 | readonly wsBaseUrl: string; | ||
| 6 | } | ||
| 7 | |||
| 8 | export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; | ||
| 9 | |||
| 10 | export interface KnownEnvironment { | ||
| 11 | readonly id: string; | ||
| 12 | readonly label: string; | ||
| 13 | readonly source: KnownEnvironmentSource; | ||
| 14 | readonly environmentId?: EnvironmentId; | ||
| 15 | readonly target: KnownEnvironmentConnectionTarget; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function createKnownEnvironment(input: { | ||
| 19 | readonly id?: string; | ||
| 20 | readonly label: string; | ||
| 21 | readonly source?: KnownEnvironmentSource; | ||
| 22 | readonly target: KnownEnvironmentConnectionTarget; | ||
| 23 | }): KnownEnvironment { | ||
| 24 | return { | ||
| 25 | id: input.id ?? `ws:${input.label}`, | ||
| 26 | label: input.label, | ||
| 27 | source: input.source ?? "manual", | ||
| 28 | target: input.target, | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | export function attachEnvironmentDescriptor( | ||
| 33 | environment: KnownEnvironment, | ||
| 34 | descriptor: ExecutionEnvironmentDescriptor, | ||
| 35 | ): KnownEnvironment { | ||
| 36 | return { | ||
| 37 | ...environment, | ||
| 38 | environmentId: descriptor.environmentId, | ||
| 39 | label: descriptor.label, | ||
| 40 | }; | ||
| 41 | } | ||
packages/client-runtime/src/environment/scoped.ts created+69| ... | @@ -0,0 +1,69 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | ProjectId, | ||
| 4 | ThreadId, | ||
| 5 | type EnvironmentId as EnvironmentIdType, | ||
| 6 | type ProjectId as ProjectIdType, | ||
| 7 | type ScopedProjectRef, | ||
| 8 | type ScopedThreadRef, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | export function scopeProjectRef( | ||
| 12 | environmentId: EnvironmentIdType, | ||
| 13 | projectId: ProjectIdType, | ||
| 14 | ): ScopedProjectRef { | ||
| 15 | return { environmentId, projectId }; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function scopeThreadRef( | ||
| 19 | environmentId: EnvironmentIdType, | ||
| 20 | threadId: ThreadId, | ||
| 21 | ): ScopedThreadRef { | ||
| 22 | return { environmentId, threadId }; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { | ||
| 26 | const localId = "projectId" in ref ? ref.projectId : ref.threadId; | ||
| 27 | return `${ref.environmentId}:${localId}`; | ||
| 28 | } | ||
| 29 | |||
| 30 | export function scopedProjectKey(ref: ScopedProjectRef): string { | ||
| 31 | return scopedRefKey(ref); | ||
| 32 | } | ||
| 33 | |||
| 34 | export function scopedThreadKey(ref: ScopedThreadRef): string { | ||
| 35 | return scopedRefKey(ref); | ||
| 36 | } | ||
| 37 | |||
| 38 | function parseScopedKey(key: string): { environmentId: EnvironmentIdType; localId: string } | null { | ||
| 39 | const separatorIndex = key.indexOf(":"); | ||
| 40 | if (separatorIndex <= 0 || separatorIndex >= key.length - 1) { | ||
| 41 | return null; | ||
| 42 | } | ||
| 43 | return { | ||
| 44 | environmentId: EnvironmentId.make(key.slice(0, separatorIndex)), | ||
| 45 | localId: key.slice(separatorIndex + 1), | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function parseScopedProjectKey(key: string): ScopedProjectRef | null { | ||
| 50 | const parsed = parseScopedKey(key); | ||
| 51 | if (!parsed) { | ||
| 52 | return null; | ||
| 53 | } | ||
| 54 | return { | ||
| 55 | environmentId: parsed.environmentId, | ||
| 56 | projectId: ProjectId.make(parsed.localId), | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 60 | export function parseScopedThreadKey(key: string): ScopedThreadRef | null { | ||
| 61 | const parsed = parseScopedKey(key); | ||
| 62 | if (!parsed) { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | return { | ||
| 66 | environmentId: parsed.environmentId, | ||
| 67 | threadId: ThreadId.make(parsed.localId), | ||
| 68 | }; | ||
| 69 | } | ||
packages/client-runtime/src/environmentConnection.ts deleted-244| ... | @@ -1,244 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationShellSnapshot, | ||
| 4 | OrchestrationShellStreamEvent, | ||
| 5 | ServerConfig, | ||
| 6 | ServerLifecycleWelcomePayload, | ||
| 7 | TerminalEvent, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | import type { KnownEnvironment } from "./knownEnvironment.ts"; | ||
| 11 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 12 | |||
| 13 | export interface EnvironmentConnection { | ||
| 14 | readonly kind: "primary" | "saved"; | ||
| 15 | readonly environmentId: EnvironmentId; | ||
| 16 | readonly knownEnvironment: KnownEnvironment; | ||
| 17 | readonly client: WsRpcClient; | ||
| 18 | readonly ensureBootstrapped: () => Promise<void>; | ||
| 19 | readonly reconnect: () => Promise<void>; | ||
| 20 | readonly dispose: () => Promise<void>; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface OrchestrationHandlers { | ||
| 24 | readonly applyShellEvent: ( | ||
| 25 | event: OrchestrationShellStreamEvent, | ||
| 26 | environmentId: EnvironmentId, | ||
| 27 | ) => void; | ||
| 28 | readonly syncShellSnapshot: ( | ||
| 29 | snapshot: OrchestrationShellSnapshot, | ||
| 30 | environmentId: EnvironmentId, | ||
| 31 | ) => void; | ||
| 32 | readonly applyTerminalEvent?: (event: TerminalEvent, environmentId: EnvironmentId) => void; | ||
| 33 | } | ||
| 34 | |||
| 35 | export interface EnvironmentConnectionInput extends OrchestrationHandlers { | ||
| 36 | readonly kind: "primary" | "saved"; | ||
| 37 | readonly knownEnvironment: KnownEnvironment; | ||
| 38 | readonly client: WsRpcClient; | ||
| 39 | readonly refreshMetadata?: () => Promise<void>; | ||
| 40 | readonly onConfigSnapshot?: (config: ServerConfig) => void; | ||
| 41 | readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; | ||
| 42 | readonly onShellResubscribe?: (environmentId: EnvironmentId) => void; | ||
| 43 | } | ||
| 44 | |||
| 45 | export interface EnvironmentConnectionAttempt { | ||
| 46 | readonly environmentId: EnvironmentId; | ||
| 47 | readonly isCurrent: () => boolean; | ||
| 48 | } | ||
| 49 | |||
| 50 | export class EnvironmentConnectionAttemptCancelledError extends Error { | ||
| 51 | constructor(environmentId: EnvironmentId) { | ||
| 52 | super(`Environment connection attempt ${environmentId} was cancelled.`); | ||
| 53 | this.name = "EnvironmentConnectionAttemptCancelledError"; | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | export function createEnvironmentConnectionAttemptRegistry() { | ||
| 58 | const attempts = new Map<EnvironmentId, symbol>(); | ||
| 59 | |||
| 60 | return { | ||
| 61 | begin: (environmentId: EnvironmentId): EnvironmentConnectionAttempt => { | ||
| 62 | const id = Symbol(environmentId); | ||
| 63 | attempts.set(environmentId, id); | ||
| 64 | return { | ||
| 65 | environmentId, | ||
| 66 | isCurrent: () => attempts.get(environmentId) === id, | ||
| 67 | }; | ||
| 68 | }, | ||
| 69 | cancel: (environmentId: EnvironmentId): void => { | ||
| 70 | attempts.delete(environmentId); | ||
| 71 | }, | ||
| 72 | clear: (): void => { | ||
| 73 | attempts.clear(); | ||
| 74 | }, | ||
| 75 | }; | ||
| 76 | } | ||
| 77 | |||
| 78 | export class EnvironmentConnectionDisposedError extends Error { | ||
| 79 | constructor(environmentId: EnvironmentId) { | ||
| 80 | super(`Environment connection ${environmentId} was disposed before it finished bootstrapping.`); | ||
| 81 | this.name = "EnvironmentConnectionDisposedError"; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | function createBootstrapGate() { | ||
| 86 | let resolve: (() => void) | null = null; | ||
| 87 | let reject: ((error: unknown) => void) | null = null; | ||
| 88 | const makePromise = () => { | ||
| 89 | const nextPromise = new Promise<void>((nextResolve, nextReject) => { | ||
| 90 | resolve = nextResolve; | ||
| 91 | reject = nextReject; | ||
| 92 | }); | ||
| 93 | void nextPromise.catch(() => undefined); | ||
| 94 | return nextPromise; | ||
| 95 | }; | ||
| 96 | let promise = makePromise(); | ||
| 97 | |||
| 98 | return { | ||
| 99 | wait: () => promise, | ||
| 100 | resolve: () => { | ||
| 101 | resolve?.(); | ||
| 102 | resolve = null; | ||
| 103 | reject = null; | ||
| 104 | }, | ||
| 105 | reject: (error: unknown) => { | ||
| 106 | reject?.(error); | ||
| 107 | resolve = null; | ||
| 108 | reject = null; | ||
| 109 | }, | ||
| 110 | reset: () => { | ||
| 111 | promise = makePromise(); | ||
| 112 | }, | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | export function createEnvironmentConnection( | ||
| 117 | input: EnvironmentConnectionInput, | ||
| 118 | ): EnvironmentConnection { | ||
| 119 | const environmentId = input.knownEnvironment.environmentId; | ||
| 120 | |||
| 121 | if (!environmentId) { | ||
| 122 | throw new Error( | ||
| 123 | `Known environment ${input.knownEnvironment.label} is missing its environmentId.`, | ||
| 124 | ); | ||
| 125 | } | ||
| 126 | |||
| 127 | let disposed = false; | ||
| 128 | const bootstrapGate = createBootstrapGate(); | ||
| 129 | const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined; | ||
| 130 | const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined; | ||
| 131 | |||
| 132 | const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { | ||
| 133 | if (environmentId !== nextEnvironmentId) { | ||
| 134 | throw new Error( | ||
| 135 | `Environment connection ${environmentId} changed identity to ${nextEnvironmentId} via ${source}.`, | ||
| 136 | ); | ||
| 137 | } | ||
| 138 | }; | ||
| 139 | |||
| 140 | const unsubLifecycle = shouldObserveLifecycle | ||
| 141 | ? input.client.server.subscribeLifecycle((event) => { | ||
| 142 | if (disposed || event.type !== "welcome") { | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | |||
| 146 | observeEnvironmentIdentity( | ||
| 147 | event.payload.environment.environmentId, | ||
| 148 | "server lifecycle welcome", | ||
| 149 | ); | ||
| 150 | input.onWelcome?.(event.payload); | ||
| 151 | }) | ||
| 152 | : () => undefined; | ||
| 153 | |||
| 154 | const unsubConfig = shouldObserveConfig | ||
| 155 | ? input.client.server.subscribeConfig((event) => { | ||
| 156 | if (disposed || event.type !== "snapshot") { | ||
| 157 | return; | ||
| 158 | } | ||
| 159 | |||
| 160 | observeEnvironmentIdentity( | ||
| 161 | event.config.environment.environmentId, | ||
| 162 | "server config snapshot", | ||
| 163 | ); | ||
| 164 | input.onConfigSnapshot?.(event.config); | ||
| 165 | }) | ||
| 166 | : () => undefined; | ||
| 167 | |||
| 168 | const unsubShell = input.client.orchestration.subscribeShell( | ||
| 169 | (item) => { | ||
| 170 | if (disposed) { | ||
| 171 | return; | ||
| 172 | } | ||
| 173 | |||
| 174 | if (item.kind === "snapshot") { | ||
| 175 | input.syncShellSnapshot(item.snapshot, environmentId); | ||
| 176 | bootstrapGate.resolve(); | ||
| 177 | return; | ||
| 178 | } | ||
| 179 | |||
| 180 | input.applyShellEvent(item, environmentId); | ||
| 181 | }, | ||
| 182 | { | ||
| 183 | onResubscribe: () => { | ||
| 184 | if (disposed) { | ||
| 185 | return; | ||
| 186 | } | ||
| 187 | |||
| 188 | bootstrapGate.reset(); | ||
| 189 | input.onShellResubscribe?.(environmentId); | ||
| 190 | }, | ||
| 191 | }, | ||
| 192 | ); | ||
| 193 | |||
| 194 | const unsubTerminalEvent = input.applyTerminalEvent | ||
| 195 | ? input.client.terminal.onEvent((event) => { | ||
| 196 | if (!disposed) { | ||
| 197 | input.applyTerminalEvent?.(event, environmentId); | ||
| 198 | } | ||
| 199 | }) | ||
| 200 | : () => undefined; | ||
| 201 | |||
| 202 | const cleanup = () => { | ||
| 203 | if (disposed) { | ||
| 204 | return; | ||
| 205 | } | ||
| 206 | |||
| 207 | disposed = true; | ||
| 208 | bootstrapGate.reject(new EnvironmentConnectionDisposedError(environmentId)); | ||
| 209 | unsubShell(); | ||
| 210 | unsubTerminalEvent(); | ||
| 211 | unsubLifecycle(); | ||
| 212 | unsubConfig(); | ||
| 213 | }; | ||
| 214 | |||
| 215 | return { | ||
| 216 | kind: input.kind, | ||
| 217 | environmentId, | ||
| 218 | knownEnvironment: input.knownEnvironment, | ||
| 219 | client: input.client, | ||
| 220 | ensureBootstrapped: () => | ||
| 221 | disposed | ||
| 222 | ? Promise.reject(new EnvironmentConnectionDisposedError(environmentId)) | ||
| 223 | : bootstrapGate.wait(), | ||
| 224 | reconnect: async () => { | ||
| 225 | if (disposed) { | ||
| 226 | throw new EnvironmentConnectionDisposedError(environmentId); | ||
| 227 | } | ||
| 228 | |||
| 229 | bootstrapGate.reset(); | ||
| 230 | try { | ||
| 231 | await input.client.reconnect(); | ||
| 232 | await input.refreshMetadata?.(); | ||
| 233 | await bootstrapGate.wait(); | ||
| 234 | } catch (error) { | ||
| 235 | bootstrapGate.reject(error); | ||
| 236 | throw error; | ||
| 237 | } | ||
| 238 | }, | ||
| 239 | dispose: async () => { | ||
| 240 | cleanup(); | ||
| 241 | await input.client.dispose(); | ||
| 242 | }, | ||
| 243 | }; | ||
| 244 | } | ||
packages/client-runtime/src/environmentRuntimeState.test.ts deleted-75| ... | @@ -1,75 +0,0 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { afterEach, describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { createEnvironmentRuntimeManager } from "./environmentRuntimeState.ts"; | ||
| 6 | |||
| 7 | let atomRegistry = AtomRegistry.make(); | ||
| 8 | |||
| 9 | function resetAtomRegistry() { | ||
| 10 | atomRegistry.dispose(); | ||
| 11 | atomRegistry = AtomRegistry.make(); | ||
| 12 | } | ||
| 13 | |||
| 14 | const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; | ||
| 15 | |||
| 16 | describe("createEnvironmentRuntimeManager", () => { | ||
| 17 | afterEach(() => { | ||
| 18 | resetAtomRegistry(); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("stores state per environment", () => { | ||
| 22 | const manager = createEnvironmentRuntimeManager({ | ||
| 23 | getRegistry: () => atomRegistry, | ||
| 24 | }); | ||
| 25 | |||
| 26 | manager.setState(TARGET, { | ||
| 27 | connectionState: "ready", | ||
| 28 | connectionError: null, | ||
| 29 | serverConfig: null, | ||
| 30 | }); | ||
| 31 | |||
| 32 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 33 | connectionState: "ready", | ||
| 34 | connectionError: null, | ||
| 35 | serverConfig: null, | ||
| 36 | }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("patches the current state", () => { | ||
| 40 | const manager = createEnvironmentRuntimeManager({ | ||
| 41 | getRegistry: () => atomRegistry, | ||
| 42 | }); | ||
| 43 | |||
| 44 | manager.patch(TARGET, (current) => ({ | ||
| 45 | ...current, | ||
| 46 | connectionState: "disconnected", | ||
| 47 | connectionError: "Socket closed.", | ||
| 48 | })); | ||
| 49 | |||
| 50 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 51 | connectionState: "disconnected", | ||
| 52 | connectionError: "Socket closed.", | ||
| 53 | serverConfig: null, | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("invalidates a single environment", () => { | ||
| 58 | const manager = createEnvironmentRuntimeManager({ | ||
| 59 | getRegistry: () => atomRegistry, | ||
| 60 | }); | ||
| 61 | |||
| 62 | manager.setState(TARGET, { | ||
| 63 | connectionState: "ready", | ||
| 64 | connectionError: null, | ||
| 65 | serverConfig: null, | ||
| 66 | }); | ||
| 67 | manager.invalidate(TARGET); | ||
| 68 | |||
| 69 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 70 | connectionState: "idle", | ||
| 71 | connectionError: null, | ||
| 72 | serverConfig: null, | ||
| 73 | }); | ||
| 74 | }); | ||
| 75 | }); | ||
packages/client-runtime/src/environmentRuntimeState.ts deleted-104| ... | @@ -1,104 +0,0 @@ | ||
| 1 | import type { EnvironmentId, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; | ||
| 2 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | export type EnvironmentConnectionState = | ||
| 5 | | "idle" | ||
| 6 | | "connecting" | ||
| 7 | | "ready" | ||
| 8 | | "reconnecting" | ||
| 9 | | "disconnected"; | ||
| 10 | |||
| 11 | export interface EnvironmentRuntimeState { | ||
| 12 | readonly connectionState: EnvironmentConnectionState; | ||
| 13 | readonly connectionError: string | null; | ||
| 14 | readonly serverConfig: T3ServerConfig | null; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface EnvironmentRuntimeTarget { | ||
| 18 | readonly environmentId: EnvironmentId | null; | ||
| 19 | } | ||
| 20 | |||
| 21 | export const EMPTY_ENVIRONMENT_RUNTIME_STATE = Object.freeze<EnvironmentRuntimeState>({ | ||
| 22 | connectionState: "idle", | ||
| 23 | connectionError: null, | ||
| 24 | serverConfig: null, | ||
| 25 | }); | ||
| 26 | |||
| 27 | const knownEnvironmentRuntimeKeys = new Set<string>(); | ||
| 28 | |||
| 29 | export const environmentRuntimeStateAtom = Atom.family((key: string) => { | ||
| 30 | knownEnvironmentRuntimeKeys.add(key); | ||
| 31 | return Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( | ||
| 32 | Atom.keepAlive, | ||
| 33 | Atom.withLabel(`environment-runtime:${key}`), | ||
| 34 | ); | ||
| 35 | }); | ||
| 36 | |||
| 37 | export const EMPTY_ENVIRONMENT_RUNTIME_ATOM = Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( | ||
| 38 | Atom.keepAlive, | ||
| 39 | Atom.withLabel("environment-runtime:null"), | ||
| 40 | ); | ||
| 41 | |||
| 42 | export function getEnvironmentRuntimeTargetKey(target: EnvironmentRuntimeTarget): string | null { | ||
| 43 | return target.environmentId; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface EnvironmentRuntimeManagerConfig { | ||
| 47 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 48 | } | ||
| 49 | |||
| 50 | export function createEnvironmentRuntimeManager(config: EnvironmentRuntimeManagerConfig) { | ||
| 51 | function getSnapshot(target: EnvironmentRuntimeTarget): EnvironmentRuntimeState { | ||
| 52 | const targetKey = getEnvironmentRuntimeTargetKey(target); | ||
| 53 | if (targetKey === null) { | ||
| 54 | return EMPTY_ENVIRONMENT_RUNTIME_STATE; | ||
| 55 | } | ||
| 56 | |||
| 57 | return config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); | ||
| 58 | } | ||
| 59 | |||
| 60 | function setState(target: EnvironmentRuntimeTarget, nextState: EnvironmentRuntimeState): void { | ||
| 61 | const targetKey = getEnvironmentRuntimeTargetKey(target); | ||
| 62 | if (targetKey === null) { | ||
| 63 | return; | ||
| 64 | } | ||
| 65 | |||
| 66 | config.getRegistry().set(environmentRuntimeStateAtom(targetKey), nextState); | ||
| 67 | } | ||
| 68 | |||
| 69 | function patch( | ||
| 70 | target: EnvironmentRuntimeTarget, | ||
| 71 | updater: (current: EnvironmentRuntimeState) => EnvironmentRuntimeState, | ||
| 72 | ): void { | ||
| 73 | const targetKey = getEnvironmentRuntimeTargetKey(target); | ||
| 74 | if (targetKey === null) { | ||
| 75 | return; | ||
| 76 | } | ||
| 77 | |||
| 78 | const current = config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); | ||
| 79 | config.getRegistry().set(environmentRuntimeStateAtom(targetKey), updater(current)); | ||
| 80 | } | ||
| 81 | |||
| 82 | function invalidate(target?: EnvironmentRuntimeTarget): void { | ||
| 83 | if (target) { | ||
| 84 | setState(target, EMPTY_ENVIRONMENT_RUNTIME_STATE); | ||
| 85 | return; | ||
| 86 | } | ||
| 87 | |||
| 88 | for (const key of knownEnvironmentRuntimeKeys) { | ||
| 89 | config.getRegistry().set(environmentRuntimeStateAtom(key), EMPTY_ENVIRONMENT_RUNTIME_STATE); | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | function reset(): void { | ||
| 94 | invalidate(); | ||
| 95 | } | ||
| 96 | |||
| 97 | return { | ||
| 98 | getSnapshot, | ||
| 99 | setState, | ||
| 100 | patch, | ||
| 101 | invalidate, | ||
| 102 | reset, | ||
| 103 | }; | ||
| 104 | } | ||
packages/client-runtime/src/errors/errorTrace.test.ts created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { findErrorTraceId } from "./errorTrace.ts"; | ||
| 4 | |||
| 5 | describe("findErrorTraceId", () => { | ||
| 6 | it("finds trace metadata through wrapped typed errors", () => { | ||
| 7 | expect( | ||
| 8 | findErrorTraceId({ | ||
| 9 | cause: { | ||
| 10 | cause: { | ||
| 11 | _tag: "RelayInternalError", | ||
| 12 | traceId: "trace-relay", | ||
| 13 | }, | ||
| 14 | }, | ||
| 15 | }), | ||
| 16 | ).toBe("trace-relay"); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("terminates for cyclic causes", () => { | ||
| 20 | const error: { cause?: unknown } = {}; | ||
| 21 | error.cause = error; | ||
| 22 | |||
| 23 | expect(findErrorTraceId(error)).toBeNull(); | ||
| 24 | }); | ||
| 25 | }); | ||
packages/client-runtime/src/errors/errorTrace.ts created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | export function findErrorTraceId(error: unknown): string | null { | ||
| 2 | const seen = new Set<object>(); | ||
| 3 | let current: unknown = error; | ||
| 4 | |||
| 5 | while (typeof current === "object" && current !== null && !seen.has(current)) { | ||
| 6 | seen.add(current); | ||
| 7 | const record = current as { | ||
| 8 | readonly cause?: unknown; | ||
| 9 | readonly traceId?: unknown; | ||
| 10 | }; | ||
| 11 | if (typeof record.traceId === "string" && record.traceId.trim().length > 0) { | ||
| 12 | return record.traceId; | ||
| 13 | } | ||
| 14 | current = record.cause; | ||
| 15 | } | ||
| 16 | |||
| 17 | return null; | ||
| 18 | } | ||
packages/client-runtime/src/errors/index.ts created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | export * from "./errorTrace.ts"; | ||
| 2 | export * from "./transport.ts"; | ||
packages/client-runtime/src/errors/transport.test.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transport.ts"; | ||
| 4 | |||
| 5 | describe("isTransportConnectionErrorMessage", () => { | ||
| 6 | it("returns true for SocketCloseError", () => { | ||
| 7 | expect(isTransportConnectionErrorMessage("SocketCloseError: connection reset")).toBe(true); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("returns true for SocketOpenError", () => { | ||
| 11 | expect(isTransportConnectionErrorMessage("SocketOpenError: ECONNREFUSED")).toBe(true); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("returns true for React Native disconnected socket errors", () => { | ||
| 15 | expect( | ||
| 16 | isTransportConnectionErrorMessage( | ||
| 17 | "The operation couldn't be completed. Socket is not connected", | ||
| 18 | ), | ||
| 19 | ).toBe(true); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("recognizes connection errors emitted by the Effect RPC session", () => { | ||
| 23 | expect(isTransportConnectionErrorMessage("Test environment disconnected.")).toBe(true); | ||
| 24 | expect( | ||
| 25 | isTransportConnectionErrorMessage( | ||
| 26 | "Test environment could not establish a WebSocket connection.", | ||
| 27 | ), | ||
| 28 | ).toBe(true); | ||
| 29 | expect(isTransportConnectionErrorMessage("Test environment is not connected.")).toBe(true); | ||
| 30 | expect(isTransportConnectionErrorMessage("ClientProtocolError: socket closed")).toBe(true); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("returns true for the T3 server WebSocket message", () => { | ||
| 34 | expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( | ||
| 35 | true, | ||
| 36 | ); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("returns true for ping timeout", () => { | ||
| 40 | expect(isTransportConnectionErrorMessage("ping timeout")).toBe(true); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("returns false for business logic errors", () => { | ||
| 44 | expect(isTransportConnectionErrorMessage("Thread not found")).toBe(false); | ||
| 45 | expect(isTransportConnectionErrorMessage("Invalid model selection")).toBe(false); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("returns false for null, undefined, and empty strings", () => { | ||
| 49 | expect(isTransportConnectionErrorMessage(null)).toBe(false); | ||
| 50 | expect(isTransportConnectionErrorMessage(undefined)).toBe(false); | ||
| 51 | expect(isTransportConnectionErrorMessage("")).toBe(false); | ||
| 52 | expect(isTransportConnectionErrorMessage(" ")).toBe(false); | ||
| 53 | }); | ||
| 54 | }); | ||
| 55 | |||
| 56 | describe("sanitizeThreadErrorMessage", () => { | ||
| 57 | it("strips transport errors", () => { | ||
| 58 | expect(sanitizeThreadErrorMessage("SocketCloseError: oops")).toBeNull(); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("preserves non-transport errors", () => { | ||
| 62 | expect(sanitizeThreadErrorMessage("Thread not found")).toBe("Thread not found"); | ||
| 63 | expect(sanitizeThreadErrorMessage("Select a base branch before sending.")).toBe( | ||
| 64 | "Select a base branch before sending.", | ||
| 65 | ); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("returns null for null/undefined", () => { | ||
| 69 | expect(sanitizeThreadErrorMessage(null)).toBeNull(); | ||
| 70 | expect(sanitizeThreadErrorMessage(undefined)).toBeNull(); | ||
| 71 | }); | ||
| 72 | }); | ||
packages/client-runtime/src/errors/transport.ts created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | const TRANSPORT_ERROR_PATTERNS = [ | ||
| 2 | /\bSocketCloseError\b/i, | ||
| 3 | /\bSocketOpenError\b/i, | ||
| 4 | /\bSocket is not connected\b/i, | ||
| 5 | /Unable to connect to the T3 server WebSocket\./i, | ||
| 6 | /\bis not connected\.$/i, | ||
| 7 | /\bdisconnected\.$/i, | ||
| 8 | /\bcould not establish a WebSocket connection\.$/i, | ||
| 9 | /\bClientProtocolError\b/i, | ||
| 10 | /\bRpcClientError\b/i, | ||
| 11 | /\bping timeout\b/i, | ||
| 12 | ] as const; | ||
| 13 | |||
| 14 | /** | ||
| 15 | * Check whether an error message originates from a transport-level connection | ||
| 16 | * failure (socket close, socket open, ping timeout, etc.) rather than a | ||
| 17 | * business-logic error. | ||
| 18 | */ | ||
| 19 | export function isTransportConnectionErrorMessage(message: string | null | undefined): boolean { | ||
| 20 | if (typeof message !== "string") { | ||
| 21 | return false; | ||
| 22 | } | ||
| 23 | |||
| 24 | const normalizedMessage = message.trim(); | ||
| 25 | if (normalizedMessage.length === 0) { | ||
| 26 | return false; | ||
| 27 | } | ||
| 28 | |||
| 29 | return TRANSPORT_ERROR_PATTERNS.some((pattern) => pattern.test(normalizedMessage)); | ||
| 30 | } | ||
| 31 | |||
| 32 | /** | ||
| 33 | * Strip transport connection errors from user-facing error messages. | ||
| 34 | * Returns `null` for transport errors so the UI can distinguish between | ||
| 35 | * real errors and transient connection issues. | ||
| 36 | */ | ||
| 37 | export function sanitizeThreadErrorMessage(message: string | null | undefined): string | null { | ||
| 38 | return isTransportConnectionErrorMessage(message) ? null : (message ?? null); | ||
| 39 | } | ||
packages/client-runtime/src/filesystemBrowseState.test.ts deleted-119| ... | @@ -1,119 +0,0 @@ | ||
| 1 | import { assert, beforeEach, it } from "vite-plus/test"; | ||
| 2 | import type { FilesystemBrowseResult } from "@t3tools/contracts"; | ||
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | EMPTY_FILESYSTEM_BROWSE_STATE, | ||
| 7 | createFilesystemBrowseManager, | ||
| 8 | } from "./filesystemBrowseState.ts"; | ||
| 9 | |||
| 10 | const ROOT_RESULT: FilesystemBrowseResult = { | ||
| 11 | parentPath: "/Users/julius", | ||
| 12 | entries: [ | ||
| 13 | { | ||
| 14 | name: "code", | ||
| 15 | fullPath: "/Users/julius/code", | ||
| 16 | }, | ||
| 17 | ], | ||
| 18 | }; | ||
| 19 | |||
| 20 | let registry = AtomRegistry.make(); | ||
| 21 | |||
| 22 | beforeEach(() => { | ||
| 23 | registry.dispose(); | ||
| 24 | registry = AtomRegistry.make(); | ||
| 25 | }); | ||
| 26 | |||
| 27 | function unresolvedBrowse() { | ||
| 28 | throw new Error("Browse resolver was not initialized."); | ||
| 29 | } | ||
| 30 | |||
| 31 | function flushAsyncWork(): Promise<void> { | ||
| 32 | return Promise.resolve().then(() => undefined); | ||
| 33 | } | ||
| 34 | |||
| 35 | it("stores browsed folder data in an atom snapshot", async () => { | ||
| 36 | const manager = createFilesystemBrowseManager({ | ||
| 37 | getRegistry: () => registry, | ||
| 38 | getClient: () => ({ | ||
| 39 | browse: async () => ROOT_RESULT, | ||
| 40 | }), | ||
| 41 | }); | ||
| 42 | |||
| 43 | assert.deepStrictEqual( | ||
| 44 | manager.getSnapshot({ key: null, input: null }), | ||
| 45 | EMPTY_FILESYSTEM_BROWSE_STATE, | ||
| 46 | ); | ||
| 47 | |||
| 48 | const target = { key: "env-1", input: { partialPath: "~" } }; | ||
| 49 | const result = await manager.refresh(target); | ||
| 50 | |||
| 51 | assert.strictEqual(result, ROOT_RESULT); | ||
| 52 | assert.deepStrictEqual(manager.getSnapshot(target), { | ||
| 53 | data: ROOT_RESULT, | ||
| 54 | error: null, | ||
| 55 | isPending: false, | ||
| 56 | }); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("deduplicates in-flight browse refreshes by target input", async () => { | ||
| 60 | let resolveBrowse: (result: FilesystemBrowseResult) => void = unresolvedBrowse; | ||
| 61 | let calls = 0; | ||
| 62 | const target = { key: "env-1", input: { partialPath: "~" } }; | ||
| 63 | const manager = createFilesystemBrowseManager({ | ||
| 64 | getRegistry: () => registry, | ||
| 65 | getClient: () => ({ | ||
| 66 | browse: () => { | ||
| 67 | calls += 1; | ||
| 68 | return new Promise<FilesystemBrowseResult>((resolve) => { | ||
| 69 | resolveBrowse = resolve; | ||
| 70 | }); | ||
| 71 | }, | ||
| 72 | }), | ||
| 73 | }); | ||
| 74 | |||
| 75 | const first = manager.refresh(target); | ||
| 76 | const second = manager.refresh(target); | ||
| 77 | |||
| 78 | assert.strictEqual(first, second); | ||
| 79 | assert.strictEqual(calls, 1); | ||
| 80 | assert.deepStrictEqual(manager.getSnapshot(target), { | ||
| 81 | data: null, | ||
| 82 | error: null, | ||
| 83 | isPending: true, | ||
| 84 | }); | ||
| 85 | |||
| 86 | resolveBrowse(ROOT_RESULT); | ||
| 87 | await first; | ||
| 88 | |||
| 89 | assert.deepStrictEqual(manager.getSnapshot(target), { | ||
| 90 | data: ROOT_RESULT, | ||
| 91 | error: null, | ||
| 92 | isPending: false, | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("keeps fresh watched browse results on remount", async () => { | ||
| 97 | let browseCalls = 0; | ||
| 98 | const target = { key: "env-1", input: { partialPath: "~" } }; | ||
| 99 | const manager = createFilesystemBrowseManager({ | ||
| 100 | getRegistry: () => registry, | ||
| 101 | getClient: () => ({ | ||
| 102 | browse: async () => { | ||
| 103 | browseCalls += 1; | ||
| 104 | return ROOT_RESULT; | ||
| 105 | }, | ||
| 106 | }), | ||
| 107 | staleTimeMs: 60_000, | ||
| 108 | }); | ||
| 109 | |||
| 110 | const firstUnwatch = manager.watch(target); | ||
| 111 | await flushAsyncWork(); | ||
| 112 | firstUnwatch(); | ||
| 113 | |||
| 114 | const secondUnwatch = manager.watch(target); | ||
| 115 | await flushAsyncWork(); | ||
| 116 | secondUnwatch(); | ||
| 117 | |||
| 118 | assert.strictEqual(browseCalls, 1); | ||
| 119 | }); | ||
packages/client-runtime/src/filesystemBrowseState.ts deleted-339| ... | @@ -1,339 +0,0 @@ | ||
| 1 | import type { FilesystemBrowseInput, FilesystemBrowseResult } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | export interface FilesystemBrowseState { | ||
| 6 | readonly data: FilesystemBrowseResult | null; | ||
| 7 | readonly error: string | null; | ||
| 8 | readonly isPending: boolean; | ||
| 9 | } | ||
| 10 | |||
| 11 | export interface FilesystemBrowseTarget<TKey extends string = string> { | ||
| 12 | readonly key: TKey | null; | ||
| 13 | readonly input: FilesystemBrowseInput | null; | ||
| 14 | } | ||
| 15 | |||
| 16 | export interface FilesystemBrowseClient { | ||
| 17 | readonly browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>; | ||
| 18 | } | ||
| 19 | |||
| 20 | interface WatchedEntry { | ||
| 21 | refCount: number; | ||
| 22 | teardown: () => void; | ||
| 23 | } | ||
| 24 | |||
| 25 | export const EMPTY_FILESYSTEM_BROWSE_STATE = Object.freeze<FilesystemBrowseState>({ | ||
| 26 | data: null, | ||
| 27 | error: null, | ||
| 28 | isPending: false, | ||
| 29 | }); | ||
| 30 | |||
| 31 | const INITIAL_FILESYSTEM_BROWSE_STATE = Object.freeze<FilesystemBrowseState>({ | ||
| 32 | data: null, | ||
| 33 | error: null, | ||
| 34 | isPending: true, | ||
| 35 | }); | ||
| 36 | |||
| 37 | const knownFilesystemBrowseKeys = new Set<string>(); | ||
| 38 | |||
| 39 | export const filesystemBrowseStateAtom = Atom.family((targetKey: string) => { | ||
| 40 | knownFilesystemBrowseKeys.add(targetKey); | ||
| 41 | return Atom.make(INITIAL_FILESYSTEM_BROWSE_STATE).pipe( | ||
| 42 | Atom.keepAlive, | ||
| 43 | Atom.withLabel(`filesystem-browse:${targetKey}`), | ||
| 44 | ); | ||
| 45 | }); | ||
| 46 | |||
| 47 | export const EMPTY_FILESYSTEM_BROWSE_ATOM = Atom.make(EMPTY_FILESYSTEM_BROWSE_STATE).pipe( | ||
| 48 | Atom.keepAlive, | ||
| 49 | Atom.withLabel("filesystem-browse:null"), | ||
| 50 | ); | ||
| 51 | |||
| 52 | const NOOP: () => void = () => undefined; | ||
| 53 | const DEFAULT_STALE_TIME_MS = 30_000; | ||
| 54 | const DEFAULT_IDLE_TTL_MS = 5 * 60_000; | ||
| 55 | |||
| 56 | export function getFilesystemBrowseTargetKey<TKey extends string>( | ||
| 57 | target: FilesystemBrowseTarget<TKey>, | ||
| 58 | ): string | null { | ||
| 59 | const key = target.key; | ||
| 60 | const input = target.input; | ||
| 61 | if (!key || !input || input.partialPath.length === 0) { | ||
| 62 | return null; | ||
| 63 | } | ||
| 64 | |||
| 65 | return JSON.stringify([key, input.cwd ?? null, input.partialPath]); | ||
| 66 | } | ||
| 67 | |||
| 68 | export interface FilesystemBrowseManagerConfig<TKey extends string = string> { | ||
| 69 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 70 | readonly getClient: (key: TKey) => FilesystemBrowseClient | null; | ||
| 71 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 72 | readonly staleTimeMs?: number; | ||
| 73 | readonly idleTtlMs?: number; | ||
| 74 | } | ||
| 75 | |||
| 76 | export function createFilesystemBrowseManager<TKey extends string = string>( | ||
| 77 | config: FilesystemBrowseManagerConfig<TKey>, | ||
| 78 | ) { | ||
| 79 | const refreshInFlight = new Map< | ||
| 80 | string, | ||
| 81 | { | ||
| 82 | readonly client: FilesystemBrowseClient; | ||
| 83 | readonly promise: Promise<FilesystemBrowseResult | null>; | ||
| 84 | } | ||
| 85 | >(); | ||
| 86 | const refreshVersions = new Map<string, number>(); | ||
| 87 | const watched = new Map<string, WatchedEntry>(); | ||
| 88 | const refreshTargets = new Map<string, FilesystemBrowseTarget<TKey>>(); | ||
| 89 | const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; | ||
| 90 | const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; | ||
| 91 | |||
| 92 | const watchedRefreshAtom = Atom.family((targetKey: string) => | ||
| 93 | Atom.make(() => | ||
| 94 | Effect.promise(() => { | ||
| 95 | const target = refreshTargets.get(targetKey); | ||
| 96 | return target ? refresh(target) : Promise.resolve(null); | ||
| 97 | }), | ||
| 98 | ).pipe( | ||
| 99 | Atom.swr({ | ||
| 100 | staleTime: staleTimeMs, | ||
| 101 | revalidateOnMount: true, | ||
| 102 | }), | ||
| 103 | Atom.setIdleTTL(idleTtlMs), | ||
| 104 | Atom.withLabel(`filesystem-browse:watched-refresh:${targetKey}`), | ||
| 105 | ), | ||
| 106 | ); | ||
| 107 | |||
| 108 | function getRefreshVersion(targetKey: string): number { | ||
| 109 | return refreshVersions.get(targetKey) ?? 0; | ||
| 110 | } | ||
| 111 | |||
| 112 | function bumpRefreshVersion(targetKey: string): void { | ||
| 113 | refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); | ||
| 114 | } | ||
| 115 | |||
| 116 | function setState(targetKey: string, nextState: FilesystemBrowseState): void { | ||
| 117 | config.getRegistry().set(filesystemBrowseStateAtom(targetKey), nextState); | ||
| 118 | } | ||
| 119 | |||
| 120 | function markPending(targetKey: string): void { | ||
| 121 | const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); | ||
| 122 | const next: FilesystemBrowseState = | ||
| 123 | current.data === null | ||
| 124 | ? INITIAL_FILESYSTEM_BROWSE_STATE | ||
| 125 | : { | ||
| 126 | data: current.data, | ||
| 127 | error: null, | ||
| 128 | isPending: true, | ||
| 129 | }; | ||
| 130 | |||
| 131 | if ( | ||
| 132 | current.data === next.data && | ||
| 133 | current.error === next.error && | ||
| 134 | current.isPending === next.isPending | ||
| 135 | ) { | ||
| 136 | return; | ||
| 137 | } | ||
| 138 | |||
| 139 | setState(targetKey, next); | ||
| 140 | } | ||
| 141 | |||
| 142 | function setData(targetKey: string, data: FilesystemBrowseResult): void { | ||
| 143 | setState(targetKey, { | ||
| 144 | data, | ||
| 145 | error: null, | ||
| 146 | isPending: false, | ||
| 147 | }); | ||
| 148 | } | ||
| 149 | |||
| 150 | function setError(targetKey: string, error: unknown): void { | ||
| 151 | const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); | ||
| 152 | setState(targetKey, { | ||
| 153 | data: current.data, | ||
| 154 | error: error instanceof Error ? error.message : "Failed to browse folder.", | ||
| 155 | isPending: false, | ||
| 156 | }); | ||
| 157 | } | ||
| 158 | |||
| 159 | function refresh( | ||
| 160 | target: FilesystemBrowseTarget<TKey>, | ||
| 161 | client?: FilesystemBrowseClient, | ||
| 162 | ): Promise<FilesystemBrowseResult | null> { | ||
| 163 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 164 | if (targetKey === null || target.key === null || target.input === null) { | ||
| 165 | return Promise.resolve(null); | ||
| 166 | } | ||
| 167 | refreshTargets.set(targetKey, target); | ||
| 168 | |||
| 169 | const resolvedClient = client ?? config.getClient(target.key); | ||
| 170 | if (!resolvedClient) { | ||
| 171 | setError(targetKey, new Error("Filesystem browser client is unavailable.")); | ||
| 172 | return Promise.resolve(getSnapshot(target).data); | ||
| 173 | } | ||
| 174 | |||
| 175 | const existing = refreshInFlight.get(targetKey); | ||
| 176 | if (existing) { | ||
| 177 | if (!client || existing.client === resolvedClient) { | ||
| 178 | return existing.promise; | ||
| 179 | } | ||
| 180 | return existing.promise.then(() => refresh(target, resolvedClient)); | ||
| 181 | } | ||
| 182 | |||
| 183 | markPending(targetKey); | ||
| 184 | const refreshVersion = getRefreshVersion(targetKey); | ||
| 185 | const promise = resolvedClient.browse(target.input).then( | ||
| 186 | (result) => { | ||
| 187 | if (getRefreshVersion(targetKey) === refreshVersion) { | ||
| 188 | setData(targetKey, result); | ||
| 189 | } | ||
| 190 | return result; | ||
| 191 | }, | ||
| 192 | (error: unknown) => { | ||
| 193 | if (getRefreshVersion(targetKey) === refreshVersion) { | ||
| 194 | setError(targetKey, error); | ||
| 195 | } | ||
| 196 | return getSnapshot(target).data; | ||
| 197 | }, | ||
| 198 | ); | ||
| 199 | |||
| 200 | let tracked: Promise<FilesystemBrowseResult | null>; | ||
| 201 | tracked = promise.finally(() => { | ||
| 202 | if (refreshInFlight.get(targetKey)?.promise === tracked) { | ||
| 203 | refreshInFlight.delete(targetKey); | ||
| 204 | } | ||
| 205 | }); | ||
| 206 | refreshInFlight.set(targetKey, { | ||
| 207 | client: resolvedClient, | ||
| 208 | promise: tracked, | ||
| 209 | }); | ||
| 210 | return tracked; | ||
| 211 | } | ||
| 212 | |||
| 213 | function invalidate(target?: FilesystemBrowseTarget<TKey>): void { | ||
| 214 | if (!target) { | ||
| 215 | reset(); | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | |||
| 219 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 220 | if (targetKey === null) { | ||
| 221 | return; | ||
| 222 | } | ||
| 223 | |||
| 224 | bumpRefreshVersion(targetKey); | ||
| 225 | refreshInFlight.delete(targetKey); | ||
| 226 | setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); | ||
| 227 | } | ||
| 228 | |||
| 229 | function getSnapshot(target: FilesystemBrowseTarget<TKey>): FilesystemBrowseState { | ||
| 230 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 231 | if (targetKey === null) { | ||
| 232 | return EMPTY_FILESYSTEM_BROWSE_STATE; | ||
| 233 | } | ||
| 234 | |||
| 235 | return config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); | ||
| 236 | } | ||
| 237 | |||
| 238 | function watch( | ||
| 239 | target: FilesystemBrowseTarget<TKey>, | ||
| 240 | client?: FilesystemBrowseClient, | ||
| 241 | ): () => void { | ||
| 242 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 243 | if (targetKey === null || target.key === null) { | ||
| 244 | return NOOP; | ||
| 245 | } | ||
| 246 | refreshTargets.set(targetKey, target); | ||
| 247 | |||
| 248 | const existing = watched.get(targetKey); | ||
| 249 | if (existing) { | ||
| 250 | existing.refCount += 1; | ||
| 251 | return () => unwatch(targetKey); | ||
| 252 | } | ||
| 253 | |||
| 254 | let teardown: () => void; | ||
| 255 | |||
| 256 | if (client) { | ||
| 257 | void refresh(target, client); | ||
| 258 | teardown = NOOP; | ||
| 259 | } else if (config.subscribeClientChanges) { | ||
| 260 | let currentClient: FilesystemBrowseClient | null = null; | ||
| 261 | |||
| 262 | const sync = () => { | ||
| 263 | const resolved = config.getClient(target.key!); | ||
| 264 | if (!resolved) { | ||
| 265 | currentClient = null; | ||
| 266 | markPending(targetKey); | ||
| 267 | return; | ||
| 268 | } | ||
| 269 | |||
| 270 | if (currentClient === resolved) { | ||
| 271 | return; | ||
| 272 | } | ||
| 273 | |||
| 274 | const isClientReplacement = currentClient !== null; | ||
| 275 | currentClient = resolved; | ||
| 276 | refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); | ||
| 277 | }; | ||
| 278 | |||
| 279 | const unsubChanges = config.subscribeClientChanges(sync); | ||
| 280 | sync(); | ||
| 281 | teardown = unsubChanges; | ||
| 282 | } else { | ||
| 283 | if (!config.getClient(target.key)) { | ||
| 284 | return NOOP; | ||
| 285 | } | ||
| 286 | refreshWatchedTarget(targetKey, target); | ||
| 287 | teardown = NOOP; | ||
| 288 | } | ||
| 289 | |||
| 290 | watched.set(targetKey, { refCount: 1, teardown }); | ||
| 291 | return () => unwatch(targetKey); | ||
| 292 | } | ||
| 293 | |||
| 294 | function unwatch(targetKey: string): void { | ||
| 295 | const entry = watched.get(targetKey); | ||
| 296 | if (!entry) { | ||
| 297 | return; | ||
| 298 | } | ||
| 299 | |||
| 300 | entry.refCount -= 1; | ||
| 301 | if (entry.refCount > 0) { | ||
| 302 | return; | ||
| 303 | } | ||
| 304 | |||
| 305 | entry.teardown(); | ||
| 306 | watched.delete(targetKey); | ||
| 307 | } | ||
| 308 | |||
| 309 | function refreshWatchedTarget( | ||
| 310 | targetKey: string, | ||
| 311 | target: FilesystemBrowseTarget<TKey>, | ||
| 312 | client?: FilesystemBrowseClient, | ||
| 313 | ): void { | ||
| 314 | refreshTargets.set(targetKey, target); | ||
| 315 | const registry = config.getRegistry(); | ||
| 316 | void registry.get(watchedRefreshAtom(targetKey)); | ||
| 317 | if (client) { | ||
| 318 | void refresh(target, client); | ||
| 319 | } | ||
| 320 | } | ||
| 321 | |||
| 322 | function reset(): void { | ||
| 323 | refreshInFlight.clear(); | ||
| 324 | watched.clear(); | ||
| 325 | refreshTargets.clear(); | ||
| 326 | for (const targetKey of knownFilesystemBrowseKeys) { | ||
| 327 | bumpRefreshVersion(targetKey); | ||
| 328 | setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); | ||
| 329 | } | ||
| 330 | } | ||
| 331 | |||
| 332 | return { | ||
| 333 | refresh, | ||
| 334 | invalidate, | ||
| 335 | getSnapshot, | ||
| 336 | watch, | ||
| 337 | reset, | ||
| 338 | }; | ||
| 339 | } | ||
packages/client-runtime/src/gitActions.test.ts deleted-43| ... | @@ -1,43 +0,0 @@ | ||
| 1 | import type { VcsStatusResult } from "@t3tools/contracts"; | ||
| 2 | import { assert, describe, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { resolveLiveThreadBranchUpdate } from "./gitActions.js"; | ||
| 5 | |||
| 6 | function status(refName: string): VcsStatusResult { | ||
| 7 | return { | ||
| 8 | isRepo: true, | ||
| 9 | hasPrimaryRemote: true, | ||
| 10 | isDefaultRef: refName === "main", | ||
| 11 | refName, | ||
| 12 | hasWorkingTreeChanges: false, | ||
| 13 | workingTree: { | ||
| 14 | files: [], | ||
| 15 | insertions: 0, | ||
| 16 | deletions: 0, | ||
| 17 | }, | ||
| 18 | hasUpstream: true, | ||
| 19 | aheadCount: 0, | ||
| 20 | behindCount: 0, | ||
| 21 | pr: null, | ||
| 22 | }; | ||
| 23 | } | ||
| 24 | |||
| 25 | describe("resolveLiveThreadBranchUpdate", () => { | ||
| 26 | it("allows a temporary worktree ref to reconcile to a semantic branch", () => { | ||
| 27 | const update = resolveLiveThreadBranchUpdate({ | ||
| 28 | threadBranch: "t3code/a9628676", | ||
| 29 | gitStatus: status("feature/diff-panel-toggle"), | ||
| 30 | }); | ||
| 31 | |||
| 32 | assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("still reconciles ordinary semantic branch changes", () => { | ||
| 36 | const update = resolveLiveThreadBranchUpdate({ | ||
| 37 | threadBranch: "feature/old", | ||
| 38 | gitStatus: status("feature/new"), | ||
| 39 | }); | ||
| 40 | |||
| 41 | assert.deepEqual(update, { branch: "feature/new" }); | ||
| 42 | }); | ||
| 43 | }); | ||
packages/client-runtime/src/gitActions.ts deleted-440| ... | @@ -1,440 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | GitRunStackedActionInput, | ||
| 3 | GitRunStackedActionResult, | ||
| 4 | GitStackedAction, | ||
| 5 | VcsStatusResult, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; | ||
| 8 | |||
| 9 | export type GitActionIconName = "commit" | "push" | "pr"; | ||
| 10 | |||
| 11 | export type GitDialogAction = "commit" | "push" | "create_pr"; | ||
| 12 | |||
| 13 | export interface GitActionMenuItem { | ||
| 14 | id: "commit" | "push" | "pr"; | ||
| 15 | label: string; | ||
| 16 | disabled: boolean; | ||
| 17 | icon: GitActionIconName; | ||
| 18 | kind: "open_dialog" | "open_pr"; | ||
| 19 | dialogAction?: GitDialogAction; | ||
| 20 | } | ||
| 21 | |||
| 22 | export interface GitQuickAction { | ||
| 23 | label: string; | ||
| 24 | disabled: boolean; | ||
| 25 | kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; | ||
| 26 | action?: GitStackedAction; | ||
| 27 | hint?: string; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface DefaultBranchActionDialogCopy { | ||
| 31 | title: string; | ||
| 32 | description: string; | ||
| 33 | continueLabel: string; | ||
| 34 | } | ||
| 35 | |||
| 36 | export type DefaultBranchConfirmableAction = | ||
| 37 | | "push" | ||
| 38 | | "create_pr" | ||
| 39 | | "commit_push" | ||
| 40 | | "commit_push_pr"; | ||
| 41 | |||
| 42 | export type GitActionRequestInput = Pick< | ||
| 43 | GitRunStackedActionInput, | ||
| 44 | "action" | "commitMessage" | "featureBranch" | "filePaths" | ||
| 45 | >; | ||
| 46 | |||
| 47 | export function buildGitActionProgressStages(input: { | ||
| 48 | action: GitStackedAction; | ||
| 49 | hasCustomCommitMessage: boolean; | ||
| 50 | hasWorkingTreeChanges: boolean; | ||
| 51 | pushTarget?: string; | ||
| 52 | featureBranch?: boolean; | ||
| 53 | shouldPushBeforePr?: boolean; | ||
| 54 | }): string[] { | ||
| 55 | const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; | ||
| 56 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; | ||
| 57 | const prStages = [ | ||
| 58 | "Preparing PR...", | ||
| 59 | "Generating PR content...", | ||
| 60 | "Creating GitHub pull request...", | ||
| 61 | ]; | ||
| 62 | |||
| 63 | if (input.action === "push") { | ||
| 64 | return [pushStage]; | ||
| 65 | } | ||
| 66 | if (input.action === "create_pr") { | ||
| 67 | return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; | ||
| 68 | } | ||
| 69 | |||
| 70 | const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; | ||
| 71 | const commitStages = !shouldIncludeCommitStages | ||
| 72 | ? [] | ||
| 73 | : input.hasCustomCommitMessage | ||
| 74 | ? ["Committing..."] | ||
| 75 | : ["Generating commit message...", "Committing..."]; | ||
| 76 | if (input.action === "commit") { | ||
| 77 | return [...branchStages, ...commitStages]; | ||
| 78 | } | ||
| 79 | if (input.action === "commit_push") { | ||
| 80 | return [...branchStages, ...commitStages, pushStage]; | ||
| 81 | } | ||
| 82 | return [...branchStages, ...commitStages, pushStage, ...prStages]; | ||
| 83 | } | ||
| 84 | |||
| 85 | export function buildMenuItems( | ||
| 86 | gitStatus: VcsStatusResult | null, | ||
| 87 | isBusy: boolean, | ||
| 88 | hasOriginRemote = true, | ||
| 89 | ): GitActionMenuItem[] { | ||
| 90 | if (!gitStatus) return []; | ||
| 91 | |||
| 92 | const hasBranch = gitStatus.refName !== null; | ||
| 93 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 94 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 95 | const isBehind = gitStatus.behindCount > 0; | ||
| 96 | const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; | ||
| 97 | const canCommit = !isBusy && hasChanges; | ||
| 98 | const canPush = | ||
| 99 | !isBusy && | ||
| 100 | hasBranch && | ||
| 101 | !hasChanges && | ||
| 102 | !isBehind && | ||
| 103 | gitStatus.aheadCount > 0 && | ||
| 104 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 105 | const canCreatePr = | ||
| 106 | !isBusy && | ||
| 107 | hasBranch && | ||
| 108 | !hasChanges && | ||
| 109 | !hasOpenPr && | ||
| 110 | gitStatus.aheadCount > 0 && | ||
| 111 | !isBehind && | ||
| 112 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 113 | const canOpenPr = !isBusy && hasOpenPr; | ||
| 114 | |||
| 115 | return [ | ||
| 116 | { | ||
| 117 | id: "commit", | ||
| 118 | label: "Commit", | ||
| 119 | disabled: !canCommit, | ||
| 120 | icon: "commit", | ||
| 121 | kind: "open_dialog", | ||
| 122 | dialogAction: "commit", | ||
| 123 | }, | ||
| 124 | { | ||
| 125 | id: "push", | ||
| 126 | label: "Push", | ||
| 127 | disabled: !canPush, | ||
| 128 | icon: "push", | ||
| 129 | kind: "open_dialog", | ||
| 130 | dialogAction: "push", | ||
| 131 | }, | ||
| 132 | hasOpenPr | ||
| 133 | ? { | ||
| 134 | id: "pr", | ||
| 135 | label: "View PR", | ||
| 136 | disabled: !canOpenPr, | ||
| 137 | icon: "pr", | ||
| 138 | kind: "open_pr", | ||
| 139 | } | ||
| 140 | : { | ||
| 141 | id: "pr", | ||
| 142 | label: "Create PR", | ||
| 143 | disabled: !canCreatePr, | ||
| 144 | icon: "pr", | ||
| 145 | kind: "open_dialog", | ||
| 146 | dialogAction: "create_pr", | ||
| 147 | }, | ||
| 148 | ]; | ||
| 149 | } | ||
| 150 | |||
| 151 | export function resolveQuickAction( | ||
| 152 | gitStatus: VcsStatusResult | null, | ||
| 153 | isBusy: boolean, | ||
| 154 | isDefaultBranch = false, | ||
| 155 | hasOriginRemote = true, | ||
| 156 | ): GitQuickAction { | ||
| 157 | if (isBusy) { | ||
| 158 | return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; | ||
| 159 | } | ||
| 160 | |||
| 161 | if (!gitStatus) { | ||
| 162 | return { | ||
| 163 | label: "Commit", | ||
| 164 | disabled: true, | ||
| 165 | kind: "show_hint", | ||
| 166 | hint: "Git status is unavailable.", | ||
| 167 | }; | ||
| 168 | } | ||
| 169 | |||
| 170 | const hasBranch = gitStatus.refName !== null; | ||
| 171 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 172 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 173 | const isAhead = gitStatus.aheadCount > 0; | ||
| 174 | const isBehind = gitStatus.behindCount > 0; | ||
| 175 | const isDiverged = isAhead && isBehind; | ||
| 176 | |||
| 177 | if (!hasBranch) { | ||
| 178 | return { | ||
| 179 | label: "Commit", | ||
| 180 | disabled: true, | ||
| 181 | kind: "show_hint", | ||
| 182 | hint: "Create and checkout a branch before pushing or opening a PR.", | ||
| 183 | }; | ||
| 184 | } | ||
| 185 | |||
| 186 | if (hasChanges) { | ||
| 187 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 188 | return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; | ||
| 189 | } | ||
| 190 | if (hasOpenPr || isDefaultBranch) { | ||
| 191 | return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; | ||
| 192 | } | ||
| 193 | return { | ||
| 194 | label: "Commit, push & PR", | ||
| 195 | disabled: false, | ||
| 196 | kind: "run_action", | ||
| 197 | action: "commit_push_pr", | ||
| 198 | }; | ||
| 199 | } | ||
| 200 | |||
| 201 | if (!gitStatus.hasUpstream) { | ||
| 202 | if (!hasOriginRemote) { | ||
| 203 | if (hasOpenPr && !isAhead) { | ||
| 204 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 205 | } | ||
| 206 | return { | ||
| 207 | label: "Push", | ||
| 208 | disabled: true, | ||
| 209 | kind: "show_hint", | ||
| 210 | hint: 'Add an "origin" remote before pushing or creating a PR.', | ||
| 211 | }; | ||
| 212 | } | ||
| 213 | if (!isAhead) { | ||
| 214 | if (hasOpenPr) { | ||
| 215 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 216 | } | ||
| 217 | return { | ||
| 218 | label: "Push", | ||
| 219 | disabled: true, | ||
| 220 | kind: "show_hint", | ||
| 221 | hint: "No local commits to push.", | ||
| 222 | }; | ||
| 223 | } | ||
| 224 | if (hasOpenPr || isDefaultBranch) { | ||
| 225 | return { | ||
| 226 | label: "Push", | ||
| 227 | disabled: false, | ||
| 228 | kind: "run_action", | ||
| 229 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 230 | }; | ||
| 231 | } | ||
| 232 | return { | ||
| 233 | label: "Push & create PR", | ||
| 234 | disabled: false, | ||
| 235 | kind: "run_action", | ||
| 236 | action: "create_pr", | ||
| 237 | }; | ||
| 238 | } | ||
| 239 | |||
| 240 | if (isDiverged) { | ||
| 241 | return { | ||
| 242 | label: "Sync branch", | ||
| 243 | disabled: true, | ||
| 244 | kind: "show_hint", | ||
| 245 | hint: "Branch has diverged from upstream. Rebase/merge first.", | ||
| 246 | }; | ||
| 247 | } | ||
| 248 | |||
| 249 | if (isBehind) { | ||
| 250 | return { | ||
| 251 | label: "Pull", | ||
| 252 | disabled: false, | ||
| 253 | kind: "run_pull", | ||
| 254 | }; | ||
| 255 | } | ||
| 256 | |||
| 257 | if (isAhead) { | ||
| 258 | if (hasOpenPr || isDefaultBranch) { | ||
| 259 | return { | ||
| 260 | label: "Push", | ||
| 261 | disabled: false, | ||
| 262 | kind: "run_action", | ||
| 263 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 264 | }; | ||
| 265 | } | ||
| 266 | return { | ||
| 267 | label: "Push & create PR", | ||
| 268 | disabled: false, | ||
| 269 | kind: "run_action", | ||
| 270 | action: "create_pr", | ||
| 271 | }; | ||
| 272 | } | ||
| 273 | |||
| 274 | if (hasOpenPr && gitStatus.hasUpstream) { | ||
| 275 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 276 | } | ||
| 277 | |||
| 278 | return { | ||
| 279 | label: "Commit", | ||
| 280 | disabled: true, | ||
| 281 | kind: "show_hint", | ||
| 282 | hint: "Branch is up to date. No action needed.", | ||
| 283 | }; | ||
| 284 | } | ||
| 285 | |||
| 286 | export function getGitActionDisabledReason(input: { | ||
| 287 | item: GitActionMenuItem; | ||
| 288 | gitStatus: VcsStatusResult | null; | ||
| 289 | isBusy: boolean; | ||
| 290 | hasOriginRemote: boolean; | ||
| 291 | }): string | null { | ||
| 292 | const { item, gitStatus, isBusy, hasOriginRemote } = input; | ||
| 293 | if (!item.disabled) return null; | ||
| 294 | if (isBusy) return "Git action in progress."; | ||
| 295 | if (!gitStatus) return "Git status is unavailable."; | ||
| 296 | |||
| 297 | const hasBranch = gitStatus.refName !== null; | ||
| 298 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 299 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 300 | const isAhead = gitStatus.aheadCount > 0; | ||
| 301 | const isBehind = gitStatus.behindCount > 0; | ||
| 302 | |||
| 303 | if (item.id === "commit") { | ||
| 304 | if (!hasChanges) { | ||
| 305 | return "Worktree is clean. Make changes before committing."; | ||
| 306 | } | ||
| 307 | return "Commit is currently unavailable."; | ||
| 308 | } | ||
| 309 | |||
| 310 | if (item.id === "push") { | ||
| 311 | if (!hasBranch) { | ||
| 312 | return "Detached HEAD: checkout a branch before pushing."; | ||
| 313 | } | ||
| 314 | if (hasChanges) { | ||
| 315 | return "Commit or stash local changes before pushing."; | ||
| 316 | } | ||
| 317 | if (isBehind) { | ||
| 318 | return "Branch is behind upstream. Pull/rebase before pushing."; | ||
| 319 | } | ||
| 320 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 321 | return 'Add an "origin" remote before pushing.'; | ||
| 322 | } | ||
| 323 | if (!isAhead) { | ||
| 324 | return "No local commits to push."; | ||
| 325 | } | ||
| 326 | return "Push is currently unavailable."; | ||
| 327 | } | ||
| 328 | |||
| 329 | if (hasOpenPr) { | ||
| 330 | return "View PR is currently unavailable."; | ||
| 331 | } | ||
| 332 | if (!hasBranch) { | ||
| 333 | return "Detached HEAD: checkout a branch before creating a PR."; | ||
| 334 | } | ||
| 335 | if (hasChanges) { | ||
| 336 | return "Commit local changes before creating a PR."; | ||
| 337 | } | ||
| 338 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 339 | return 'Add an "origin" remote before creating a PR.'; | ||
| 340 | } | ||
| 341 | if (!isAhead) { | ||
| 342 | return "No local commits to include in a PR."; | ||
| 343 | } | ||
| 344 | if (isBehind) { | ||
| 345 | return "Branch is behind upstream. Pull/rebase before creating a PR."; | ||
| 346 | } | ||
| 347 | return "Create PR is currently unavailable."; | ||
| 348 | } | ||
| 349 | |||
| 350 | export function requiresDefaultBranchConfirmation( | ||
| 351 | action: GitStackedAction, | ||
| 352 | isDefaultBranch: boolean, | ||
| 353 | ): boolean { | ||
| 354 | if (!isDefaultBranch) return false; | ||
| 355 | return ( | ||
| 356 | action === "push" || | ||
| 357 | action === "create_pr" || | ||
| 358 | action === "commit_push" || | ||
| 359 | action === "commit_push_pr" | ||
| 360 | ); | ||
| 361 | } | ||
| 362 | |||
| 363 | export function resolveDefaultBranchActionDialogCopy(input: { | ||
| 364 | action: DefaultBranchConfirmableAction; | ||
| 365 | branchName: string; | ||
| 366 | includesCommit: boolean; | ||
| 367 | }): DefaultBranchActionDialogCopy { | ||
| 368 | const branchLabel = input.branchName; | ||
| 369 | const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; | ||
| 370 | |||
| 371 | if (input.action === "push" || input.action === "commit_push") { | ||
| 372 | if (input.includesCommit) { | ||
| 373 | return { | ||
| 374 | title: "Commit & push to default branch?", | ||
| 375 | description: `This action will commit and push changes${suffix}`, | ||
| 376 | continueLabel: `Commit & push to ${branchLabel}`, | ||
| 377 | }; | ||
| 378 | } | ||
| 379 | return { | ||
| 380 | title: "Push to default branch?", | ||
| 381 | description: `This action will push local commits${suffix}`, | ||
| 382 | continueLabel: `Push to ${branchLabel}`, | ||
| 383 | }; | ||
| 384 | } | ||
| 385 | |||
| 386 | if (input.includesCommit) { | ||
| 387 | return { | ||
| 388 | title: "Commit, push & create PR from default branch?", | ||
| 389 | description: `This action will commit, push, and create a PR${suffix}`, | ||
| 390 | continueLabel: "Commit, push & create PR", | ||
| 391 | }; | ||
| 392 | } | ||
| 393 | return { | ||
| 394 | title: "Push & create PR from default branch?", | ||
| 395 | description: `This action will push local commits and create a PR${suffix}`, | ||
| 396 | continueLabel: "Push & create PR", | ||
| 397 | }; | ||
| 398 | } | ||
| 399 | |||
| 400 | export function resolveThreadBranchUpdate( | ||
| 401 | result: GitRunStackedActionResult, | ||
| 402 | ): { branch: string } | null { | ||
| 403 | if (result.branch.status !== "created" || !result.branch.name) { | ||
| 404 | return null; | ||
| 405 | } | ||
| 406 | |||
| 407 | return { | ||
| 408 | branch: result.branch.name, | ||
| 409 | }; | ||
| 410 | } | ||
| 411 | |||
| 412 | export function resolveLiveThreadBranchUpdate(input: { | ||
| 413 | threadBranch: string | null; | ||
| 414 | gitStatus: VcsStatusResult | null; | ||
| 415 | }): { branch: string | null } | null { | ||
| 416 | if (!input.gitStatus) { | ||
| 417 | return null; | ||
| 418 | } | ||
| 419 | |||
| 420 | if (input.gitStatus.refName === null && input.threadBranch !== null) { | ||
| 421 | return null; | ||
| 422 | } | ||
| 423 | |||
| 424 | if (input.threadBranch === input.gitStatus.refName) { | ||
| 425 | return null; | ||
| 426 | } | ||
| 427 | |||
| 428 | if ( | ||
| 429 | input.threadBranch !== null && | ||
| 430 | input.gitStatus.refName !== null && | ||
| 431 | !isTemporaryWorktreeBranch(input.threadBranch) && | ||
| 432 | isTemporaryWorktreeBranch(input.gitStatus.refName) | ||
| 433 | ) { | ||
| 434 | return null; | ||
| 435 | } | ||
| 436 | |||
| 437 | return { | ||
| 438 | branch: input.gitStatus.refName, | ||
| 439 | }; | ||
| 440 | } | ||
packages/client-runtime/src/index.ts deleted-30| ... | @@ -1,30 +0,0 @@ | ||
| 1 | export * from "./advertisedEndpoint.ts"; | ||
| 2 | export * from "./knownEnvironment.ts"; | ||
| 3 | export * from "./reconnectBackoff.ts"; | ||
| 4 | export * from "./scoped.ts"; | ||
| 5 | export * from "./projectPaths.ts"; | ||
| 6 | export * from "./addProject.ts"; | ||
| 7 | export * from "./filesystemBrowseState.ts"; | ||
| 8 | export * from "./sourceControlDiscoveryState.ts"; | ||
| 9 | export * from "./environmentRuntimeState.ts"; | ||
| 10 | export * from "./shellTypes.ts"; | ||
| 11 | export * from "./shellSnapshotReducer.ts"; | ||
| 12 | export * from "./shellSnapshotState.ts"; | ||
| 13 | export * from "./threadDetailReducer.ts"; | ||
| 14 | export * from "./threadDetailState.ts"; | ||
| 15 | export * from "./gitActions.ts"; | ||
| 16 | export * from "./vcsActionState.ts"; | ||
| 17 | export * from "./vcsRefState.ts"; | ||
| 18 | export * from "./vcsStatusState.ts"; | ||
| 19 | export * from "./terminalSessionState.ts"; | ||
| 20 | export * from "./transportError.ts"; | ||
| 21 | export * from "./wsRpcProtocol.ts"; | ||
| 22 | export * from "./wsTransport.ts"; | ||
| 23 | export * from "./wsRpcClient.ts"; | ||
| 24 | export * from "./environmentConnection.ts"; | ||
| 25 | export * from "./composerPathSearchState.ts"; | ||
| 26 | export * from "./archivedThreadsState.ts"; | ||
| 27 | export * from "./checkpointDiffState.ts"; | ||
| 28 | export * from "./remote.ts"; | ||
| 29 | export * from "./managedRelay.ts"; | ||
| 30 | export * from "./managedRelayState.ts"; | ||
packages/client-runtime/src/knownEnvironment.test.ts deleted-92| ... | @@ -1,92 +0,0 @@ | ||
| 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment.ts"; | ||
| 5 | import { | ||
| 6 | parseScopedProjectKey, | ||
| 7 | parseScopedThreadKey, | ||
| 8 | scopedProjectKey, | ||
| 9 | scopedRefKey, | ||
| 10 | scopedThreadKey, | ||
| 11 | scopeProjectRef, | ||
| 12 | scopeThreadRef, | ||
| 13 | } from "./scoped.ts"; | ||
| 14 | |||
| 15 | describe("known environment bootstrap helpers", () => { | ||
| 16 | it("creates known environments from explicit server base urls", () => { | ||
| 17 | expect( | ||
| 18 | createKnownEnvironment({ | ||
| 19 | label: "Remote environment", | ||
| 20 | target: { | ||
| 21 | httpBaseUrl: "https://remote.example.com", | ||
| 22 | wsBaseUrl: "wss://remote.example.com", | ||
| 23 | }, | ||
| 24 | }), | ||
| 25 | ).toEqual({ | ||
| 26 | id: "ws:Remote environment", | ||
| 27 | label: "Remote environment", | ||
| 28 | source: "manual", | ||
| 29 | target: { | ||
| 30 | httpBaseUrl: "https://remote.example.com", | ||
| 31 | wsBaseUrl: "wss://remote.example.com", | ||
| 32 | }, | ||
| 33 | }); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("returns the explicit fetchable http origin", () => { | ||
| 37 | expect( | ||
| 38 | getKnownEnvironmentHttpBaseUrl( | ||
| 39 | createKnownEnvironment({ | ||
| 40 | label: "Local environment", | ||
| 41 | target: { | ||
| 42 | httpBaseUrl: "http://localhost:3773", | ||
| 43 | wsBaseUrl: "ws://localhost:3773", | ||
| 44 | }, | ||
| 45 | }), | ||
| 46 | ), | ||
| 47 | ).toBe("http://localhost:3773"); | ||
| 48 | |||
| 49 | expect( | ||
| 50 | getKnownEnvironmentHttpBaseUrl( | ||
| 51 | createKnownEnvironment({ | ||
| 52 | label: "Remote environment", | ||
| 53 | target: { | ||
| 54 | httpBaseUrl: "https://remote.example.com/api", | ||
| 55 | wsBaseUrl: "wss://remote.example.com/api", | ||
| 56 | }, | ||
| 57 | }), | ||
| 58 | ), | ||
| 59 | ).toBe("https://remote.example.com/api"); | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | describe("scoped refs", () => { | ||
| 64 | const environmentId = EnvironmentId.make("environment-test"); | ||
| 65 | const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); | ||
| 66 | const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1")); | ||
| 67 | |||
| 68 | it("builds stable scoped project and thread keys", () => { | ||
| 69 | expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); | ||
| 70 | expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); | ||
| 71 | expect(scopedProjectKey(projectRef)).toBe("environment-test:project-1"); | ||
| 72 | expect(scopedThreadKey(threadRef)).toBe("environment-test:thread-1"); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("returns typed scoped refs", () => { | ||
| 76 | expect(projectRef).toEqual({ | ||
| 77 | environmentId, | ||
| 78 | projectId: ProjectId.make("project-1"), | ||
| 79 | }); | ||
| 80 | expect(threadRef).toEqual({ | ||
| 81 | environmentId, | ||
| 82 | threadId: ThreadId.make("thread-1"), | ||
| 83 | }); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("parses scoped project and thread keys back into refs", () => { | ||
| 87 | expect(parseScopedProjectKey("environment-test:project-1")).toEqual(projectRef); | ||
| 88 | expect(parseScopedThreadKey("environment-test:thread-1")).toEqual(threadRef); | ||
| 89 | expect(parseScopedProjectKey("bad-key")).toBeNull(); | ||
| 90 | expect(parseScopedThreadKey("bad-key")).toBeNull(); | ||
| 91 | }); | ||
| 92 | }); | ||
packages/client-runtime/src/knownEnvironment.ts deleted-53| ... | @@ -1,53 +0,0 @@ | ||
| 1 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface KnownEnvironmentConnectionTarget { | ||
| 4 | readonly httpBaseUrl: string; | ||
| 5 | readonly wsBaseUrl: string; | ||
| 6 | } | ||
| 7 | |||
| 8 | export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; | ||
| 9 | |||
| 10 | export interface KnownEnvironment { | ||
| 11 | readonly id: string; | ||
| 12 | readonly label: string; | ||
| 13 | readonly source: KnownEnvironmentSource; | ||
| 14 | readonly environmentId?: EnvironmentId; | ||
| 15 | readonly target: KnownEnvironmentConnectionTarget; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function createKnownEnvironment(input: { | ||
| 19 | readonly id?: string; | ||
| 20 | readonly label: string; | ||
| 21 | readonly source?: KnownEnvironmentSource; | ||
| 22 | readonly target: KnownEnvironmentConnectionTarget; | ||
| 23 | }): KnownEnvironment { | ||
| 24 | return { | ||
| 25 | id: input.id ?? `ws:${input.label}`, | ||
| 26 | label: input.label, | ||
| 27 | source: input.source ?? "manual", | ||
| 28 | target: input.target, | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | export function getKnownEnvironmentWsBaseUrl( | ||
| 33 | environment: KnownEnvironment | null | undefined, | ||
| 34 | ): string | null { | ||
| 35 | return environment?.target.wsBaseUrl ?? null; | ||
| 36 | } | ||
| 37 | |||
| 38 | export function getKnownEnvironmentHttpBaseUrl( | ||
| 39 | environment: KnownEnvironment | null | undefined, | ||
| 40 | ): string | null { | ||
| 41 | return environment?.target.httpBaseUrl ?? null; | ||
| 42 | } | ||
| 43 | |||
| 44 | export function attachEnvironmentDescriptor( | ||
| 45 | environment: KnownEnvironment, | ||
| 46 | descriptor: ExecutionEnvironmentDescriptor, | ||
| 47 | ): KnownEnvironment { | ||
| 48 | return { | ||
| 49 | ...environment, | ||
| 50 | environmentId: descriptor.environmentId, | ||
| 51 | label: descriptor.label, | ||
| 52 | }; | ||
| 53 | } | ||
packages/client-runtime/src/managedRelay.test.ts deleted-185| ... | @@ -1,185 +0,0 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as Duration from "effect/Duration"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as TestClock from "effect/testing/TestClock"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | MANAGED_RELAY_REQUEST_TIMEOUT_MS, | ||
| 12 | ManagedRelayClient, | ||
| 13 | ManagedRelayDpopSigner, | ||
| 14 | managedRelayClientLayer, | ||
| 15 | type ManagedRelayDpopProofInput, | ||
| 16 | } from "./managedRelay.ts"; | ||
| 17 | import { remoteHttpClientLayer } from "./remote.ts"; | ||
| 18 | |||
| 19 | function managedRelayTestLayer( | ||
| 20 | fetchFn: typeof globalThis.fetch, | ||
| 21 | relayUrl = "https://relay.example.test", | ||
| 22 | ) { | ||
| 23 | const httpClientLayer = remoteHttpClientLayer(fetchFn); | ||
| 24 | const signerLayer = Layer.succeed( | ||
| 25 | ManagedRelayDpopSigner, | ||
| 26 | ManagedRelayDpopSigner.of({ | ||
| 27 | thumbprint: Effect.succeed("client-thumbprint"), | ||
| 28 | createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), | ||
| 29 | }), | ||
| 30 | ); | ||
| 31 | return managedRelayClientLayer({ | ||
| 32 | relayUrl, | ||
| 33 | clientId: "t3-mobile", | ||
| 34 | }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); | ||
| 35 | } | ||
| 36 | |||
| 37 | describe("ManagedRelayClient", () => { | ||
| 38 | it.effect("rejects unsafe relay URLs before sending credentials", () => { | ||
| 39 | let requestCount = 0; | ||
| 40 | const fetchFn = (() => { | ||
| 41 | requestCount += 1; | ||
| 42 | return Promise.resolve(Response.json({})); | ||
| 43 | }) satisfies typeof globalThis.fetch; | ||
| 44 | |||
| 45 | return Effect.gen(function* () { | ||
| 46 | const relayClient = yield* ManagedRelayClient; | ||
| 47 | const error = yield* relayClient | ||
| 48 | .listEnvironments({ clerkToken: "clerk-token" }) | ||
| 49 | .pipe(Effect.flip); | ||
| 50 | |||
| 51 | expect(error).toMatchObject({ | ||
| 52 | _tag: "ManagedRelayClientError", | ||
| 53 | message: "Relay URL must be a secure absolute HTTPS origin.", | ||
| 54 | }); | ||
| 55 | expect(requestCount).toBe(0); | ||
| 56 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { | ||
| 60 | let tokenExchangeCount = 0; | ||
| 61 | const fetchFn = ((input) => { | ||
| 62 | const url = String(input); | ||
| 63 | if (url.endsWith("/v1/client/dpop-token")) { | ||
| 64 | tokenExchangeCount += 1; | ||
| 65 | return Promise.resolve( | ||
| 66 | Response.json({ | ||
| 67 | access_token: `relay-token-${tokenExchangeCount}`, | ||
| 68 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 69 | token_type: "DPoP", | ||
| 70 | expires_in: 10, | ||
| 71 | scope: RelayEnvironmentStatusScope, | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | return Promise.resolve( | ||
| 76 | Response.json({ | ||
| 77 | environmentId: "env-1", | ||
| 78 | endpoint: { | ||
| 79 | httpBaseUrl: "https://desktop.example.test/", | ||
| 80 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 81 | providerKind: "cloudflare_tunnel", | ||
| 82 | }, | ||
| 83 | status: "online", | ||
| 84 | checkedAt: "2026-05-25T00:01:00.000Z", | ||
| 85 | descriptor: { | ||
| 86 | environmentId: "env-1", | ||
| 87 | label: "Desktop", | ||
| 88 | platform: { os: "darwin", arch: "arm64" }, | ||
| 89 | serverVersion: "0.0.0-test", | ||
| 90 | capabilities: { repositoryIdentity: true }, | ||
| 91 | }, | ||
| 92 | }), | ||
| 93 | ); | ||
| 94 | }) satisfies typeof globalThis.fetch; | ||
| 95 | |||
| 96 | return Effect.gen(function* () { | ||
| 97 | const relayClient = yield* ManagedRelayClient; | ||
| 98 | const statusInput = { | ||
| 99 | clerkToken: "clerk-token", | ||
| 100 | scopes: [RelayEnvironmentStatusScope], | ||
| 101 | environmentId: EnvironmentId.make("env-1"), | ||
| 102 | } as const; | ||
| 103 | |||
| 104 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 105 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 106 | expect(tokenExchangeCount).toBe(1); | ||
| 107 | |||
| 108 | yield* TestClock.adjust(Duration.seconds(6)); | ||
| 109 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 110 | expect(tokenExchangeCount).toBe(2); | ||
| 111 | |||
| 112 | yield* relayClient.resetTokenCache; | ||
| 113 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 114 | expect(tokenExchangeCount).toBe(3); | ||
| 115 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it.effect("times out stalled relay environment listing requests", () => { | ||
| 119 | const fetchFn = (() => | ||
| 120 | new Promise<Response>(() => undefined)) satisfies typeof globalThis.fetch; | ||
| 121 | |||
| 122 | return Effect.gen(function* () { | ||
| 123 | const relayClient = yield* ManagedRelayClient; | ||
| 124 | const errorFiber = yield* relayClient | ||
| 125 | .listEnvironments({ clerkToken: "clerk-token" }) | ||
| 126 | .pipe(Effect.flip, Effect.forkScoped); | ||
| 127 | |||
| 128 | yield* Effect.yieldNow; | ||
| 129 | yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); | ||
| 130 | const error = yield* Fiber.join(errorFiber); | ||
| 131 | |||
| 132 | expect(error).toMatchObject({ | ||
| 133 | _tag: "ManagedRelayClientError", | ||
| 134 | message: "Relay environment listing timed out.", | ||
| 135 | }); | ||
| 136 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it.effect("lists account devices through the Clerk bearer client endpoint", () => { | ||
| 140 | const fetchFn = ((input, init) => { | ||
| 141 | expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); | ||
| 142 | expect(init?.headers).toMatchObject({ | ||
| 143 | authorization: "Bearer clerk-token", | ||
| 144 | }); | ||
| 145 | return Promise.resolve( | ||
| 146 | Response.json({ | ||
| 147 | devices: [ | ||
| 148 | { | ||
| 149 | deviceId: "device-1", | ||
| 150 | label: "Julius's iPhone", | ||
| 151 | platform: "ios", | ||
| 152 | iosMajorVersion: 18, | ||
| 153 | appVersion: "1.0.0", | ||
| 154 | notifications: { | ||
| 155 | enabled: false, | ||
| 156 | notifyOnApproval: true, | ||
| 157 | notifyOnInput: true, | ||
| 158 | notifyOnCompletion: true, | ||
| 159 | notifyOnFailure: true, | ||
| 160 | }, | ||
| 161 | liveActivities: { | ||
| 162 | enabled: true, | ||
| 163 | }, | ||
| 164 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 165 | }, | ||
| 166 | ], | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | }) satisfies typeof globalThis.fetch; | ||
| 170 | |||
| 171 | return Effect.gen(function* () { | ||
| 172 | const relayClient = yield* ManagedRelayClient; | ||
| 173 | const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); | ||
| 174 | expect(devices).toMatchObject([ | ||
| 175 | { | ||
| 176 | deviceId: "device-1", | ||
| 177 | label: "Julius's iPhone", | ||
| 178 | notifications: { | ||
| 179 | enabled: false, | ||
| 180 | }, | ||
| 181 | }, | ||
| 182 | ]); | ||
| 183 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); | ||
| 184 | }); | ||
| 185 | }); | ||
packages/client-runtime/src/managedRelay.ts deleted-516| ... | @@ -1,516 +0,0 @@ | ||
| 1 | import { | ||
| 2 | RelayAccessTokenType, | ||
| 3 | RelayApi, | ||
| 4 | type RelayClientEnvironmentRecord, | ||
| 5 | type RelayClientDeviceRecord, | ||
| 6 | RelayConnectEnvironmentEndpoint, | ||
| 7 | type RelayDeviceRegistrationRequest, | ||
| 8 | type RelayDpopAccessTokenScope, | ||
| 9 | RelayDpopTokenExchangeGrantType, | ||
| 10 | type RelayEnvironmentConnectRequest, | ||
| 11 | type RelayEnvironmentConnectResponse, | ||
| 12 | type RelayEnvironmentLinkChallengeRequest, | ||
| 13 | type RelayEnvironmentLinkChallengeResponse, | ||
| 14 | type RelayEnvironmentLinkRequest, | ||
| 15 | type RelayEnvironmentLinkResponse, | ||
| 16 | type RelayEnvironmentStatusResponse, | ||
| 17 | RelayExchangeDpopAccessTokenEndpoint, | ||
| 18 | RelayGetEnvironmentStatusEndpoint, | ||
| 19 | RelayJwtSubjectTokenType, | ||
| 20 | type RelayLiveActivityRegistrationRequest, | ||
| 21 | RelayMobileRegistrationScope, | ||
| 22 | type RelayOkResponse, | ||
| 23 | type RelayPublicClientId, | ||
| 24 | RelayRegisterDeviceEndpoint, | ||
| 25 | RelayRegisterLiveActivityEndpoint, | ||
| 26 | RelayUnregisterDeviceEndpoint, | ||
| 27 | } from "@t3tools/contracts/relay"; | ||
| 28 | import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; | ||
| 29 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 30 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 31 | import * as Clock from "effect/Clock"; | ||
| 32 | import * as Context from "effect/Context"; | ||
| 33 | import * as Data from "effect/Data"; | ||
| 34 | import * as Duration from "effect/Duration"; | ||
| 35 | import * as Effect from "effect/Effect"; | ||
| 36 | import * as Layer from "effect/Layer"; | ||
| 37 | import * as Option from "effect/Option"; | ||
| 38 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 39 | import type { HttpMethod } from "effect/unstable/http/HttpMethod"; | ||
| 40 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | ||
| 41 | |||
| 42 | export interface ManagedRelayDpopProofInput { | ||
| 43 | readonly method: HttpMethod; | ||
| 44 | readonly url: string; | ||
| 45 | readonly accessToken?: string; | ||
| 46 | } | ||
| 47 | |||
| 48 | export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ | ||
| 49 | readonly cause: unknown; | ||
| 50 | }> {} | ||
| 51 | |||
| 52 | export interface ManagedRelayDpopSignerShape { | ||
| 53 | readonly thumbprint: Effect.Effect<string, ManagedRelayDpopSignerError>; | ||
| 54 | readonly createProof: ( | ||
| 55 | input: ManagedRelayDpopProofInput, | ||
| 56 | ) => Effect.Effect<string, ManagedRelayDpopSignerError>; | ||
| 57 | } | ||
| 58 | |||
| 59 | export class ManagedRelayDpopSigner extends Context.Service< | ||
| 60 | ManagedRelayDpopSigner, | ||
| 61 | ManagedRelayDpopSignerShape | ||
| 62 | >()("@t3tools/client-runtime/managedRelay/ManagedRelayDpopSigner") {} | ||
| 63 | |||
| 64 | export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ | ||
| 65 | readonly message: string; | ||
| 66 | readonly cause?: unknown; | ||
| 67 | }> {} | ||
| 68 | |||
| 69 | export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; | ||
| 70 | |||
| 71 | interface CachedRelayAccessToken { | ||
| 72 | readonly clerkToken: string; | ||
| 73 | readonly thumbprint: string; | ||
| 74 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 75 | readonly accessToken: string; | ||
| 76 | readonly expiresAtMillis: number; | ||
| 77 | } | ||
| 78 | |||
| 79 | export interface ManagedRelayAuthorization { | ||
| 80 | readonly accessToken: string; | ||
| 81 | readonly proof: string; | ||
| 82 | readonly thumbprint: string; | ||
| 83 | } | ||
| 84 | |||
| 85 | export interface ManagedRelayClientLayerOptions { | ||
| 86 | readonly relayUrl: string; | ||
| 87 | readonly clientId: RelayPublicClientId; | ||
| 88 | } | ||
| 89 | |||
| 90 | export interface ManagedRelayClientShape { | ||
| 91 | readonly relayUrl: string; | ||
| 92 | readonly listEnvironments: (input: { | ||
| 93 | readonly clerkToken: string; | ||
| 94 | }) => Effect.Effect<ReadonlyArray<RelayClientEnvironmentRecord>, ManagedRelayClientError>; | ||
| 95 | readonly listDevices: (input: { | ||
| 96 | readonly clerkToken: string; | ||
| 97 | }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, ManagedRelayClientError>; | ||
| 98 | readonly createEnvironmentLinkChallenge: (input: { | ||
| 99 | readonly clerkToken: string; | ||
| 100 | readonly payload: RelayEnvironmentLinkChallengeRequest; | ||
| 101 | }) => Effect.Effect<RelayEnvironmentLinkChallengeResponse, ManagedRelayClientError>; | ||
| 102 | readonly linkEnvironment: (input: { | ||
| 103 | readonly clerkToken: string; | ||
| 104 | readonly payload: RelayEnvironmentLinkRequest; | ||
| 105 | }) => Effect.Effect<RelayEnvironmentLinkResponse, ManagedRelayClientError>; | ||
| 106 | readonly unlinkEnvironment: (input: { | ||
| 107 | readonly clerkToken: string; | ||
| 108 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 109 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 110 | readonly getEnvironmentStatus: (input: { | ||
| 111 | readonly clerkToken: string; | ||
| 112 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 113 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 114 | }) => Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelayClientError>; | ||
| 115 | readonly connectEnvironment: (input: { | ||
| 116 | readonly clerkToken: string; | ||
| 117 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 118 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 119 | readonly deviceId?: string; | ||
| 120 | }) => Effect.Effect<RelayEnvironmentConnectResponse, ManagedRelayClientError>; | ||
| 121 | readonly registerDevice: (input: { | ||
| 122 | readonly clerkToken: string; | ||
| 123 | readonly payload: RelayDeviceRegistrationRequest; | ||
| 124 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 125 | readonly unregisterDevice: (input: { | ||
| 126 | readonly clerkToken: string; | ||
| 127 | readonly deviceId: string; | ||
| 128 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 129 | readonly registerLiveActivity: (input: { | ||
| 130 | readonly clerkToken: string; | ||
| 131 | readonly payload: RelayLiveActivityRegistrationRequest; | ||
| 132 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 133 | readonly resetTokenCache: Effect.Effect<void>; | ||
| 134 | } | ||
| 135 | |||
| 136 | export class ManagedRelayClient extends Context.Service< | ||
| 137 | ManagedRelayClient, | ||
| 138 | ManagedRelayClientShape | ||
| 139 | >()("@t3tools/client-runtime/managedRelay/ManagedRelayClient") {} | ||
| 140 | |||
| 141 | function relayClientError(message: string, cause?: unknown): ManagedRelayClientError { | ||
| 142 | return new ManagedRelayClientError({ message, ...(cause === undefined ? {} : { cause }) }); | ||
| 143 | } | ||
| 144 | |||
| 145 | function timeoutRelayRequest(message: string) { | ||
| 146 | return <A, E, R>( | ||
| 147 | request: Effect.Effect<A, E, R>, | ||
| 148 | ): Effect.Effect<A, E | ManagedRelayClientError, R> => | ||
| 149 | request.pipe( | ||
| 150 | Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), | ||
| 151 | Effect.flatMap( | ||
| 152 | Option.match({ | ||
| 153 | onNone: () => Effect.fail(relayClientError(message)), | ||
| 154 | onSome: Effect.succeed, | ||
| 155 | }), | ||
| 156 | ), | ||
| 157 | ); | ||
| 158 | } | ||
| 159 | |||
| 160 | function tokenMatches( | ||
| 161 | token: CachedRelayAccessToken, | ||
| 162 | input: { | ||
| 163 | readonly clerkToken: string; | ||
| 164 | readonly thumbprint: string; | ||
| 165 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 166 | readonly nowMillis: number; | ||
| 167 | }, | ||
| 168 | ): boolean { | ||
| 169 | return ( | ||
| 170 | token.clerkToken === input.clerkToken && | ||
| 171 | token.thumbprint === input.thumbprint && | ||
| 172 | token.expiresAtMillis > input.nowMillis + 5_000 && | ||
| 173 | input.scopes.every((scope) => token.scopes.includes(scope)) | ||
| 174 | ); | ||
| 175 | } | ||
| 176 | |||
| 177 | function bearerHeaders(clerkToken: string) { | ||
| 178 | return { authorization: `Bearer ${clerkToken}` }; | ||
| 179 | } | ||
| 180 | |||
| 181 | function dpopHeaders(authorization: ManagedRelayAuthorization) { | ||
| 182 | return { | ||
| 183 | authorization: `DPoP ${authorization.accessToken}`, | ||
| 184 | dpop: authorization.proof, | ||
| 185 | }; | ||
| 186 | } | ||
| 187 | |||
| 188 | function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { | ||
| 189 | const unavailable = () => | ||
| 190 | Effect.fail(relayClientError("Relay URL must be a secure absolute HTTPS origin.")); | ||
| 191 | return ManagedRelayClient.of({ | ||
| 192 | relayUrl, | ||
| 193 | listEnvironments: unavailable, | ||
| 194 | listDevices: unavailable, | ||
| 195 | createEnvironmentLinkChallenge: unavailable, | ||
| 196 | linkEnvironment: unavailable, | ||
| 197 | unlinkEnvironment: unavailable, | ||
| 198 | getEnvironmentStatus: unavailable, | ||
| 199 | connectEnvironment: unavailable, | ||
| 200 | registerDevice: unavailable, | ||
| 201 | unregisterDevice: unavailable, | ||
| 202 | registerLiveActivity: unavailable, | ||
| 203 | resetTokenCache: Effect.void, | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | |||
| 207 | export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { | ||
| 208 | return Layer.effect( | ||
| 209 | ManagedRelayClient, | ||
| 210 | Effect.gen(function* () { | ||
| 211 | const relayUrl = normalizeSecureRelayUrl(options.relayUrl); | ||
| 212 | if (relayUrl === null) { | ||
| 213 | return disabledManagedRelayClient(options.relayUrl); | ||
| 214 | } | ||
| 215 | const signer = yield* ManagedRelayDpopSigner; | ||
| 216 | const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); | ||
| 217 | const cachedTokens = yield* SynchronizedRef.make<ReadonlyArray<CachedRelayAccessToken>>([]); | ||
| 218 | const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); | ||
| 219 | |||
| 220 | type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">; | ||
| 221 | const dpopProofTargets = { | ||
| 222 | exchangeAccessToken: (): DpopProofTarget => ({ | ||
| 223 | method: RelayExchangeDpopAccessTokenEndpoint.method, | ||
| 224 | url: urlBuilder.token.exchangeDpopAccessToken(), | ||
| 225 | }), | ||
| 226 | getEnvironmentStatus: ( | ||
| 227 | environmentId: RelayClientEnvironmentRecord["environmentId"], | ||
| 228 | ): DpopProofTarget => ({ | ||
| 229 | method: RelayGetEnvironmentStatusEndpoint.method, | ||
| 230 | url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), | ||
| 231 | }), | ||
| 232 | connectEnvironment: ( | ||
| 233 | environmentId: RelayClientEnvironmentRecord["environmentId"], | ||
| 234 | ): DpopProofTarget => ({ | ||
| 235 | method: RelayConnectEnvironmentEndpoint.method, | ||
| 236 | url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), | ||
| 237 | }), | ||
| 238 | registerDevice: (): DpopProofTarget => ({ | ||
| 239 | method: RelayRegisterDeviceEndpoint.method, | ||
| 240 | url: urlBuilder.mobile.registerDevice(), | ||
| 241 | }), | ||
| 242 | unregisterDevice: (deviceId: string): DpopProofTarget => ({ | ||
| 243 | method: RelayUnregisterDeviceEndpoint.method, | ||
| 244 | url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), | ||
| 245 | }), | ||
| 246 | registerLiveActivity: (): DpopProofTarget => ({ | ||
| 247 | method: RelayRegisterLiveActivityEndpoint.method, | ||
| 248 | url: urlBuilder.mobile.registerLiveActivity(), | ||
| 249 | }), | ||
| 250 | }; | ||
| 251 | |||
| 252 | const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( | ||
| 253 | function* (input: { | ||
| 254 | readonly clerkToken: string; | ||
| 255 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 256 | readonly thumbprint: string; | ||
| 257 | }) { | ||
| 258 | const nowMillis = yield* Clock.currentTimeMillis; | ||
| 259 | return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { | ||
| 260 | const activeTokens = tokens.filter( | ||
| 261 | (token) => token.expiresAtMillis > nowMillis + 5_000, | ||
| 262 | ); | ||
| 263 | const cached = activeTokens.find((token) => | ||
| 264 | tokenMatches(token, { ...input, nowMillis }), | ||
| 265 | ); | ||
| 266 | if (cached) { | ||
| 267 | return Effect.succeed([cached, activeTokens] as const); | ||
| 268 | } | ||
| 269 | return Effect.gen(function* () { | ||
| 270 | const proof = yield* signer | ||
| 271 | .createProof(dpopProofTargets.exchangeAccessToken()) | ||
| 272 | .pipe( | ||
| 273 | Effect.mapError((cause) => | ||
| 274 | relayClientError("Could not create relay token DPoP proof.", cause), | ||
| 275 | ), | ||
| 276 | ); | ||
| 277 | const response = yield* client.token | ||
| 278 | .exchangeDpopAccessToken({ | ||
| 279 | headers: { dpop: proof }, | ||
| 280 | payload: { | ||
| 281 | grant_type: RelayDpopTokenExchangeGrantType, | ||
| 282 | subject_token: input.clerkToken, | ||
| 283 | subject_token_type: RelayJwtSubjectTokenType, | ||
| 284 | requested_token_type: RelayAccessTokenType, | ||
| 285 | resource: relayUrl, | ||
| 286 | scope: encodeOAuthScope(input.scopes), | ||
| 287 | client_id: options.clientId, | ||
| 288 | }, | ||
| 289 | }) | ||
| 290 | .pipe( | ||
| 291 | Effect.mapError((cause) => | ||
| 292 | relayClientError("Could not exchange relay DPoP access token.", cause), | ||
| 293 | ), | ||
| 294 | timeoutRelayRequest("Relay DPoP access token exchange timed out."), | ||
| 295 | ); | ||
| 296 | if (!oauthScopeSetEquals(response.scope, input.scopes)) { | ||
| 297 | return yield* relayClientError( | ||
| 298 | "Relay granted unexpected DPoP access token scopes.", | ||
| 299 | ); | ||
| 300 | } | ||
| 301 | const next: CachedRelayAccessToken = { | ||
| 302 | clerkToken: input.clerkToken, | ||
| 303 | thumbprint: input.thumbprint, | ||
| 304 | scopes: input.scopes, | ||
| 305 | accessToken: response.access_token, | ||
| 306 | expiresAtMillis: nowMillis + response.expires_in * 1_000, | ||
| 307 | }; | ||
| 308 | return [next, [...activeTokens, next]] as const; | ||
| 309 | }); | ||
| 310 | }); | ||
| 311 | }, | ||
| 312 | ); | ||
| 313 | |||
| 314 | const authorize = (input: { | ||
| 315 | readonly clerkToken: string; | ||
| 316 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 317 | readonly target: DpopProofTarget; | ||
| 318 | }) => | ||
| 319 | Effect.gen(function* () { | ||
| 320 | const thumbprint = yield* signer.thumbprint.pipe( | ||
| 321 | Effect.mapError((cause) => | ||
| 322 | relayClientError("Could not load relay DPoP proof key.", cause), | ||
| 323 | ), | ||
| 324 | ); | ||
| 325 | const token = yield* obtainAccessToken({ | ||
| 326 | clerkToken: input.clerkToken, | ||
| 327 | scopes: input.scopes, | ||
| 328 | thumbprint, | ||
| 329 | }); | ||
| 330 | const proof = yield* signer | ||
| 331 | .createProof({ | ||
| 332 | ...input.target, | ||
| 333 | accessToken: token.accessToken, | ||
| 334 | }) | ||
| 335 | .pipe( | ||
| 336 | Effect.mapError((cause) => | ||
| 337 | relayClientError("Could not create relay request DPoP proof.", cause), | ||
| 338 | ), | ||
| 339 | ); | ||
| 340 | return { accessToken: token.accessToken, proof, thumbprint }; | ||
| 341 | }); | ||
| 342 | |||
| 343 | const authorizeMobileRegistration = (input: { | ||
| 344 | readonly clerkToken: string; | ||
| 345 | readonly target: DpopProofTarget; | ||
| 346 | }) => | ||
| 347 | authorize({ | ||
| 348 | ...input, | ||
| 349 | scopes: [RelayMobileRegistrationScope], | ||
| 350 | }); | ||
| 351 | |||
| 352 | return ManagedRelayClient.of({ | ||
| 353 | relayUrl, | ||
| 354 | listEnvironments: (input) => | ||
| 355 | client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe( | ||
| 356 | Effect.map((response) => response.environments), | ||
| 357 | Effect.mapError((cause) => | ||
| 358 | relayClientError("Could not list relay-managed environments.", cause), | ||
| 359 | ), | ||
| 360 | timeoutRelayRequest("Relay environment listing timed out."), | ||
| 361 | withRelayClientTracing, | ||
| 362 | ), | ||
| 363 | listDevices: (input) => | ||
| 364 | client.client | ||
| 365 | .listDevices({ | ||
| 366 | headers: bearerHeaders(input.clerkToken), | ||
| 367 | }) | ||
| 368 | .pipe( | ||
| 369 | Effect.map((response) => response.devices), | ||
| 370 | Effect.mapError((cause) => | ||
| 371 | relayClientError("Could not list relay client devices.", cause), | ||
| 372 | ), | ||
| 373 | timeoutRelayRequest("Relay client device listing timed out."), | ||
| 374 | withRelayClientTracing, | ||
| 375 | ), | ||
| 376 | createEnvironmentLinkChallenge: (input) => | ||
| 377 | client.client | ||
| 378 | .createEnvironmentLinkChallenge({ | ||
| 379 | headers: bearerHeaders(input.clerkToken), | ||
| 380 | payload: input.payload, | ||
| 381 | }) | ||
| 382 | .pipe( | ||
| 383 | Effect.mapError((cause) => | ||
| 384 | relayClientError("Could not create relay environment link challenge.", cause), | ||
| 385 | ), | ||
| 386 | timeoutRelayRequest("Relay environment link challenge timed out."), | ||
| 387 | withRelayClientTracing, | ||
| 388 | ), | ||
| 389 | linkEnvironment: (input) => | ||
| 390 | client.client | ||
| 391 | .linkEnvironment({ | ||
| 392 | headers: bearerHeaders(input.clerkToken), | ||
| 393 | payload: input.payload, | ||
| 394 | }) | ||
| 395 | .pipe( | ||
| 396 | Effect.mapError((cause) => | ||
| 397 | relayClientError("Could not link relay environment.", cause), | ||
| 398 | ), | ||
| 399 | timeoutRelayRequest("Relay environment linking timed out."), | ||
| 400 | withRelayClientTracing, | ||
| 401 | ), | ||
| 402 | unlinkEnvironment: (input) => | ||
| 403 | client.client | ||
| 404 | .unlinkEnvironment({ | ||
| 405 | headers: bearerHeaders(input.clerkToken), | ||
| 406 | params: { environmentId: input.environmentId }, | ||
| 407 | }) | ||
| 408 | .pipe( | ||
| 409 | Effect.mapError((cause) => | ||
| 410 | relayClientError("Could not unlink relay environment.", cause), | ||
| 411 | ), | ||
| 412 | timeoutRelayRequest("Relay environment unlinking timed out."), | ||
| 413 | withRelayClientTracing, | ||
| 414 | ), | ||
| 415 | getEnvironmentStatus: (input) => | ||
| 416 | Effect.gen(function* () { | ||
| 417 | const authorization = yield* authorize({ | ||
| 418 | clerkToken: input.clerkToken, | ||
| 419 | scopes: input.scopes, | ||
| 420 | target: dpopProofTargets.getEnvironmentStatus(input.environmentId), | ||
| 421 | }); | ||
| 422 | return yield* client.dpopClient | ||
| 423 | .getEnvironmentStatus({ | ||
| 424 | headers: dpopHeaders(authorization), | ||
| 425 | params: { environmentId: input.environmentId }, | ||
| 426 | }) | ||
| 427 | .pipe( | ||
| 428 | Effect.mapError((cause) => | ||
| 429 | relayClientError("Could not get relay environment status.", cause), | ||
| 430 | ), | ||
| 431 | timeoutRelayRequest("Relay environment status request timed out."), | ||
| 432 | ); | ||
| 433 | }).pipe(withRelayClientTracing), | ||
| 434 | connectEnvironment: (input) => | ||
| 435 | Effect.gen(function* () { | ||
| 436 | const authorization = yield* authorize({ | ||
| 437 | clerkToken: input.clerkToken, | ||
| 438 | scopes: input.scopes, | ||
| 439 | target: dpopProofTargets.connectEnvironment(input.environmentId), | ||
| 440 | }); | ||
| 441 | const payload: RelayEnvironmentConnectRequest = { | ||
| 442 | ...(input.deviceId ? { deviceId: input.deviceId } : {}), | ||
| 443 | clientKeyThumbprint: authorization.thumbprint, | ||
| 444 | }; | ||
| 445 | return yield* client.dpopClient | ||
| 446 | .connectEnvironment({ | ||
| 447 | headers: dpopHeaders(authorization), | ||
| 448 | params: { environmentId: input.environmentId }, | ||
| 449 | payload, | ||
| 450 | }) | ||
| 451 | .pipe( | ||
| 452 | Effect.mapError((cause) => | ||
| 453 | relayClientError("Could not connect relay environment.", cause), | ||
| 454 | ), | ||
| 455 | timeoutRelayRequest("Relay environment connection timed out."), | ||
| 456 | ); | ||
| 457 | }).pipe(withRelayClientTracing), | ||
| 458 | registerDevice: (input) => | ||
| 459 | Effect.gen(function* () { | ||
| 460 | const authorization = yield* authorizeMobileRegistration({ | ||
| 461 | clerkToken: input.clerkToken, | ||
| 462 | target: dpopProofTargets.registerDevice(), | ||
| 463 | }); | ||
| 464 | return yield* client.mobile | ||
| 465 | .registerDevice({ | ||
| 466 | headers: dpopHeaders(authorization), | ||
| 467 | payload: input.payload, | ||
| 468 | }) | ||
| 469 | .pipe( | ||
| 470 | Effect.mapError((cause) => | ||
| 471 | relayClientError("Could not register relay mobile device.", cause), | ||
| 472 | ), | ||
| 473 | timeoutRelayRequest("Relay mobile device registration timed out."), | ||
| 474 | ); | ||
| 475 | }).pipe(withRelayClientTracing), | ||
| 476 | unregisterDevice: (input) => | ||
| 477 | Effect.gen(function* () { | ||
| 478 | const authorization = yield* authorizeMobileRegistration({ | ||
| 479 | clerkToken: input.clerkToken, | ||
| 480 | target: dpopProofTargets.unregisterDevice(input.deviceId), | ||
| 481 | }); | ||
| 482 | return yield* client.mobile | ||
| 483 | .unregisterDevice({ | ||
| 484 | headers: dpopHeaders(authorization), | ||
| 485 | params: { deviceId: input.deviceId }, | ||
| 486 | }) | ||
| 487 | .pipe( | ||
| 488 | Effect.mapError((cause) => | ||
| 489 | relayClientError("Could not unregister relay mobile device.", cause), | ||
| 490 | ), | ||
| 491 | timeoutRelayRequest("Relay mobile device unregistration timed out."), | ||
| 492 | ); | ||
| 493 | }).pipe(withRelayClientTracing), | ||
| 494 | registerLiveActivity: (input) => | ||
| 495 | Effect.gen(function* () { | ||
| 496 | const authorization = yield* authorizeMobileRegistration({ | ||
| 497 | clerkToken: input.clerkToken, | ||
| 498 | target: dpopProofTargets.registerLiveActivity(), | ||
| 499 | }); | ||
| 500 | return yield* client.mobile | ||
| 501 | .registerLiveActivity({ | ||
| 502 | headers: dpopHeaders(authorization), | ||
| 503 | payload: input.payload, | ||
| 504 | }) | ||
| 505 | .pipe( | ||
| 506 | Effect.mapError((cause) => | ||
| 507 | relayClientError("Could not register relay live activity.", cause), | ||
| 508 | ), | ||
| 509 | timeoutRelayRequest("Relay Live Activity registration timed out."), | ||
| 510 | ); | ||
| 511 | }).pipe(withRelayClientTracing), | ||
| 512 | resetTokenCache: SynchronizedRef.set(cachedTokens, []), | ||
| 513 | }); | ||
| 514 | }), | ||
| 515 | ); | ||
| 516 | } | ||
packages/client-runtime/src/managedRelayState.test.ts deleted-155| ... | @@ -1,155 +0,0 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { | ||
| 3 | RelayClientDeviceRecord, | ||
| 4 | RelayClientEnvironmentRecord, | ||
| 5 | RelayEnvironmentStatusResponse, | ||
| 6 | } from "@t3tools/contracts/relay"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 10 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 11 | |||
| 12 | import { ManagedRelayClient, type ManagedRelayClientShape } from "./managedRelay.ts"; | ||
| 13 | import { | ||
| 14 | createManagedRelayQueryManager, | ||
| 15 | createManagedRelaySession, | ||
| 16 | managedRelaySessionAtom, | ||
| 17 | readManagedRelaySnapshotState, | ||
| 18 | setManagedRelaySession, | ||
| 19 | waitForManagedRelayClerkToken, | ||
| 20 | } from "./managedRelayState.ts"; | ||
| 21 | |||
| 22 | let registry = AtomRegistry.make(); | ||
| 23 | |||
| 24 | const environment = { | ||
| 25 | environmentId: EnvironmentId.make("environment-1"), | ||
| 26 | label: "Main environment", | ||
| 27 | endpoint: { | ||
| 28 | httpBaseUrl: "https://environment.example.test", | ||
| 29 | wsBaseUrl: "wss://environment.example.test", | ||
| 30 | providerKind: "cloudflare_tunnel", | ||
| 31 | }, | ||
| 32 | linkedAt: "2026-06-01T00:00:00.000Z", | ||
| 33 | } satisfies RelayClientEnvironmentRecord; | ||
| 34 | |||
| 35 | const device = { | ||
| 36 | deviceId: "device-1", | ||
| 37 | label: "Julius iPhone", | ||
| 38 | platform: "ios", | ||
| 39 | iosMajorVersion: 18, | ||
| 40 | appVersion: null, | ||
| 41 | notifications: { | ||
| 42 | enabled: true, | ||
| 43 | notifyOnApproval: true, | ||
| 44 | notifyOnInput: true, | ||
| 45 | notifyOnCompletion: true, | ||
| 46 | notifyOnFailure: true, | ||
| 47 | }, | ||
| 48 | liveActivities: { | ||
| 49 | enabled: true, | ||
| 50 | }, | ||
| 51 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 52 | } satisfies RelayClientDeviceRecord; | ||
| 53 | |||
| 54 | function resetRegistry() { | ||
| 55 | registry.dispose(); | ||
| 56 | registry = AtomRegistry.make(); | ||
| 57 | } | ||
| 58 | |||
| 59 | function createManager(overrides?: Partial<ManagedRelayClientShape>) { | ||
| 60 | const client = ManagedRelayClient.of({ | ||
| 61 | relayUrl: "https://relay.example.test", | ||
| 62 | listEnvironments: () => Effect.succeed([environment]), | ||
| 63 | listDevices: () => Effect.succeed([device]), | ||
| 64 | createEnvironmentLinkChallenge: () => Effect.die("unused"), | ||
| 65 | linkEnvironment: () => Effect.die("unused"), | ||
| 66 | unlinkEnvironment: () => Effect.die("unused"), | ||
| 67 | getEnvironmentStatus: () => | ||
| 68 | Effect.succeed({ | ||
| 69 | environmentId: environment.environmentId, | ||
| 70 | endpoint: environment.endpoint, | ||
| 71 | status: "online", | ||
| 72 | checkedAt: "2026-06-01T00:00:00.000Z", | ||
| 73 | }), | ||
| 74 | connectEnvironment: () => Effect.die("unused"), | ||
| 75 | registerDevice: () => Effect.die("unused"), | ||
| 76 | unregisterDevice: () => Effect.die("unused"), | ||
| 77 | registerLiveActivity: () => Effect.die("unused"), | ||
| 78 | resetTokenCache: Effect.void, | ||
| 79 | ...overrides, | ||
| 80 | }); | ||
| 81 | const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); | ||
| 82 | return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000 }); | ||
| 83 | } | ||
| 84 | |||
| 85 | function setSession() { | ||
| 86 | setManagedRelaySession( | ||
| 87 | registry, | ||
| 88 | createManagedRelaySession({ | ||
| 89 | accountId: "account-1", | ||
| 90 | readClerkToken: () => Promise.resolve("clerk-token"), | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | |||
| 95 | describe("createManagedRelayQueryManager", () => { | ||
| 96 | afterEach(resetRegistry); | ||
| 97 | |||
| 98 | it("waits for the current cloud session before reading its token", async () => { | ||
| 99 | const token = Effect.runPromise(waitForManagedRelayClerkToken(registry)); | ||
| 100 | |||
| 101 | setSession(); | ||
| 102 | |||
| 103 | await expect(token).resolves.toBe("clerk-token"); | ||
| 104 | expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("keeps environment snapshots cached and refreshes them explicitly", async () => { | ||
| 108 | const listEnvironments = vi.fn(() => Effect.succeed([environment])); | ||
| 109 | const manager = createManager({ listEnvironments }); | ||
| 110 | setSession(); | ||
| 111 | const atom = manager.environmentsAtom("account-1"); | ||
| 112 | |||
| 113 | registry.get(atom); | ||
| 114 | await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); | ||
| 115 | |||
| 116 | registry.get(manager.environmentsAtom("account-1")); | ||
| 117 | expect(listEnvironments).toHaveBeenCalledTimes(1); | ||
| 118 | |||
| 119 | manager.refreshEnvironments(registry, "account-1"); | ||
| 120 | await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it("loads device snapshots through the current account session", async () => { | ||
| 124 | const listDevices = vi.fn(() => Effect.succeed([device])); | ||
| 125 | const manager = createManager({ listDevices }); | ||
| 126 | setSession(); | ||
| 127 | const atom = manager.devicesAtom("account-1"); | ||
| 128 | |||
| 129 | registry.get(atom); | ||
| 130 | await vi.waitFor(() => { | ||
| 131 | expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); | ||
| 132 | }); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("rejects status responses for a different environment", async () => { | ||
| 136 | const mismatchedStatus = { | ||
| 137 | environmentId: EnvironmentId.make("environment-2"), | ||
| 138 | endpoint: environment.endpoint, | ||
| 139 | status: "online", | ||
| 140 | checkedAt: "2026-06-01T00:00:00.000Z", | ||
| 141 | } satisfies RelayEnvironmentStatusResponse; | ||
| 142 | const manager = createManager({ | ||
| 143 | getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), | ||
| 144 | }); | ||
| 145 | setSession(); | ||
| 146 | const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); | ||
| 147 | |||
| 148 | registry.get(atom); | ||
| 149 | await vi.waitFor(() => { | ||
| 150 | expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( | ||
| 151 | "Relay returned status for a different environment.", | ||
| 152 | ); | ||
| 153 | }); | ||
| 154 | }); | ||
| 155 | }); | ||
packages/client-runtime/src/managedRelayState.ts deleted-289| ... | @@ -1,289 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | RelayClientEnvironmentRecord, | ||
| 3 | RelayEnvironmentStatusResponse, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import { | ||
| 6 | RelayEnvironmentConnectScope, | ||
| 7 | RelayEnvironmentStatusScope, | ||
| 8 | } from "@t3tools/contracts/relay"; | ||
| 9 | import * as Cause from "effect/Cause"; | ||
| 10 | import * as Data from "effect/Data"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 14 | |||
| 15 | import { ManagedRelayClient } from "./managedRelay.ts"; | ||
| 16 | |||
| 17 | const DEFAULT_STALE_TIME_MS = 15_000; | ||
| 18 | const DEFAULT_IDLE_TTL_MS = 5 * 60_000; | ||
| 19 | |||
| 20 | export interface ManagedRelaySession { | ||
| 21 | readonly accountId: string; | ||
| 22 | readonly readClerkToken: () => Effect.Effect<string | null, ManagedRelaySessionError>; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface ManagedRelaySnapshotState<A> { | ||
| 26 | readonly data: A | null; | ||
| 27 | readonly error: string | null; | ||
| 28 | readonly isPending: boolean; | ||
| 29 | } | ||
| 30 | |||
| 31 | export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{ | ||
| 32 | readonly message: string; | ||
| 33 | readonly cause?: unknown; | ||
| 34 | }> {} | ||
| 35 | |||
| 36 | export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{ | ||
| 37 | readonly message: string; | ||
| 38 | }> {} | ||
| 39 | |||
| 40 | export const managedRelaySessionAtom = Atom.make<ManagedRelaySession | null>(null).pipe( | ||
| 41 | Atom.keepAlive, | ||
| 42 | Atom.withLabel("managed-relay:session"), | ||
| 43 | ); | ||
| 44 | |||
| 45 | export function createManagedRelaySession(input: { | ||
| 46 | readonly accountId: string; | ||
| 47 | readonly readClerkToken: () => Promise<string | null>; | ||
| 48 | }): ManagedRelaySession { | ||
| 49 | return { | ||
| 50 | accountId: input.accountId, | ||
| 51 | readClerkToken: () => | ||
| 52 | Effect.tryPromise({ | ||
| 53 | try: input.readClerkToken, | ||
| 54 | catch: (cause) => | ||
| 55 | new ManagedRelaySessionError({ | ||
| 56 | message: "Could not obtain the T3 Connect session token.", | ||
| 57 | cause, | ||
| 58 | }), | ||
| 59 | }), | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | export function setManagedRelaySession( | ||
| 64 | registry: AtomRegistry.AtomRegistry, | ||
| 65 | session: ManagedRelaySession | null, | ||
| 66 | ): void { | ||
| 67 | registry.set(managedRelaySessionAtom, session); | ||
| 68 | } | ||
| 69 | |||
| 70 | function readSessionClerkToken( | ||
| 71 | session: ManagedRelaySession, | ||
| 72 | ): Effect.Effect<string, ManagedRelaySessionError> { | ||
| 73 | return session.readClerkToken().pipe( | ||
| 74 | Effect.flatMap((token) => | ||
| 75 | token | ||
| 76 | ? Effect.succeed(token) | ||
| 77 | : Effect.fail( | ||
| 78 | new ManagedRelaySessionError({ | ||
| 79 | message: "The T3 Connect session token is unavailable.", | ||
| 80 | }), | ||
| 81 | ), | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | } | ||
| 85 | |||
| 86 | export function waitForManagedRelayClerkToken( | ||
| 87 | registry: AtomRegistry.AtomRegistry, | ||
| 88 | ): Effect.Effect<string, ManagedRelaySessionError> { | ||
| 89 | return Effect.callback<string, ManagedRelaySessionError>((resume) => { | ||
| 90 | let unsubscribe: (() => void) | undefined; | ||
| 91 | let completed = false; | ||
| 92 | const readCurrentSession = () => { | ||
| 93 | if (completed) { | ||
| 94 | return true; | ||
| 95 | } | ||
| 96 | const session = registry.get(managedRelaySessionAtom); | ||
| 97 | if (!session) { | ||
| 98 | return false; | ||
| 99 | } | ||
| 100 | completed = true; | ||
| 101 | unsubscribe?.(); | ||
| 102 | resume(readSessionClerkToken(session)); | ||
| 103 | return true; | ||
| 104 | }; | ||
| 105 | |||
| 106 | if (readCurrentSession()) { | ||
| 107 | return; | ||
| 108 | } | ||
| 109 | |||
| 110 | unsubscribe = registry.subscribe(managedRelaySessionAtom, readCurrentSession); | ||
| 111 | readCurrentSession(); | ||
| 112 | return Effect.sync(() => unsubscribe?.()); | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | |||
| 116 | function requireClerkToken( | ||
| 117 | get: Atom.AtomContext, | ||
| 118 | accountId: string, | ||
| 119 | ): Effect.Effect<string, ManagedRelaySessionError> { | ||
| 120 | const session = get(managedRelaySessionAtom); | ||
| 121 | if (!session || session.accountId !== accountId) { | ||
| 122 | return Effect.fail( | ||
| 123 | new ManagedRelaySessionError({ | ||
| 124 | message: "Sign in to T3 Connect before loading relay data.", | ||
| 125 | }), | ||
| 126 | ); | ||
| 127 | } | ||
| 128 | return readSessionClerkToken(session); | ||
| 129 | } | ||
| 130 | |||
| 131 | function statusKey(input: { | ||
| 132 | readonly accountId: string; | ||
| 133 | readonly environment: RelayClientEnvironmentRecord; | ||
| 134 | }): string { | ||
| 135 | return JSON.stringify(input); | ||
| 136 | } | ||
| 137 | |||
| 138 | function parseStatusKey(key: string): { | ||
| 139 | readonly accountId: string; | ||
| 140 | readonly environment: RelayClientEnvironmentRecord; | ||
| 141 | } { | ||
| 142 | return JSON.parse(key) as { | ||
| 143 | readonly accountId: string; | ||
| 144 | readonly environment: RelayClientEnvironmentRecord; | ||
| 145 | }; | ||
| 146 | } | ||
| 147 | |||
| 148 | function endpointMatches( | ||
| 149 | left: RelayClientEnvironmentRecord["endpoint"], | ||
| 150 | right: RelayClientEnvironmentRecord["endpoint"], | ||
| 151 | ): boolean { | ||
| 152 | return ( | ||
| 153 | left.httpBaseUrl === right.httpBaseUrl && | ||
| 154 | left.wsBaseUrl === right.wsBaseUrl && | ||
| 155 | left.providerKind === right.providerKind | ||
| 156 | ); | ||
| 157 | } | ||
| 158 | |||
| 159 | function validateEnvironmentStatus( | ||
| 160 | environment: RelayClientEnvironmentRecord, | ||
| 161 | status: RelayEnvironmentStatusResponse, | ||
| 162 | ): Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelaySnapshotError> { | ||
| 163 | if (status.environmentId !== environment.environmentId) { | ||
| 164 | return Effect.fail( | ||
| 165 | new ManagedRelaySnapshotError({ | ||
| 166 | message: "Relay returned status for a different environment.", | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | } | ||
| 170 | if (!endpointMatches(status.endpoint, environment.endpoint)) { | ||
| 171 | return Effect.fail( | ||
| 172 | new ManagedRelaySnapshotError({ | ||
| 173 | message: "Relay returned status for a different endpoint.", | ||
| 174 | }), | ||
| 175 | ); | ||
| 176 | } | ||
| 177 | if (status.descriptor && status.descriptor.environmentId !== environment.environmentId) { | ||
| 178 | return Effect.fail( | ||
| 179 | new ManagedRelaySnapshotError({ | ||
| 180 | message: "Relay returned status descriptor for a different environment.", | ||
| 181 | }), | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | return Effect.succeed(status); | ||
| 185 | } | ||
| 186 | |||
| 187 | export function readManagedRelaySnapshotState<A>( | ||
| 188 | result: AsyncResult.AsyncResult<A, unknown>, | ||
| 189 | ): ManagedRelaySnapshotState<A> { | ||
| 190 | let error: string | null = null; | ||
| 191 | if (result._tag === "Failure") { | ||
| 192 | const cause = Cause.squash(result.cause); | ||
| 193 | error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; | ||
| 194 | } | ||
| 195 | return { | ||
| 196 | data: Option.getOrNull(AsyncResult.value(result)), | ||
| 197 | error, | ||
| 198 | isPending: result.waiting, | ||
| 199 | }; | ||
| 200 | } | ||
| 201 | |||
| 202 | export function createManagedRelayQueryManager( | ||
| 203 | runtime: Atom.AtomRuntime<ManagedRelayClient>, | ||
| 204 | options?: { | ||
| 205 | readonly staleTimeMs?: number; | ||
| 206 | readonly idleTtlMs?: number; | ||
| 207 | }, | ||
| 208 | ) { | ||
| 209 | const staleTime = options?.staleTimeMs ?? DEFAULT_STALE_TIME_MS; | ||
| 210 | const idleTtl = options?.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; | ||
| 211 | |||
| 212 | const environmentsAtom = Atom.family((accountId: string) => | ||
| 213 | runtime | ||
| 214 | .atom((get) => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | const clerkToken = yield* requireClerkToken(get, accountId); | ||
| 217 | const relay = yield* ManagedRelayClient; | ||
| 218 | return yield* relay.listEnvironments({ clerkToken }); | ||
| 219 | }), | ||
| 220 | ) | ||
| 221 | .pipe( | ||
| 222 | Atom.swr({ staleTime, revalidateOnMount: true }), | ||
| 223 | Atom.setIdleTTL(idleTtl), | ||
| 224 | Atom.withLabel(`managed-relay:environments:${accountId}`), | ||
| 225 | ), | ||
| 226 | ); | ||
| 227 | |||
| 228 | const devicesAtom = Atom.family((accountId: string) => | ||
| 229 | runtime | ||
| 230 | .atom((get) => | ||
| 231 | Effect.gen(function* () { | ||
| 232 | const clerkToken = yield* requireClerkToken(get, accountId); | ||
| 233 | const relay = yield* ManagedRelayClient; | ||
| 234 | return yield* relay.listDevices({ clerkToken }); | ||
| 235 | }), | ||
| 236 | ) | ||
| 237 | .pipe( | ||
| 238 | Atom.swr({ staleTime, revalidateOnMount: true }), | ||
| 239 | Atom.setIdleTTL(idleTtl), | ||
| 240 | Atom.withLabel(`managed-relay:devices:${accountId}`), | ||
| 241 | ), | ||
| 242 | ); | ||
| 243 | |||
| 244 | const environmentStatusAtom = Atom.family((key: string) => { | ||
| 245 | const { accountId, environment } = parseStatusKey(key); | ||
| 246 | return runtime | ||
| 247 | .atom((get) => | ||
| 248 | Effect.gen(function* () { | ||
| 249 | const clerkToken = yield* requireClerkToken(get, accountId); | ||
| 250 | const relay = yield* ManagedRelayClient; | ||
| 251 | const status = yield* relay.getEnvironmentStatus({ | ||
| 252 | clerkToken, | ||
| 253 | scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], | ||
| 254 | environmentId: environment.environmentId, | ||
| 255 | }); | ||
| 256 | return yield* validateEnvironmentStatus(environment, status); | ||
| 257 | }), | ||
| 258 | ) | ||
| 259 | .pipe( | ||
| 260 | Atom.swr({ staleTime, revalidateOnMount: true }), | ||
| 261 | Atom.setIdleTTL(idleTtl), | ||
| 262 | Atom.withLabel(`managed-relay:environment-status:${key}`), | ||
| 263 | ); | ||
| 264 | }); | ||
| 265 | |||
| 266 | return { | ||
| 267 | environmentsAtom, | ||
| 268 | devicesAtom, | ||
| 269 | environmentStatusAtom: (input: { | ||
| 270 | readonly accountId: string; | ||
| 271 | readonly environment: RelayClientEnvironmentRecord; | ||
| 272 | }) => environmentStatusAtom(statusKey(input)), | ||
| 273 | refreshEnvironments(registry: AtomRegistry.AtomRegistry, accountId: string): void { | ||
| 274 | registry.refresh(environmentsAtom(accountId)); | ||
| 275 | }, | ||
| 276 | refreshDevices(registry: AtomRegistry.AtomRegistry, accountId: string): void { | ||
| 277 | registry.refresh(devicesAtom(accountId)); | ||
| 278 | }, | ||
| 279 | refreshEnvironmentStatus( | ||
| 280 | registry: AtomRegistry.AtomRegistry, | ||
| 281 | input: { | ||
| 282 | readonly accountId: string; | ||
| 283 | readonly environment: RelayClientEnvironmentRecord; | ||
| 284 | }, | ||
| 285 | ): void { | ||
| 286 | registry.refresh(environmentStatusAtom(statusKey(input))); | ||
| 287 | }, | ||
| 288 | }; | ||
| 289 | } | ||
packages/client-runtime/src/operations/commands.test.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | EnvironmentId, | ||
| 4 | ORCHESTRATION_WS_METHODS, | ||
| 5 | ProjectId, | ||
| 6 | ThreadId, | ||
| 7 | type ClientOrchestrationCommand, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { describe, expect, it } from "@effect/vitest"; | ||
| 10 | import * as Crypto from "effect/Crypto"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | AVAILABLE_CONNECTION_STATE, | ||
| 18 | PrimaryConnectionTarget, | ||
| 19 | type PreparedConnection, | ||
| 20 | } from "../connection/model.ts"; | ||
| 21 | import { | ||
| 22 | EnvironmentSupervisor, | ||
| 23 | type EnvironmentSupervisorService, | ||
| 24 | } from "../connection/supervisor.ts"; | ||
| 25 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 26 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | ||
| 27 | import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; | ||
| 28 | |||
| 29 | const TEST_CRYPTO_LAYER = Layer.succeed( | ||
| 30 | Crypto.Crypto, | ||
| 31 | Crypto.make({ | ||
| 32 | randomBytes: (size) => new Uint8Array(size), | ||
| 33 | digest: (_algorithm, data) => Effect.succeed(data), | ||
| 34 | }), | ||
| 35 | ); | ||
| 36 | |||
| 37 | const TARGET = new PrimaryConnectionTarget({ | ||
| 38 | environmentId: EnvironmentId.make("environment-1"), | ||
| 39 | label: "Test environment", | ||
| 40 | httpBaseUrl: "https://environment.example.test", | ||
| 41 | wsBaseUrl: "wss://environment.example.test", | ||
| 42 | }); | ||
| 43 | |||
| 44 | const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* ( | ||
| 45 | dispatched: ClientOrchestrationCommand[], | ||
| 46 | ) { | ||
| 47 | const client = { | ||
| 48 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command: ClientOrchestrationCommand) => | ||
| 49 | Effect.sync(() => { | ||
| 50 | dispatched.push(command); | ||
| 51 | return { sequence: dispatched.length }; | ||
| 52 | }), | ||
| 53 | } as unknown as WsRpcProtocolClient; | ||
| 54 | const session: RpcSession = { | ||
| 55 | client, | ||
| 56 | initialConfig: Effect.never, | ||
| 57 | ready: Effect.void, | ||
| 58 | probe: Effect.void, | ||
| 59 | closed: Effect.never, | ||
| 60 | }; | ||
| 61 | return EnvironmentSupervisor.of({ | ||
| 62 | target: TARGET, | ||
| 63 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), | ||
| 64 | session: yield* SubscriptionRef.make(Option.some(session)), | ||
| 65 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | ||
| 66 | connect: Effect.void, | ||
| 67 | disconnect: Effect.void, | ||
| 68 | retryNow: Effect.void, | ||
| 69 | } satisfies EnvironmentSupervisorService); | ||
| 70 | }); | ||
| 71 | |||
| 72 | describe("environment commands", () => { | ||
| 73 | it.effect("adds generated command metadata", () => | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const dispatched: ClientOrchestrationCommand[] = []; | ||
| 76 | const supervisor = yield* makeSupervisor(dispatched); | ||
| 77 | |||
| 78 | const result = yield* createProject({ | ||
| 79 | projectId: ProjectId.make("project-1"), | ||
| 80 | title: "Project", | ||
| 81 | workspaceRoot: "/workspace/project", | ||
| 82 | createdAt: "2026-06-06T00:00:00.000Z", | ||
| 83 | }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); | ||
| 84 | |||
| 85 | expect(result).toEqual({ sequence: 1 }); | ||
| 86 | expect(dispatched).toEqual([ | ||
| 87 | { | ||
| 88 | type: "project.create", | ||
| 89 | commandId: "00000000-0000-4000-8000-000000000000", | ||
| 90 | projectId: "project-1", | ||
| 91 | title: "Project", | ||
| 92 | workspaceRoot: "/workspace/project", | ||
| 93 | createdAt: "2026-06-06T00:00:00.000Z", | ||
| 94 | }, | ||
| 95 | ]); | ||
| 96 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | ||
| 97 | ); | ||
| 98 | |||
| 99 | it.effect("preserves caller metadata for idempotent queued commands", () => | ||
| 100 | Effect.gen(function* () { | ||
| 101 | const dispatched: ClientOrchestrationCommand[] = []; | ||
| 102 | const supervisor = yield* makeSupervisor(dispatched); | ||
| 103 | |||
| 104 | yield* stopThreadSession({ | ||
| 105 | commandId: CommandId.make("queued-command"), | ||
| 106 | threadId: ThreadId.make("thread-1"), | ||
| 107 | createdAt: "2026-06-06T00:01:00.000Z", | ||
| 108 | }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); | ||
| 109 | |||
| 110 | expect(dispatched).toEqual([ | ||
| 111 | { | ||
| 112 | type: "thread.session.stop", | ||
| 113 | commandId: "queued-command", | ||
| 114 | threadId: "thread-1", | ||
| 115 | createdAt: "2026-06-06T00:01:00.000Z", | ||
| 116 | }, | ||
| 117 | ]); | ||
| 118 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect("does not add timestamps to commands without createdAt", () => | ||
| 122 | Effect.gen(function* () { | ||
| 123 | const dispatched: ClientOrchestrationCommand[] = []; | ||
| 124 | const supervisor = yield* makeSupervisor(dispatched); | ||
| 125 | |||
| 126 | yield* archiveThread({ | ||
| 127 | commandId: CommandId.make("archive-command"), | ||
| 128 | threadId: ThreadId.make("thread-1"), | ||
| 129 | }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); | ||
| 130 | |||
| 131 | expect(dispatched).toEqual([ | ||
| 132 | { | ||
| 133 | type: "thread.archive", | ||
| 134 | commandId: "archive-command", | ||
| 135 | threadId: "thread-1", | ||
| 136 | }, | ||
| 137 | ]); | ||
| 138 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | ||
| 139 | ); | ||
| 140 | }); | ||
packages/client-runtime/src/operations/commands.ts created+256| ... | @@ -0,0 +1,256 @@ | ||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | ORCHESTRATION_WS_METHODS, | ||
| 4 | type ClientOrchestrationCommand, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Crypto from "effect/Crypto"; | ||
| 7 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | |||
| 10 | import type { EnvironmentSupervisor } from "../connection/supervisor.ts"; | ||
| 11 | import { | ||
| 12 | type EnvironmentRpcFailure, | ||
| 13 | type EnvironmentRpcSuccess, | ||
| 14 | type EnvironmentRpcUnavailableError, | ||
| 15 | request, | ||
| 16 | } from "../rpc/client.ts"; | ||
| 17 | |||
| 18 | type CommandType = ClientOrchestrationCommand["type"]; | ||
| 19 | type CommandOf<T extends CommandType> = Extract<ClientOrchestrationCommand, { readonly type: T }>; | ||
| 20 | type CommandInput<T extends CommandType> = Omit< | ||
| 21 | CommandOf<T>, | ||
| 22 | "type" | "commandId" | "createdAt" | ||
| 23 | > & { | ||
| 24 | readonly commandId?: CommandId; | ||
| 25 | } & ("createdAt" extends keyof CommandOf<T> | ||
| 26 | ? { | ||
| 27 | readonly createdAt?: CommandOf<T>["createdAt"]; | ||
| 28 | } | ||
| 29 | : {}); | ||
| 30 | |||
| 31 | export type CreateProjectInput = CommandInput<"project.create">; | ||
| 32 | export type UpdateProjectInput = CommandInput<"project.meta.update">; | ||
| 33 | export type DeleteProjectInput = CommandInput<"project.delete">; | ||
| 34 | export type CreateThreadInput = CommandInput<"thread.create">; | ||
| 35 | export type DeleteThreadInput = CommandInput<"thread.delete">; | ||
| 36 | export type ArchiveThreadInput = CommandInput<"thread.archive">; | ||
| 37 | export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; | ||
| 38 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; | ||
| 39 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; | ||
| 40 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; | ||
| 41 | export type StartThreadTurnInput = CommandInput<"thread.turn.start">; | ||
| 42 | export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; | ||
| 43 | export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; | ||
| 44 | export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; | ||
| 45 | export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; | ||
| 46 | export type StopThreadSessionInput = CommandInput<"thread.session.stop">; | ||
| 47 | |||
| 48 | type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; | ||
| 49 | type CommandEffect = Effect.Effect< | ||
| 50 | EnvironmentRpcSuccess<DispatchTag>, | ||
| 51 | EnvironmentRpcFailure<DispatchTag> | EnvironmentRpcUnavailableError, | ||
| 52 | Crypto.Crypto | EnvironmentSupervisor | ||
| 53 | >; | ||
| 54 | |||
| 55 | function commandId(input: { readonly commandId?: CommandId }) { | ||
| 56 | return Effect.gen(function* () { | ||
| 57 | if (input.commandId !== undefined) { | ||
| 58 | return input.commandId; | ||
| 59 | } | ||
| 60 | const crypto = yield* Crypto.Crypto; | ||
| 61 | return yield* crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(CommandId.make)); | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | |||
| 65 | function timestampedCommandMetadata(input: { | ||
| 66 | readonly commandId?: CommandId; | ||
| 67 | readonly createdAt?: string; | ||
| 68 | }) { | ||
| 69 | return Effect.all({ | ||
| 70 | commandId: commandId(input), | ||
| 71 | createdAt: | ||
| 72 | input.createdAt === undefined | ||
| 73 | ? DateTime.now.pipe(Effect.map(DateTime.formatIso)) | ||
| 74 | : Effect.succeed(input.createdAt), | ||
| 75 | }); | ||
| 76 | } | ||
| 77 | |||
| 78 | function dispatch(command: ClientOrchestrationCommand) { | ||
| 79 | return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command); | ||
| 80 | } | ||
| 81 | |||
| 82 | export const createProject: (input: CreateProjectInput) => CommandEffect = Effect.fn( | ||
| 83 | "EnvironmentCommands.createProject", | ||
| 84 | )(function* (input) { | ||
| 85 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 86 | return yield* dispatch({ | ||
| 87 | ...input, | ||
| 88 | type: "project.create", | ||
| 89 | commandId: metadata.commandId, | ||
| 90 | createdAt: metadata.createdAt, | ||
| 91 | }); | ||
| 92 | }); | ||
| 93 | |||
| 94 | export const updateProject: (input: UpdateProjectInput) => CommandEffect = Effect.fn( | ||
| 95 | "EnvironmentCommands.updateProject", | ||
| 96 | )(function* (input) { | ||
| 97 | return yield* dispatch({ | ||
| 98 | ...input, | ||
| 99 | type: "project.meta.update", | ||
| 100 | commandId: yield* commandId(input), | ||
| 101 | }); | ||
| 102 | }); | ||
| 103 | |||
| 104 | export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effect.fn( | ||
| 105 | "EnvironmentCommands.deleteProject", | ||
| 106 | )(function* (input) { | ||
| 107 | return yield* dispatch({ | ||
| 108 | ...input, | ||
| 109 | type: "project.delete", | ||
| 110 | commandId: yield* commandId(input), | ||
| 111 | }); | ||
| 112 | }); | ||
| 113 | |||
| 114 | export const createThread: (input: CreateThreadInput) => CommandEffect = Effect.fn( | ||
| 115 | "EnvironmentCommands.createThread", | ||
| 116 | )(function* (input) { | ||
| 117 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 118 | return yield* dispatch({ | ||
| 119 | ...input, | ||
| 120 | type: "thread.create", | ||
| 121 | commandId: metadata.commandId, | ||
| 122 | createdAt: metadata.createdAt, | ||
| 123 | }); | ||
| 124 | }); | ||
| 125 | |||
| 126 | export const deleteThread: (input: DeleteThreadInput) => CommandEffect = Effect.fn( | ||
| 127 | "EnvironmentCommands.deleteThread", | ||
| 128 | )(function* (input) { | ||
| 129 | return yield* dispatch({ | ||
| 130 | ...input, | ||
| 131 | type: "thread.delete", | ||
| 132 | commandId: yield* commandId(input), | ||
| 133 | }); | ||
| 134 | }); | ||
| 135 | |||
| 136 | export const archiveThread: (input: ArchiveThreadInput) => CommandEffect = Effect.fn( | ||
| 137 | "EnvironmentCommands.archiveThread", | ||
| 138 | )(function* (input) { | ||
| 139 | return yield* dispatch({ | ||
| 140 | ...input, | ||
| 141 | type: "thread.archive", | ||
| 142 | commandId: yield* commandId(input), | ||
| 143 | }); | ||
| 144 | }); | ||
| 145 | |||
| 146 | export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = Effect.fn( | ||
| 147 | "EnvironmentCommands.unarchiveThread", | ||
| 148 | )(function* (input) { | ||
| 149 | return yield* dispatch({ | ||
| 150 | ...input, | ||
| 151 | type: "thread.unarchive", | ||
| 152 | commandId: yield* commandId(input), | ||
| 153 | }); | ||
| 154 | }); | ||
| 155 | |||
| 156 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( | ||
| 157 | "EnvironmentCommands.updateThreadMetadata", | ||
| 158 | )(function* (input) { | ||
| 159 | return yield* dispatch({ | ||
| 160 | ...input, | ||
| 161 | type: "thread.meta.update", | ||
| 162 | commandId: yield* commandId(input), | ||
| 163 | }); | ||
| 164 | }); | ||
| 165 | |||
| 166 | export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn( | ||
| 167 | "EnvironmentCommands.setThreadRuntimeMode", | ||
| 168 | )(function* (input) { | ||
| 169 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 170 | return yield* dispatch({ | ||
| 171 | ...input, | ||
| 172 | type: "thread.runtime-mode.set", | ||
| 173 | commandId: metadata.commandId, | ||
| 174 | createdAt: metadata.createdAt, | ||
| 175 | }); | ||
| 176 | }); | ||
| 177 | |||
| 178 | export const setThreadInteractionMode: (input: SetThreadInteractionModeInput) => CommandEffect = | ||
| 179 | Effect.fn("EnvironmentCommands.setThreadInteractionMode")(function* (input) { | ||
| 180 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 181 | return yield* dispatch({ | ||
| 182 | ...input, | ||
| 183 | type: "thread.interaction-mode.set", | ||
| 184 | commandId: metadata.commandId, | ||
| 185 | createdAt: metadata.createdAt, | ||
| 186 | }); | ||
| 187 | }); | ||
| 188 | |||
| 189 | export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = Effect.fn( | ||
| 190 | "EnvironmentCommands.startThreadTurn", | ||
| 191 | )(function* (input) { | ||
| 192 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 193 | return yield* dispatch({ | ||
| 194 | ...input, | ||
| 195 | type: "thread.turn.start", | ||
| 196 | commandId: metadata.commandId, | ||
| 197 | createdAt: metadata.createdAt, | ||
| 198 | }); | ||
| 199 | }); | ||
| 200 | |||
| 201 | export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEffect = Effect.fn( | ||
| 202 | "EnvironmentCommands.interruptThreadTurn", | ||
| 203 | )(function* (input) { | ||
| 204 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 205 | return yield* dispatch({ | ||
| 206 | ...input, | ||
| 207 | type: "thread.turn.interrupt", | ||
| 208 | commandId: metadata.commandId, | ||
| 209 | createdAt: metadata.createdAt, | ||
| 210 | }); | ||
| 211 | }); | ||
| 212 | |||
| 213 | export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect = | ||
| 214 | Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) { | ||
| 215 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 216 | return yield* dispatch({ | ||
| 217 | ...input, | ||
| 218 | type: "thread.approval.respond", | ||
| 219 | commandId: metadata.commandId, | ||
| 220 | createdAt: metadata.createdAt, | ||
| 221 | }); | ||
| 222 | }); | ||
| 223 | |||
| 224 | export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) => CommandEffect = | ||
| 225 | Effect.fn("EnvironmentCommands.respondToThreadUserInput")(function* (input) { | ||
| 226 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 227 | return yield* dispatch({ | ||
| 228 | ...input, | ||
| 229 | type: "thread.user-input.respond", | ||
| 230 | commandId: metadata.commandId, | ||
| 231 | createdAt: metadata.createdAt, | ||
| 232 | }); | ||
| 233 | }); | ||
| 234 | |||
| 235 | export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect = | ||
| 236 | Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) { | ||
| 237 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 238 | return yield* dispatch({ | ||
| 239 | ...input, | ||
| 240 | type: "thread.checkpoint.revert", | ||
| 241 | commandId: metadata.commandId, | ||
| 242 | createdAt: metadata.createdAt, | ||
| 243 | }); | ||
| 244 | }); | ||
| 245 | |||
| 246 | export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect = Effect.fn( | ||
| 247 | "EnvironmentCommands.stopThreadSession", | ||
| 248 | )(function* (input) { | ||
| 249 | const metadata = yield* timestampedCommandMetadata(input); | ||
| 250 | return yield* dispatch({ | ||
| 251 | ...input, | ||
| 252 | type: "thread.session.stop", | ||
| 253 | commandId: metadata.commandId, | ||
| 254 | createdAt: metadata.createdAt, | ||
| 255 | }); | ||
| 256 | }); | ||
packages/client-runtime/src/operations/index.ts created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | export * from "./commands.ts"; | ||
| 2 | export * from "./projects.ts"; | ||
packages/client-runtime/src/operations/projects.test.ts created+146| ... | @@ -0,0 +1,146 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { | ||
| 3 | EnvironmentId, | ||
| 4 | ProjectId, | ||
| 5 | CommandId, | ||
| 6 | SourceControlDiscoveryResult, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | buildAddProjectRemoteSourceReadiness, | ||
| 12 | buildProjectCreateCommand, | ||
| 13 | findExistingAddProject, | ||
| 14 | getAddProjectInitialQuery, | ||
| 15 | resolveAddProjectPath, | ||
| 16 | sortAddProjectProviderSources, | ||
| 17 | } from "./projects.ts"; | ||
| 18 | import type { EnvironmentProject } from "../state/models.ts"; | ||
| 19 | |||
| 20 | describe("add project shared logic", () => { | ||
| 21 | it("resolves initial browse paths from settings", () => { | ||
| 22 | expect(getAddProjectInitialQuery("")).toBe("~/"); | ||
| 23 | expect(getAddProjectInitialQuery("/work")).toBe("/work/"); | ||
| 24 | expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\"); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("rejects unsupported windows paths on non-windows environments", () => { | ||
| 28 | expect( | ||
| 29 | resolveAddProjectPath({ | ||
| 30 | rawPath: "C:\\repo", | ||
| 31 | platform: "MacIntel", | ||
| 32 | currentProjectCwd: null, | ||
| 33 | }), | ||
| 34 | ).toEqual({ | ||
| 35 | ok: false, | ||
| 36 | error: "Windows-style paths are only supported on Windows environments.", | ||
| 37 | }); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("resolves relative paths from the active project cwd", () => { | ||
| 41 | expect( | ||
| 42 | resolveAddProjectPath({ | ||
| 43 | rawPath: "../next", | ||
| 44 | platform: "Linux", | ||
| 45 | currentProjectCwd: "/work/current", | ||
| 46 | }), | ||
| 47 | ).toEqual({ ok: true, path: "/work/next" }); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("marks authenticated source control providers as ready", () => { | ||
| 51 | const discovery: SourceControlDiscoveryResult = { | ||
| 52 | versionControlSystems: [], | ||
| 53 | sourceControlProviders: [ | ||
| 54 | { | ||
| 55 | kind: "github", | ||
| 56 | label: "GitHub", | ||
| 57 | status: "available", | ||
| 58 | installHint: "Install gh", | ||
| 59 | version: Option.some("1.0.0"), | ||
| 60 | detail: Option.none(), | ||
| 61 | auth: { | ||
| 62 | status: "authenticated", | ||
| 63 | account: Option.some("octo"), | ||
| 64 | host: Option.some("github.com"), | ||
| 65 | detail: Option.none(), | ||
| 66 | }, | ||
| 67 | }, | ||
| 68 | { | ||
| 69 | kind: "gitlab", | ||
| 70 | label: "GitLab", | ||
| 71 | status: "available", | ||
| 72 | installHint: "Install glab", | ||
| 73 | version: Option.some("1.0.0"), | ||
| 74 | detail: Option.none(), | ||
| 75 | auth: { | ||
| 76 | status: "unauthenticated", | ||
| 77 | account: Option.none(), | ||
| 78 | host: Option.none(), | ||
| 79 | detail: Option.some("Run glab auth login"), | ||
| 80 | }, | ||
| 81 | }, | ||
| 82 | ], | ||
| 83 | }; | ||
| 84 | |||
| 85 | const readiness = buildAddProjectRemoteSourceReadiness(discovery); | ||
| 86 | expect(readiness.url.ready).toBe(true); | ||
| 87 | expect(readiness.github.ready).toBe(true); | ||
| 88 | expect(readiness.gitlab).toEqual({ ready: false, hint: "Run glab auth login" }); | ||
| 89 | expect(sortAddProjectProviderSources(readiness)[0]).toBe("github"); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("finds existing projects by normalized path in the target environment", () => { | ||
| 93 | const env = EnvironmentId.make("env"); | ||
| 94 | const other = EnvironmentId.make("other"); | ||
| 95 | const projects: EnvironmentProject[] = [ | ||
| 96 | { | ||
| 97 | environmentId: other, | ||
| 98 | id: ProjectId.make("same-path-other-env"), | ||
| 99 | title: "Other", | ||
| 100 | workspaceRoot: "/repo", | ||
| 101 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 102 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 103 | repositoryIdentity: null, | ||
| 104 | defaultModelSelection: null, | ||
| 105 | scripts: [], | ||
| 106 | }, | ||
| 107 | { | ||
| 108 | environmentId: env, | ||
| 109 | id: ProjectId.make("project"), | ||
| 110 | title: "Repo", | ||
| 111 | workspaceRoot: "/repo/", | ||
| 112 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 113 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 114 | repositoryIdentity: null, | ||
| 115 | defaultModelSelection: null, | ||
| 116 | scripts: [], | ||
| 117 | }, | ||
| 118 | ]; | ||
| 119 | |||
| 120 | expect(findExistingAddProject({ projects, environmentId: env, path: "/repo" })?.id).toBe( | ||
| 121 | "project", | ||
| 122 | ); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("builds the existing project.create command shape", () => { | ||
| 126 | expect( | ||
| 127 | buildProjectCreateCommand({ | ||
| 128 | commandId: CommandId.make("command"), | ||
| 129 | projectId: ProjectId.make("project"), | ||
| 130 | workspaceRoot: "/work/repo", | ||
| 131 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 132 | }), | ||
| 133 | ).toMatchObject({ | ||
| 134 | type: "project.create", | ||
| 135 | commandId: "command", | ||
| 136 | projectId: "project", | ||
| 137 | title: "repo", | ||
| 138 | workspaceRoot: "/work/repo", | ||
| 139 | createWorkspaceRootIfMissing: true, | ||
| 140 | defaultModelSelection: { | ||
| 141 | instanceId: "codex", | ||
| 142 | model: "gpt-5.4", | ||
| 143 | }, | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | }); | ||
packages/client-runtime/src/operations/projects.ts created+224| ... | @@ -0,0 +1,224 @@ | ||
| 1 | import type { | ||
| 2 | CommandId, | ||
| 3 | EnvironmentId, | ||
| 4 | OrchestrationCommand, | ||
| 5 | ProjectId, | ||
| 6 | SourceControlDiscoveryResult, | ||
| 7 | SourceControlProviderKind, | ||
| 8 | SourceControlRepositoryInfo, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 11 | import * as Arr from "effect/Array"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Order from "effect/Order"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | ensureBrowseDirectoryPath, | ||
| 17 | findProjectByPath, | ||
| 18 | inferProjectTitleFromPath, | ||
| 19 | isExplicitRelativeProjectPath, | ||
| 20 | isUnsupportedWindowsProjectPath, | ||
| 21 | resolveProjectPathForDispatch, | ||
| 22 | } from "../state/projects.ts"; | ||
| 23 | import type { EnvironmentProject } from "../state/models.ts"; | ||
| 24 | |||
| 25 | export type AddProjectRemoteProviderKind = Extract< | ||
| 26 | SourceControlProviderKind, | ||
| 27 | "github" | "gitlab" | "bitbucket" | "azure-devops" | ||
| 28 | >; | ||
| 29 | export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | ||
| 30 | |||
| 31 | export type AddProjectRemoteSourceReadiness = Record< | ||
| 32 | AddProjectRemoteSource, | ||
| 33 | { readonly ready: boolean; readonly hint: string | null } | ||
| 34 | >; | ||
| 35 | |||
| 36 | export type AddProjectCloneFlow = | ||
| 37 | | { | ||
| 38 | readonly step: "repository"; | ||
| 39 | readonly environmentId: EnvironmentId; | ||
| 40 | readonly source: AddProjectRemoteSource; | ||
| 41 | } | ||
| 42 | | { | ||
| 43 | readonly step: "confirm"; | ||
| 44 | readonly environmentId: EnvironmentId; | ||
| 45 | readonly source: AddProjectRemoteSource; | ||
| 46 | readonly repositoryInput: string; | ||
| 47 | readonly repository: SourceControlRepositoryInfo | null; | ||
| 48 | readonly remoteUrl: string; | ||
| 49 | }; | ||
| 50 | |||
| 51 | const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [ | ||
| 52 | "url", | ||
| 53 | "github", | ||
| 54 | "gitlab", | ||
| 55 | "bitbucket", | ||
| 56 | "azure-devops", | ||
| 57 | ]; | ||
| 58 | |||
| 59 | const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [ | ||
| 60 | "github", | ||
| 61 | "gitlab", | ||
| 62 | "bitbucket", | ||
| 63 | "azure-devops", | ||
| 64 | ]; | ||
| 65 | |||
| 66 | export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string { | ||
| 67 | switch (source) { | ||
| 68 | case "github": | ||
| 69 | return "GitHub"; | ||
| 70 | case "gitlab": | ||
| 71 | return "GitLab"; | ||
| 72 | case "bitbucket": | ||
| 73 | return "Bitbucket"; | ||
| 74 | case "azure-devops": | ||
| 75 | return "Azure DevOps"; | ||
| 76 | case "url": | ||
| 77 | return "Git URL"; | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string { | ||
| 82 | switch (source) { | ||
| 83 | case "github": | ||
| 84 | return "owner/repo"; | ||
| 85 | case "gitlab": | ||
| 86 | return "group/project"; | ||
| 87 | case "bitbucket": | ||
| 88 | return "workspace/repository"; | ||
| 89 | case "azure-devops": | ||
| 90 | return "project/repository"; | ||
| 91 | case "url": | ||
| 92 | return "URL"; | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | export function addProjectRemoteSourceProvider( | ||
| 97 | source: AddProjectRemoteSource, | ||
| 98 | ): AddProjectRemoteProviderKind | null { | ||
| 99 | return source === "url" ? null : source; | ||
| 100 | } | ||
| 101 | |||
| 102 | export function sortAddProjectProviderSources( | ||
| 103 | readinessBySource: AddProjectRemoteSourceReadiness, | ||
| 104 | ): ReadonlyArray<AddProjectRemoteProviderKind> { | ||
| 105 | return Arr.sort( | ||
| 106 | ADD_PROJECT_REMOTE_PROVIDER_SOURCES, | ||
| 107 | Order.mapInput( | ||
| 108 | Order.Struct({ | ||
| 109 | ready: Order.flip(Order.Boolean), | ||
| 110 | label: Order.String, | ||
| 111 | }), | ||
| 112 | (source: AddProjectRemoteProviderKind) => ({ | ||
| 113 | ready: readinessBySource[source].ready, | ||
| 114 | label: addProjectRemoteSourceLabel(source), | ||
| 115 | }), | ||
| 116 | ), | ||
| 117 | ); | ||
| 118 | } | ||
| 119 | |||
| 120 | export function buildAddProjectRemoteSourceReadiness( | ||
| 121 | discovery: SourceControlDiscoveryResult | null, | ||
| 122 | ): AddProjectRemoteSourceReadiness { | ||
| 123 | const unavailable = { | ||
| 124 | ready: false, | ||
| 125 | hint: "Provider status unavailable. Open Source Control settings and rescan.", | ||
| 126 | } as const; | ||
| 127 | const readiness: AddProjectRemoteSourceReadiness = { | ||
| 128 | url: { ready: true, hint: null }, | ||
| 129 | github: unavailable, | ||
| 130 | gitlab: unavailable, | ||
| 131 | bitbucket: unavailable, | ||
| 132 | "azure-devops": unavailable, | ||
| 133 | }; | ||
| 134 | |||
| 135 | if (!discovery) { | ||
| 136 | return readiness; | ||
| 137 | } | ||
| 138 | |||
| 139 | const providerByKind = new Map( | ||
| 140 | discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), | ||
| 141 | ); | ||
| 142 | for (const source of ADD_PROJECT_REMOTE_SOURCES) { | ||
| 143 | const kind = addProjectRemoteSourceProvider(source); | ||
| 144 | if (!kind) continue; | ||
| 145 | const provider = providerByKind.get(kind); | ||
| 146 | if (!provider) { | ||
| 147 | readiness[source] = unavailable; | ||
| 148 | continue; | ||
| 149 | } | ||
| 150 | if (provider.status !== "available") { | ||
| 151 | readiness[source] = { ready: false, hint: provider.installHint }; | ||
| 152 | continue; | ||
| 153 | } | ||
| 154 | if (provider.auth.status === "unauthenticated") { | ||
| 155 | readiness[source] = { | ||
| 156 | ready: false, | ||
| 157 | hint: | ||
| 158 | Option.getOrNull(provider.auth.detail) ?? | ||
| 159 | `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`, | ||
| 160 | }; | ||
| 161 | continue; | ||
| 162 | } | ||
| 163 | readiness[source] = { ready: true, hint: null }; | ||
| 164 | } | ||
| 165 | return readiness; | ||
| 166 | } | ||
| 167 | |||
| 168 | export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string { | ||
| 169 | const trimmed = baseDirectory?.trim() ?? ""; | ||
| 170 | return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed); | ||
| 171 | } | ||
| 172 | |||
| 173 | export function resolveAddProjectPath(input: { | ||
| 174 | readonly rawPath: string; | ||
| 175 | readonly currentProjectCwd?: string | null; | ||
| 176 | readonly platform: string; | ||
| 177 | }): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly error: string } { | ||
| 178 | const rawPath = input.rawPath.trim(); | ||
| 179 | if (rawPath.length === 0) { | ||
| 180 | return { ok: false, error: "Enter a project path." }; | ||
| 181 | } | ||
| 182 | if (isUnsupportedWindowsProjectPath(rawPath, input.platform)) { | ||
| 183 | return { ok: false, error: "Windows-style paths are only supported on Windows environments." }; | ||
| 184 | } | ||
| 185 | if (isExplicitRelativeProjectPath(rawPath) && !input.currentProjectCwd) { | ||
| 186 | return { ok: false, error: "Relative paths require an active project in this environment." }; | ||
| 187 | } | ||
| 188 | const path = resolveProjectPathForDispatch(rawPath, input.currentProjectCwd); | ||
| 189 | return path.length === 0 ? { ok: false, error: "Enter a project path." } : { ok: true, path }; | ||
| 190 | } | ||
| 191 | |||
| 192 | export function findExistingAddProject(input: { | ||
| 193 | readonly projects: ReadonlyArray<EnvironmentProject>; | ||
| 194 | readonly environmentId: EnvironmentId; | ||
| 195 | readonly path: string; | ||
| 196 | }): EnvironmentProject | null { | ||
| 197 | return ( | ||
| 198 | findProjectByPath( | ||
| 199 | input.projects.filter((project) => project.environmentId === input.environmentId), | ||
| 200 | input.path, | ||
| 201 | ) ?? null | ||
| 202 | ); | ||
| 203 | } | ||
| 204 | |||
| 205 | export function buildProjectCreateCommand(input: { | ||
| 206 | readonly commandId: CommandId; | ||
| 207 | readonly projectId: ProjectId; | ||
| 208 | readonly workspaceRoot: string; | ||
| 209 | readonly createdAt: string; | ||
| 210 | }): Extract<OrchestrationCommand, { type: "project.create" }> { | ||
| 211 | return { | ||
| 212 | type: "project.create", | ||
| 213 | commandId: input.commandId, | ||
| 214 | projectId: input.projectId, | ||
| 215 | title: inferProjectTitleFromPath(input.workspaceRoot), | ||
| 216 | workspaceRoot: input.workspaceRoot, | ||
| 217 | createWorkspaceRootIfMissing: true, | ||
| 218 | defaultModelSelection: { | ||
| 219 | instanceId: ProviderInstanceId.make("codex"), | ||
| 220 | model: DEFAULT_MODEL, | ||
| 221 | }, | ||
| 222 | createdAt: input.createdAt, | ||
| 223 | }; | ||
| 224 | } | ||
packages/client-runtime/src/platform/capabilities.ts created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | import { | ||
| 2 | type AuthClientPresentationMetadata, | ||
| 3 | type AuthEnvironmentScope, | ||
| 4 | type DesktopSshEnvironmentBootstrap, | ||
| 5 | type DesktopSshEnvironmentTarget, | ||
| 6 | EnvironmentId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Context from "effect/Context"; | ||
| 9 | import type * as Effect from "effect/Effect"; | ||
| 10 | import type * as Option from "effect/Option"; | ||
| 11 | |||
| 12 | import type { ConnectionAttemptError } from "../connection/model.ts"; | ||
| 13 | |||
| 14 | export interface PreparedSshEnvironment { | ||
| 15 | readonly bootstrap: DesktopSshEnvironmentBootstrap; | ||
| 16 | readonly bearerToken: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | export interface ProvisionedSshEnvironment extends PreparedSshEnvironment { | ||
| 20 | readonly environmentId: EnvironmentId; | ||
| 21 | readonly label: string; | ||
| 22 | } | ||
| 23 | |||
| 24 | export class CloudSession extends Context.Service< | ||
| 25 | CloudSession, | ||
| 26 | { | ||
| 27 | readonly clerkToken: Effect.Effect<string, ConnectionAttemptError>; | ||
| 28 | } | ||
| 29 | >()("@t3tools/client-runtime/platform/capabilities/CloudSession") {} | ||
| 30 | |||
| 31 | export class RelayDeviceIdentity extends Context.Service< | ||
| 32 | RelayDeviceIdentity, | ||
| 33 | { | ||
| 34 | readonly deviceId: Effect.Effect<Option.Option<string>, ConnectionAttemptError>; | ||
| 35 | } | ||
| 36 | >()("@t3tools/client-runtime/platform/capabilities/RelayDeviceIdentity") {} | ||
| 37 | |||
| 38 | export class ClientPresentation extends Context.Service< | ||
| 39 | ClientPresentation, | ||
| 40 | { | ||
| 41 | readonly metadata: AuthClientPresentationMetadata; | ||
| 42 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | ||
| 43 | } | ||
| 44 | >()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} | ||
| 45 | |||
| 46 | export class SshEnvironmentGateway extends Context.Service< | ||
| 47 | SshEnvironmentGateway, | ||
| 48 | { | ||
| 49 | readonly provision: ( | ||
| 50 | target: DesktopSshEnvironmentTarget, | ||
| 51 | ) => Effect.Effect<ProvisionedSshEnvironment, ConnectionAttemptError>; | ||
| 52 | readonly prepare: (input: { | ||
| 53 | readonly connectionId: string; | ||
| 54 | readonly expectedEnvironmentId: EnvironmentId; | ||
| 55 | readonly target: DesktopSshEnvironmentTarget; | ||
| 56 | }) => Effect.Effect<PreparedSshEnvironment, ConnectionAttemptError>; | ||
| 57 | readonly disconnect: ( | ||
| 58 | target: DesktopSshEnvironmentTarget, | ||
| 59 | ) => Effect.Effect<void, ConnectionAttemptError>; | ||
| 60 | } | ||
| 61 | >()("@t3tools/client-runtime/platform/capabilities/SshEnvironmentGateway") {} | ||
packages/client-runtime/src/platform/index.ts created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | export * from "./capabilities.ts"; | ||
| 2 | export * from "./persistence.ts"; | ||
| 3 | export * from "./source.ts"; | ||
| 4 | export * from "./storageDocument.ts"; | ||
packages/client-runtime/src/platform/persistence.ts created+84| ... | @@ -0,0 +1,84 @@ | ||
| 1 | import { | ||
| 2 | type EnvironmentId, | ||
| 3 | type OrchestrationThread, | ||
| 4 | type OrchestrationShellSnapshot, | ||
| 5 | type ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | |||
| 12 | import type { ConnectionRegistration } from "../connection/catalog.ts"; | ||
| 13 | import type { ConnectionTarget } from "../connection/model.ts"; | ||
| 14 | |||
| 15 | export class ConnectionPersistenceError extends Schema.TaggedErrorClass<ConnectionPersistenceError>()( | ||
| 16 | "ConnectionPersistenceError", | ||
| 17 | { | ||
| 18 | operation: Schema.Literals([ | ||
| 19 | "list-targets", | ||
| 20 | "register-connection", | ||
| 21 | "remove-connection", | ||
| 22 | "load-shell", | ||
| 23 | "save-shell", | ||
| 24 | "load-thread", | ||
| 25 | "save-thread", | ||
| 26 | "remove-thread", | ||
| 27 | "clear-environment", | ||
| 28 | ]), | ||
| 29 | message: Schema.String, | ||
| 30 | }, | ||
| 31 | ) {} | ||
| 32 | |||
| 33 | export class ConnectionTargetStore extends Context.Service< | ||
| 34 | ConnectionTargetStore, | ||
| 35 | { | ||
| 36 | readonly list: Effect.Effect<ReadonlyArray<ConnectionTarget>, ConnectionPersistenceError>; | ||
| 37 | } | ||
| 38 | >()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} | ||
| 39 | |||
| 40 | export class ConnectionRegistrationStore extends Context.Service< | ||
| 41 | ConnectionRegistrationStore, | ||
| 42 | { | ||
| 43 | readonly register: ( | ||
| 44 | registration: ConnectionRegistration, | ||
| 45 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 46 | readonly remove: (target: ConnectionTarget) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 47 | } | ||
| 48 | >()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} | ||
| 49 | |||
| 50 | export class EnvironmentCacheStore extends Context.Service< | ||
| 51 | EnvironmentCacheStore, | ||
| 52 | { | ||
| 53 | readonly loadShell: ( | ||
| 54 | environmentId: EnvironmentId, | ||
| 55 | ) => Effect.Effect<Option.Option<OrchestrationShellSnapshot>, ConnectionPersistenceError>; | ||
| 56 | readonly saveShell: ( | ||
| 57 | environmentId: EnvironmentId, | ||
| 58 | snapshot: OrchestrationShellSnapshot, | ||
| 59 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 60 | readonly loadThread: ( | ||
| 61 | environmentId: EnvironmentId, | ||
| 62 | threadId: ThreadId, | ||
| 63 | ) => Effect.Effect<Option.Option<OrchestrationThread>, ConnectionPersistenceError>; | ||
| 64 | readonly saveThread: ( | ||
| 65 | environmentId: EnvironmentId, | ||
| 66 | thread: OrchestrationThread, | ||
| 67 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 68 | readonly removeThread: ( | ||
| 69 | environmentId: EnvironmentId, | ||
| 70 | threadId: ThreadId, | ||
| 71 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 72 | readonly clear: ( | ||
| 73 | environmentId: EnvironmentId, | ||
| 74 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 75 | } | ||
| 76 | >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} | ||
| 77 | |||
| 78 | export class EnvironmentOwnedDataCleanup extends Context.Reference<{ | ||
| 79 | readonly clear: (environmentId: EnvironmentId) => Effect.Effect<void>; | ||
| 80 | }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { | ||
| 81 | defaultValue: () => ({ | ||
| 82 | clear: () => Effect.void, | ||
| 83 | }), | ||
| 84 | }) {} | ||
packages/client-runtime/src/platform/source.ts created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import type * as Stream from "effect/Stream"; | ||
| 3 | |||
| 4 | import type { PrimaryConnectionRegistration } from "../connection/catalog.ts"; | ||
| 5 | |||
| 6 | export class PlatformConnectionSource extends Context.Service< | ||
| 7 | PlatformConnectionSource, | ||
| 8 | { | ||
| 9 | readonly registrations: Stream.Stream<PrimaryConnectionRegistration>; | ||
| 10 | } | ||
| 11 | >()("@t3tools/client-runtime/platform/source/PlatformConnectionSource") {} | ||
packages/client-runtime/src/platform/storageDocument.test.ts created+146| ... | @@ -0,0 +1,146 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | |||
| 4 | import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; | ||
| 5 | import { | ||
| 6 | BearerConnectionCredential, | ||
| 7 | BearerConnectionProfile, | ||
| 8 | BearerConnectionRegistration, | ||
| 9 | RelayConnectionRegistration, | ||
| 10 | SshConnectionProfile, | ||
| 11 | SshConnectionRegistration, | ||
| 12 | } from "../connection/catalog.ts"; | ||
| 13 | import { | ||
| 14 | BearerConnectionTarget, | ||
| 15 | RelayConnectionTarget, | ||
| 16 | SshConnectionTarget, | ||
| 17 | } from "../connection/model.ts"; | ||
| 18 | import { | ||
| 19 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 20 | registerConnectionInCatalog, | ||
| 21 | removeConnectionFromCatalog, | ||
| 22 | } from "./storageDocument.ts"; | ||
| 23 | |||
| 24 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | ||
| 25 | |||
| 26 | const BEARER_TARGET = new BearerConnectionTarget({ | ||
| 27 | environmentId: ENVIRONMENT_ID, | ||
| 28 | label: "Remote", | ||
| 29 | connectionId: "bearer-1", | ||
| 30 | }); | ||
| 31 | const BEARER_PROFILE = new BearerConnectionProfile({ | ||
| 32 | connectionId: BEARER_TARGET.connectionId, | ||
| 33 | environmentId: ENVIRONMENT_ID, | ||
| 34 | label: BEARER_TARGET.label, | ||
| 35 | httpBaseUrl: "https://remote.example.test", | ||
| 36 | wsBaseUrl: "wss://remote.example.test", | ||
| 37 | }); | ||
| 38 | const BEARER_CREDENTIAL = new BearerConnectionCredential({ | ||
| 39 | token: "bearer-token", | ||
| 40 | }); | ||
| 41 | const REMOTE_TOKEN = new RemoteDpopAccessToken({ | ||
| 42 | environmentId: ENVIRONMENT_ID, | ||
| 43 | label: "Remote", | ||
| 44 | endpoint: { | ||
| 45 | httpBaseUrl: "https://remote.example.test", | ||
| 46 | wsBaseUrl: "wss://remote.example.test", | ||
| 47 | providerKind: "cloudflare_tunnel", | ||
| 48 | }, | ||
| 49 | accessToken: "dpop-token", | ||
| 50 | expiresAtEpochMs: 1_000_000, | ||
| 51 | dpopThumbprint: "thumbprint", | ||
| 52 | }); | ||
| 53 | |||
| 54 | describe("ConnectionCatalogDocument", () => { | ||
| 55 | it("registers a bearer connection as one catalog mutation", () => { | ||
| 56 | const document = registerConnectionInCatalog( | ||
| 57 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 58 | new BearerConnectionRegistration({ | ||
| 59 | target: BEARER_TARGET, | ||
| 60 | profile: BEARER_PROFILE, | ||
| 61 | credential: BEARER_CREDENTIAL, | ||
| 62 | }), | ||
| 63 | ); | ||
| 64 | |||
| 65 | expect(document.targets).toEqual([BEARER_TARGET]); | ||
| 66 | expect(document.profiles).toEqual([BEARER_PROFILE]); | ||
| 67 | expect(document.credentials).toEqual([ | ||
| 68 | { | ||
| 69 | connectionId: BEARER_TARGET.connectionId, | ||
| 70 | credential: BEARER_CREDENTIAL, | ||
| 71 | }, | ||
| 72 | ]); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("replaces obsolete connection metadata without discarding a reusable DPoP token", () => { | ||
| 76 | const bearer = registerConnectionInCatalog( | ||
| 77 | { | ||
| 78 | ...EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 79 | remoteDpopTokens: [REMOTE_TOKEN], | ||
| 80 | }, | ||
| 81 | new BearerConnectionRegistration({ | ||
| 82 | target: BEARER_TARGET, | ||
| 83 | profile: BEARER_PROFILE, | ||
| 84 | credential: BEARER_CREDENTIAL, | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | const relayTarget = new RelayConnectionTarget({ | ||
| 88 | environmentId: ENVIRONMENT_ID, | ||
| 89 | label: "Remote", | ||
| 90 | }); | ||
| 91 | const relay = registerConnectionInCatalog( | ||
| 92 | bearer, | ||
| 93 | new RelayConnectionRegistration({ target: relayTarget }), | ||
| 94 | ); | ||
| 95 | |||
| 96 | expect(relay.targets).toEqual([relayTarget]); | ||
| 97 | expect(relay.profiles).toEqual([]); | ||
| 98 | expect(relay.credentials).toEqual([]); | ||
| 99 | expect(relay.remoteDpopTokens).toEqual([REMOTE_TOKEN]); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("removes every catalog record owned by an explicit disconnect", () => { | ||
| 103 | const registered = registerConnectionInCatalog( | ||
| 104 | { | ||
| 105 | ...EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 106 | remoteDpopTokens: [REMOTE_TOKEN], | ||
| 107 | }, | ||
| 108 | new BearerConnectionRegistration({ | ||
| 109 | target: BEARER_TARGET, | ||
| 110 | profile: BEARER_PROFILE, | ||
| 111 | credential: BEARER_CREDENTIAL, | ||
| 112 | }), | ||
| 113 | ); | ||
| 114 | |||
| 115 | expect(removeConnectionFromCatalog(registered, BEARER_TARGET)).toEqual( | ||
| 116 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 117 | ); | ||
| 118 | }); | ||
| 119 | |||
| 120 | it("persists the normalized SSH profile beside its target", () => { | ||
| 121 | const target = new SshConnectionTarget({ | ||
| 122 | environmentId: ENVIRONMENT_ID, | ||
| 123 | label: "SSH", | ||
| 124 | connectionId: "ssh-1", | ||
| 125 | }); | ||
| 126 | const profile = new SshConnectionProfile({ | ||
| 127 | connectionId: target.connectionId, | ||
| 128 | environmentId: target.environmentId, | ||
| 129 | label: target.label, | ||
| 130 | target: { | ||
| 131 | alias: "devbox", | ||
| 132 | hostname: "devbox.example.test", | ||
| 133 | username: "developer", | ||
| 134 | port: 22, | ||
| 135 | }, | ||
| 136 | }); | ||
| 137 | const document = registerConnectionInCatalog( | ||
| 138 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 139 | new SshConnectionRegistration({ target, profile }), | ||
| 140 | ); | ||
| 141 | |||
| 142 | expect(document.targets).toEqual([target]); | ||
| 143 | expect(document.profiles).toEqual([profile]); | ||
| 144 | expect(document.credentials).toEqual([]); | ||
| 145 | }); | ||
| 146 | }); | ||
packages/client-runtime/src/platform/storageDocument.ts created+141| ... | @@ -0,0 +1,141 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | type ConnectionRegistration, | ||
| 5 | ConnectionCredential, | ||
| 6 | ConnectionProfile, | ||
| 7 | } from "../connection/catalog.ts"; | ||
| 8 | import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; | ||
| 9 | import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; | ||
| 10 | |||
| 11 | export const StoredConnectionCredential = Schema.Struct({ | ||
| 12 | connectionId: Schema.String, | ||
| 13 | credential: ConnectionCredential, | ||
| 14 | }); | ||
| 15 | export type StoredConnectionCredential = typeof StoredConnectionCredential.Type; | ||
| 16 | |||
| 17 | export const ConnectionCatalogDocument = Schema.Struct({ | ||
| 18 | schemaVersion: Schema.Literal(1), | ||
| 19 | targets: Schema.Array(PersistedConnectionTarget), | ||
| 20 | profiles: Schema.Array(ConnectionProfile), | ||
| 21 | credentials: Schema.Array(StoredConnectionCredential), | ||
| 22 | remoteDpopTokens: Schema.Array(RemoteDpopAccessToken), | ||
| 23 | }); | ||
| 24 | export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; | ||
| 25 | |||
| 26 | export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Object.freeze({ | ||
| 27 | schemaVersion: 1, | ||
| 28 | targets: [], | ||
| 29 | profiles: [], | ||
| 30 | credentials: [], | ||
| 31 | remoteDpopTokens: [], | ||
| 32 | }); | ||
| 33 | |||
| 34 | export function replaceCatalogValue<A>( | ||
| 35 | values: ReadonlyArray<A>, | ||
| 36 | key: (value: A) => string, | ||
| 37 | next: A, | ||
| 38 | ): ReadonlyArray<A> { | ||
| 39 | const nextKey = key(next); | ||
| 40 | return [...values.filter((value) => key(value) !== nextKey), next]; | ||
| 41 | } | ||
| 42 | |||
| 43 | export function removeCatalogValue<A>( | ||
| 44 | values: ReadonlyArray<A>, | ||
| 45 | key: (value: A) => string, | ||
| 46 | removedKey: string, | ||
| 47 | ): ReadonlyArray<A> { | ||
| 48 | return values.filter((value) => key(value) !== removedKey); | ||
| 49 | } | ||
| 50 | |||
| 51 | function connectionIdOf(target: ConnectionTarget): string | null { | ||
| 52 | switch (target._tag) { | ||
| 53 | case "PrimaryConnectionTarget": | ||
| 54 | case "RelayConnectionTarget": | ||
| 55 | return null; | ||
| 56 | case "BearerConnectionTarget": | ||
| 57 | case "SshConnectionTarget": | ||
| 58 | return target.connectionId; | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | function removeConnectionMetadata( | ||
| 63 | document: ConnectionCatalogDocument, | ||
| 64 | target: ConnectionTarget, | ||
| 65 | removeRemoteToken: boolean, | ||
| 66 | ): ConnectionCatalogDocument { | ||
| 67 | const connectionId = connectionIdOf(target); | ||
| 68 | return { | ||
| 69 | ...document, | ||
| 70 | targets: removeCatalogValue( | ||
| 71 | document.targets, | ||
| 72 | (value) => value.environmentId, | ||
| 73 | target.environmentId, | ||
| 74 | ), | ||
| 75 | profiles: | ||
| 76 | connectionId === null | ||
| 77 | ? document.profiles | ||
| 78 | : removeCatalogValue(document.profiles, (value) => value.connectionId, connectionId), | ||
| 79 | credentials: | ||
| 80 | connectionId === null | ||
| 81 | ? document.credentials | ||
| 82 | : removeCatalogValue(document.credentials, (value) => value.connectionId, connectionId), | ||
| 83 | remoteDpopTokens: removeRemoteToken | ||
| 84 | ? removeCatalogValue( | ||
| 85 | document.remoteDpopTokens, | ||
| 86 | (value) => value.environmentId, | ||
| 87 | target.environmentId, | ||
| 88 | ) | ||
| 89 | : document.remoteDpopTokens, | ||
| 90 | }; | ||
| 91 | } | ||
| 92 | |||
| 93 | export function registerConnectionInCatalog( | ||
| 94 | document: ConnectionCatalogDocument, | ||
| 95 | registration: ConnectionRegistration, | ||
| 96 | ): ConnectionCatalogDocument { | ||
| 97 | const target = registration.target; | ||
| 98 | const previous = document.targets.find( | ||
| 99 | (candidate) => candidate.environmentId === target.environmentId, | ||
| 100 | ); | ||
| 101 | const cleaned = | ||
| 102 | previous === undefined ? document : removeConnectionMetadata(document, previous, false); | ||
| 103 | const next: ConnectionCatalogDocument = { | ||
| 104 | ...cleaned, | ||
| 105 | targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target), | ||
| 106 | }; | ||
| 107 | |||
| 108 | switch (registration._tag) { | ||
| 109 | case "RelayConnectionRegistration": | ||
| 110 | return next; | ||
| 111 | case "BearerConnectionRegistration": | ||
| 112 | return { | ||
| 113 | ...next, | ||
| 114 | profiles: replaceCatalogValue( | ||
| 115 | next.profiles, | ||
| 116 | (value) => value.connectionId, | ||
| 117 | registration.profile, | ||
| 118 | ), | ||
| 119 | credentials: replaceCatalogValue(next.credentials, (value) => value.connectionId, { | ||
| 120 | connectionId: registration.target.connectionId, | ||
| 121 | credential: registration.credential, | ||
| 122 | }), | ||
| 123 | }; | ||
| 124 | case "SshConnectionRegistration": | ||
| 125 | return { | ||
| 126 | ...next, | ||
| 127 | profiles: replaceCatalogValue( | ||
| 128 | next.profiles, | ||
| 129 | (value) => value.connectionId, | ||
| 130 | registration.profile, | ||
| 131 | ), | ||
| 132 | }; | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | export function removeConnectionFromCatalog( | ||
| 137 | document: ConnectionCatalogDocument, | ||
| 138 | target: ConnectionTarget, | ||
| 139 | ): ConnectionCatalogDocument { | ||
| 140 | return removeConnectionMetadata(document, target, true); | ||
| 141 | } | ||
packages/client-runtime/src/projectPaths.ts deleted-221| ... | @@ -1,221 +0,0 @@ | ||
| 1 | import { | ||
| 2 | isExplicitRelativePath, | ||
| 3 | isUncPath, | ||
| 4 | isWindowsAbsolutePath, | ||
| 5 | isWindowsDrivePath, | ||
| 6 | } from "@t3tools/shared/path"; | ||
| 7 | |||
| 8 | function isWindowsPlatform(platform: string): boolean { | ||
| 9 | return /^win(dows)?/i.test(platform); | ||
| 10 | } | ||
| 11 | |||
| 12 | function isRootPath(value: string): boolean { | ||
| 13 | return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); | ||
| 14 | } | ||
| 15 | |||
| 16 | function getAbsolutePathKind(value: string): "unix" | "windows" | null { | ||
| 17 | if (isWindowsDrivePath(value) || isUncPath(value)) { | ||
| 18 | return "windows"; | ||
| 19 | } | ||
| 20 | if (value.startsWith("/")) { | ||
| 21 | return "unix"; | ||
| 22 | } | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | |||
| 26 | function trimTrailingPathSeparators(value: string): string { | ||
| 27 | if (value.length === 0 || isRootPath(value)) { | ||
| 28 | return value; | ||
| 29 | } | ||
| 30 | const trimmed = | ||
| 31 | getAbsolutePathKind(value) === "unix" | ||
| 32 | ? value.replace(/\/+$/g, "") | ||
| 33 | : value.replace(/[\\/]+$/g, ""); | ||
| 34 | if (trimmed.length === 0) { | ||
| 35 | return value; | ||
| 36 | } | ||
| 37 | return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; | ||
| 38 | } | ||
| 39 | |||
| 40 | function preferredPathSeparator(value: string): "/" | "\\" { | ||
| 41 | const absolutePathKind = getAbsolutePathKind(value); | ||
| 42 | if (absolutePathKind === "windows") return "\\"; | ||
| 43 | if (absolutePathKind === "unix") return "/"; | ||
| 44 | return value.includes("\\") ? "\\" : "/"; | ||
| 45 | } | ||
| 46 | |||
| 47 | export function hasTrailingPathSeparator(value: string): boolean { | ||
| 48 | return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value); | ||
| 49 | } | ||
| 50 | |||
| 51 | export { isExplicitRelativePath as isExplicitRelativeProjectPath }; | ||
| 52 | |||
| 53 | function splitPathSegments(value: string, separator: "/" | "\\"): string[] { | ||
| 54 | return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean); | ||
| 55 | } | ||
| 56 | |||
| 57 | function getLastPathSeparatorIndex(value: string): number { | ||
| 58 | if (getAbsolutePathKind(value) === "unix") { | ||
| 59 | return value.lastIndexOf("/"); | ||
| 60 | } | ||
| 61 | return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); | ||
| 62 | } | ||
| 63 | |||
| 64 | function splitAbsolutePath(value: string): { | ||
| 65 | root: string; | ||
| 66 | separator: "/" | "\\"; | ||
| 67 | segments: string[]; | ||
| 68 | } | null { | ||
| 69 | if (isWindowsDrivePath(value)) { | ||
| 70 | const root = `${value.slice(0, 2)}\\`; | ||
| 71 | const segments = splitPathSegments(value.slice(root.length), "\\"); | ||
| 72 | return { root, separator: "\\", segments }; | ||
| 73 | } | ||
| 74 | if (isUncPath(value)) { | ||
| 75 | const segments = splitPathSegments(value, "\\"); | ||
| 76 | const [server, share, ...rest] = segments; | ||
| 77 | if (!server || !share) return null; | ||
| 78 | return { | ||
| 79 | root: `\\\\${server}\\${share}\\`, | ||
| 80 | separator: "\\", | ||
| 81 | segments: rest, | ||
| 82 | }; | ||
| 83 | } | ||
| 84 | if (value.startsWith("/")) { | ||
| 85 | return { | ||
| 86 | root: "/", | ||
| 87 | separator: "/", | ||
| 88 | segments: splitPathSegments(value.slice(1), "/"), | ||
| 89 | }; | ||
| 90 | } | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | |||
| 94 | export function isFilesystemBrowseQuery(value: string, platform = ""): boolean { | ||
| 95 | const allowWindowsPaths = isWindowsPlatform(platform); | ||
| 96 | return ( | ||
| 97 | value.startsWith("./") || | ||
| 98 | value.startsWith("../") || | ||
| 99 | value.startsWith(".\\") || | ||
| 100 | value.startsWith("..\\") || | ||
| 101 | value.startsWith("/") || | ||
| 102 | value.startsWith("~/") || | ||
| 103 | (allowWindowsPaths && isWindowsAbsolutePath(value)) | ||
| 104 | ); | ||
| 105 | } | ||
| 106 | |||
| 107 | export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { | ||
| 108 | return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); | ||
| 109 | } | ||
| 110 | |||
| 111 | export function normalizeProjectPathForDispatch(value: string): string { | ||
| 112 | return trimTrailingPathSeparators(value.trim()); | ||
| 113 | } | ||
| 114 | |||
| 115 | export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { | ||
| 116 | const trimmedValue = value.trim(); | ||
| 117 | if (!isExplicitRelativePath(trimmedValue) || !cwd) { | ||
| 118 | return normalizeProjectPathForDispatch(trimmedValue); | ||
| 119 | } | ||
| 120 | |||
| 121 | const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); | ||
| 122 | if (!absoluteBase) { | ||
| 123 | return normalizeProjectPathForDispatch(trimmedValue); | ||
| 124 | } | ||
| 125 | |||
| 126 | const nextSegments = [...absoluteBase.segments]; | ||
| 127 | for (const segment of trimmedValue.split(/[\\/]+/)) { | ||
| 128 | if (segment.length === 0 || segment === ".") continue; | ||
| 129 | if (segment === "..") { | ||
| 130 | nextSegments.pop(); | ||
| 131 | continue; | ||
| 132 | } | ||
| 133 | nextSegments.push(segment); | ||
| 134 | } | ||
| 135 | |||
| 136 | const joinedPath = nextSegments.join(absoluteBase.separator); | ||
| 137 | return normalizeProjectPathForDispatch( | ||
| 138 | joinedPath.length === 0 ? absoluteBase.root : `${absoluteBase.root}${joinedPath}`, | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | |||
| 142 | export function normalizeProjectPathForComparison(value: string): string { | ||
| 143 | const normalized = normalizeProjectPathForDispatch(value); | ||
| 144 | if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { | ||
| 145 | return normalized.replaceAll("/", "\\").toLowerCase(); | ||
| 146 | } | ||
| 147 | return normalized; | ||
| 148 | } | ||
| 149 | |||
| 150 | export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>( | ||
| 151 | projects: ReadonlyArray<T>, | ||
| 152 | candidatePath: string, | ||
| 153 | ): T | undefined { | ||
| 154 | const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); | ||
| 155 | if (normalizedCandidate.length === 0) { | ||
| 156 | return undefined; | ||
| 157 | } | ||
| 158 | return projects.find((project) => { | ||
| 159 | const cwd = project.workspaceRoot ?? project.cwd; | ||
| 160 | return cwd ? normalizeProjectPathForComparison(cwd) === normalizedCandidate : false; | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | |||
| 164 | export function inferProjectTitleFromPath(value: string): string { | ||
| 165 | const normalized = normalizeProjectPathForDispatch(value); | ||
| 166 | const absolutePath = splitAbsolutePath(normalized); | ||
| 167 | if (absolutePath) { | ||
| 168 | return absolutePath.segments.findLast(Boolean) ?? normalized; | ||
| 169 | } | ||
| 170 | const segments = normalized.split(/[/\\]/); | ||
| 171 | return segments.findLast(Boolean) ?? normalized; | ||
| 172 | } | ||
| 173 | |||
| 174 | export function appendBrowsePathSegment(currentPath: string, segment: string): string { | ||
| 175 | const separator = preferredPathSeparator(currentPath); | ||
| 176 | return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; | ||
| 177 | } | ||
| 178 | |||
| 179 | export function getBrowseLeafPathSegment(currentPath: string): string { | ||
| 180 | const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); | ||
| 181 | return currentPath.slice(lastSeparatorIndex + 1); | ||
| 182 | } | ||
| 183 | |||
| 184 | export function getBrowseDirectoryPath(currentPath: string): string { | ||
| 185 | if (hasTrailingPathSeparator(currentPath)) { | ||
| 186 | return currentPath; | ||
| 187 | } | ||
| 188 | const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); | ||
| 189 | return lastSeparatorIndex < 0 ? currentPath : currentPath.slice(0, lastSeparatorIndex + 1); | ||
| 190 | } | ||
| 191 | |||
| 192 | export function ensureBrowseDirectoryPath(currentPath: string): string { | ||
| 193 | const trimmed = currentPath.trim(); | ||
| 194 | if (trimmed.length === 0 || hasTrailingPathSeparator(trimmed)) { | ||
| 195 | return trimmed; | ||
| 196 | } | ||
| 197 | return `${trimmed}${preferredPathSeparator(trimmed)}`; | ||
| 198 | } | ||
| 199 | |||
| 200 | export function getBrowseParentPath(currentPath: string): string | null { | ||
| 201 | const trimmed = trimTrailingPathSeparators(currentPath); | ||
| 202 | const absolutePath = splitAbsolutePath(trimmed); | ||
| 203 | if (absolutePath) { | ||
| 204 | if (absolutePath.segments.length === 0) return null; | ||
| 205 | if (absolutePath.segments.length === 1) return absolutePath.root; | ||
| 206 | const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); | ||
| 207 | return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; | ||
| 208 | } | ||
| 209 | |||
| 210 | const separator = preferredPathSeparator(currentPath); | ||
| 211 | const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed); | ||
| 212 | if (lastSeparatorIndex < 0) return null; | ||
| 213 | if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { | ||
| 214 | return `${trimmed.slice(0, 2)}${separator}`; | ||
| 215 | } | ||
| 216 | return trimmed.slice(0, lastSeparatorIndex + 1); | ||
| 217 | } | ||
| 218 | |||
| 219 | export function canNavigateUp(currentPath: string): boolean { | ||
| 220 | return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; | ||
| 221 | } | ||
packages/client-runtime/src/reconnectBackoff.test.ts deleted-65| ... | @@ -1,65 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | DEFAULT_RECONNECT_BACKOFF, | ||
| 5 | getReconnectDelayMs, | ||
| 6 | type ReconnectBackoffConfig, | ||
| 7 | } from "./reconnectBackoff.ts"; | ||
| 8 | |||
| 9 | describe("getReconnectDelayMs", () => { | ||
| 10 | it("returns exponential delays with default config", () => { | ||
| 11 | expect(getReconnectDelayMs(0)).toBe(1_000); | ||
| 12 | expect(getReconnectDelayMs(1)).toBe(2_000); | ||
| 13 | expect(getReconnectDelayMs(2)).toBe(4_000); | ||
| 14 | expect(getReconnectDelayMs(3)).toBe(8_000); | ||
| 15 | expect(getReconnectDelayMs(4)).toBe(16_000); | ||
| 16 | expect(getReconnectDelayMs(5)).toBe(32_000); | ||
| 17 | expect(getReconnectDelayMs(6)).toBe(64_000); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("returns null when retry index exceeds maxRetries", () => { | ||
| 21 | expect(getReconnectDelayMs(7)).toBeNull(); | ||
| 22 | expect(getReconnectDelayMs(100)).toBeNull(); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("returns null for negative indices", () => { | ||
| 26 | expect(getReconnectDelayMs(-1)).toBeNull(); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("returns null for non-integer indices", () => { | ||
| 30 | expect(getReconnectDelayMs(1.5)).toBeNull(); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("caps delay at maxDelayMs", () => { | ||
| 34 | const config: ReconnectBackoffConfig = { | ||
| 35 | initialDelayMs: 10_000, | ||
| 36 | backoffFactor: 10, | ||
| 37 | maxDelayMs: 30_000, | ||
| 38 | maxRetries: 5, | ||
| 39 | }; | ||
| 40 | |||
| 41 | expect(getReconnectDelayMs(0, config)).toBe(10_000); | ||
| 42 | expect(getReconnectDelayMs(1, config)).toBe(30_000); // 100_000 capped to 30_000 | ||
| 43 | expect(getReconnectDelayMs(2, config)).toBe(30_000); // 1_000_000 capped to 30_000 | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("supports unlimited retries when maxRetries is null", () => { | ||
| 47 | const config: ReconnectBackoffConfig = { | ||
| 48 | ...DEFAULT_RECONNECT_BACKOFF, | ||
| 49 | maxRetries: null, | ||
| 50 | }; | ||
| 51 | |||
| 52 | expect(getReconnectDelayMs(0, config)).toBe(1_000); | ||
| 53 | expect(getReconnectDelayMs(50, config)).toBe(64_000); // capped at maxDelayMs | ||
| 54 | expect(getReconnectDelayMs(100, config)).toBe(64_000); | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 58 | describe("DEFAULT_RECONNECT_BACKOFF", () => { | ||
| 59 | it("has sensible defaults", () => { | ||
| 60 | expect(DEFAULT_RECONNECT_BACKOFF.initialDelayMs).toBe(1_000); | ||
| 61 | expect(DEFAULT_RECONNECT_BACKOFF.backoffFactor).toBe(2); | ||
| 62 | expect(DEFAULT_RECONNECT_BACKOFF.maxDelayMs).toBe(64_000); | ||
| 63 | expect(DEFAULT_RECONNECT_BACKOFF.maxRetries).toBe(7); | ||
| 64 | }); | ||
| 65 | }); | ||
packages/client-runtime/src/reconnectBackoff.ts deleted-47| ... | @@ -1,47 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Configuration for exponential reconnect backoff. | ||
| 3 | */ | ||
| 4 | export interface ReconnectBackoffConfig { | ||
| 5 | /** Base delay in milliseconds before the first retry. */ | ||
| 6 | readonly initialDelayMs: number; | ||
| 7 | /** Multiplier applied per retry (exponential factor). */ | ||
| 8 | readonly backoffFactor: number; | ||
| 9 | /** Hard upper bound on delay in milliseconds. */ | ||
| 10 | readonly maxDelayMs: number; | ||
| 11 | /** Maximum number of retries (0-based). `null` means unlimited. */ | ||
| 12 | readonly maxRetries: number | null; | ||
| 13 | } | ||
| 14 | |||
| 15 | /** | ||
| 16 | * Sensible defaults for WebSocket reconnect backoff. | ||
| 17 | * | ||
| 18 | * - 1 s initial delay, doubling each retry, capped at 64 s, up to 7 retries. | ||
| 19 | */ | ||
| 20 | export const DEFAULT_RECONNECT_BACKOFF: ReconnectBackoffConfig = { | ||
| 21 | initialDelayMs: 1_000, | ||
| 22 | backoffFactor: 2, | ||
| 23 | maxDelayMs: 64_000, | ||
| 24 | maxRetries: 7, | ||
| 25 | }; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Calculate the reconnect delay for a given retry index using exponential | ||
| 29 | * backoff. Returns `null` when `retryIndex` exceeds the configured maximum. | ||
| 30 | */ | ||
| 31 | export function getReconnectDelayMs( | ||
| 32 | retryIndex: number, | ||
| 33 | config: ReconnectBackoffConfig = DEFAULT_RECONNECT_BACKOFF, | ||
| 34 | ): number | null { | ||
| 35 | if (!Number.isInteger(retryIndex) || retryIndex < 0) { | ||
| 36 | return null; | ||
| 37 | } | ||
| 38 | |||
| 39 | if (config.maxRetries !== null && retryIndex >= config.maxRetries) { | ||
| 40 | return null; | ||
| 41 | } | ||
| 42 | |||
| 43 | return Math.min( | ||
| 44 | Math.round(config.initialDelayMs * config.backoffFactor ** retryIndex), | ||
| 45 | config.maxDelayMs, | ||
| 46 | ); | ||
| 47 | } | ||
packages/client-runtime/src/relay/discovery.test.ts created+371| ... | @@ -0,0 +1,371 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { | ||
| 3 | RelayClientEnvironmentRecord, | ||
| 4 | RelayEnvironmentStatusResponse, | ||
| 5 | } from "@t3tools/contracts/relay"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Deferred from "effect/Deferred"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Fiber from "effect/Fiber"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Ref from "effect/Ref"; | ||
| 13 | import * as Stream from "effect/Stream"; | ||
| 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | ManagedRelayClient, | ||
| 18 | ManagedRelayClientError, | ||
| 19 | ManagedRelayRequestTimeoutError, | ||
| 20 | type ManagedRelayClientShape, | ||
| 21 | } from "./managedRelay.ts"; | ||
| 22 | import { CloudSession } from "../platform/capabilities.ts"; | ||
| 23 | import { Connectivity } from "../connection/connectivity.ts"; | ||
| 24 | import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts"; | ||
| 25 | import { ConnectionWakeups } from "../connection/wakeups.ts"; | ||
| 26 | import { RelayEnvironmentDiscovery, relayEnvironmentDiscoveryLayer } from "./discovery.ts"; | ||
| 27 | |||
| 28 | const environments = [ | ||
| 29 | { | ||
| 30 | environmentId: EnvironmentId.make("environment-1"), | ||
| 31 | label: "Environment One", | ||
| 32 | endpoint: { | ||
| 33 | httpBaseUrl: "https://one.example.test", | ||
| 34 | wsBaseUrl: "wss://one.example.test", | ||
| 35 | providerKind: "cloudflare_tunnel", | ||
| 36 | }, | ||
| 37 | linkedAt: "2026-06-01T00:00:00.000Z", | ||
| 38 | }, | ||
| 39 | { | ||
| 40 | environmentId: EnvironmentId.make("environment-2"), | ||
| 41 | label: "Environment Two", | ||
| 42 | endpoint: { | ||
| 43 | httpBaseUrl: "https://two.example.test", | ||
| 44 | wsBaseUrl: "wss://two.example.test", | ||
| 45 | providerKind: "cloudflare_tunnel", | ||
| 46 | }, | ||
| 47 | linkedAt: "2026-06-01T00:00:00.000Z", | ||
| 48 | }, | ||
| 49 | ] satisfies ReadonlyArray<RelayClientEnvironmentRecord>; | ||
| 50 | |||
| 51 | function status( | ||
| 52 | environment: RelayClientEnvironmentRecord, | ||
| 53 | value: "online" | "offline", | ||
| 54 | ): RelayEnvironmentStatusResponse { | ||
| 55 | return { | ||
| 56 | environmentId: environment.environmentId, | ||
| 57 | endpoint: environment.endpoint, | ||
| 58 | status: value, | ||
| 59 | checkedAt: "2026-06-01T00:00:00.000Z", | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { | ||
| 64 | const networkStatus = yield* SubscriptionRef.make<NetworkStatus>("online"); | ||
| 65 | const listCalls = yield* Ref.make(0); | ||
| 66 | const listFailure = yield* Ref.make<ManagedRelayClientError | null>(null); | ||
| 67 | const secondListCall = yield* Deferred.make<void>(); | ||
| 68 | const clerkToken = yield* Ref.make<string | null>("clerk-token"); | ||
| 69 | const wakeups = yield* SubscriptionRef.make<{ | ||
| 70 | readonly sequence: number; | ||
| 71 | readonly reason: "application-active" | "credentials-changed"; | ||
| 72 | }>({ | ||
| 73 | sequence: 0, | ||
| 74 | reason: "application-active", | ||
| 75 | }); | ||
| 76 | const statusRequests = yield* Ref.make( | ||
| 77 | new Map<string, Deferred.Deferred<RelayEnvironmentStatusResponse, ManagedRelayClientError>>(), | ||
| 78 | ); | ||
| 79 | for (const environment of environments) { | ||
| 80 | const request = yield* Deferred.make<RelayEnvironmentStatusResponse, ManagedRelayClientError>(); | ||
| 81 | yield* Ref.update(statusRequests, (current) => { | ||
| 82 | const next = new Map(current); | ||
| 83 | next.set(environment.environmentId, request); | ||
| 84 | return next; | ||
| 85 | }); | ||
| 86 | } | ||
| 87 | |||
| 88 | const client = ManagedRelayClient.of({ | ||
| 89 | relayUrl: "https://relay.example.test", | ||
| 90 | listEnvironments: () => | ||
| 91 | Effect.gen(function* () { | ||
| 92 | const count = yield* Ref.updateAndGet(listCalls, (current) => current + 1); | ||
| 93 | if (count >= 2) { | ||
| 94 | yield* Deferred.succeed(secondListCall, undefined); | ||
| 95 | } | ||
| 96 | const failure = yield* Ref.get(listFailure); | ||
| 97 | if (failure) { | ||
| 98 | return yield* failure; | ||
| 99 | } | ||
| 100 | return environments; | ||
| 101 | }), | ||
| 102 | getEnvironmentStatus: ({ environmentId }) => | ||
| 103 | Ref.get(statusRequests).pipe( | ||
| 104 | Effect.flatMap((requests) => Deferred.await(requests.get(environmentId)!)), | ||
| 105 | ), | ||
| 106 | listDevices: () => Effect.die("unused"), | ||
| 107 | createEnvironmentLinkChallenge: () => Effect.die("unused"), | ||
| 108 | linkEnvironment: () => Effect.die("unused"), | ||
| 109 | unlinkEnvironment: () => Effect.die("unused"), | ||
| 110 | connectEnvironment: () => Effect.die("unused"), | ||
| 111 | registerDevice: () => Effect.die("unused"), | ||
| 112 | unregisterDevice: () => Effect.die("unused"), | ||
| 113 | registerLiveActivity: () => Effect.die("unused"), | ||
| 114 | resetTokenCache: Effect.void, | ||
| 115 | } satisfies ManagedRelayClientShape); | ||
| 116 | const connectivity = Connectivity.of({ | ||
| 117 | status: SubscriptionRef.get(networkStatus), | ||
| 118 | changes: SubscriptionRef.changes(networkStatus), | ||
| 119 | }); | ||
| 120 | const layer = relayEnvironmentDiscoveryLayer.pipe( | ||
| 121 | Layer.provide( | ||
| 122 | Layer.mergeAll( | ||
| 123 | Layer.succeed(ManagedRelayClient, client), | ||
| 124 | Layer.succeed( | ||
| 125 | CloudSession, | ||
| 126 | CloudSession.of({ | ||
| 127 | clerkToken: Ref.get(clerkToken).pipe( | ||
| 128 | Effect.flatMap((token) => | ||
| 129 | token === null | ||
| 130 | ? Effect.fail( | ||
| 131 | new ConnectionBlockedError({ | ||
| 132 | reason: "authentication", | ||
| 133 | message: "Signed out.", | ||
| 134 | }), | ||
| 135 | ) | ||
| 136 | : Effect.succeed(token), | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | }), | ||
| 140 | ), | ||
| 141 | Layer.succeed(Connectivity, connectivity), | ||
| 142 | Layer.succeed( | ||
| 143 | ConnectionWakeups, | ||
| 144 | ConnectionWakeups.of({ | ||
| 145 | changes: SubscriptionRef.changes(wakeups).pipe( | ||
| 146 | Stream.drop(1), | ||
| 147 | Stream.map((event) => event.reason), | ||
| 148 | ), | ||
| 149 | }), | ||
| 150 | ), | ||
| 151 | ), | ||
| 152 | ), | ||
| 153 | ); | ||
| 154 | |||
| 155 | return { | ||
| 156 | layer, | ||
| 157 | listCalls, | ||
| 158 | listFailure, | ||
| 159 | clerkToken, | ||
| 160 | networkStatus, | ||
| 161 | secondListCall, | ||
| 162 | statusRequests, | ||
| 163 | wake: (reason: "application-active" | "credentials-changed") => | ||
| 164 | SubscriptionRef.update(wakeups, (event) => ({ | ||
| 165 | sequence: event.sequence + 1, | ||
| 166 | reason, | ||
| 167 | })), | ||
| 168 | }; | ||
| 169 | }); | ||
| 170 | |||
| 171 | describe("RelayEnvironmentDiscovery", () => { | ||
| 172 | it.effect("publishes each environment status as soon as that lookup completes", () => | ||
| 173 | Effect.gen(function* () { | ||
| 174 | const harness = yield* makeHarness(); | ||
| 175 | yield* Effect.gen(function* () { | ||
| 176 | const discovery = yield* RelayEnvironmentDiscovery; | ||
| 177 | const refreshFiber = yield* Effect.forkChild(discovery.refresh); | ||
| 178 | |||
| 179 | const checking = yield* SubscriptionRef.changes(discovery.state).pipe( | ||
| 180 | Stream.filter((state) => state.environments.size === 2), | ||
| 181 | Stream.runHead, | ||
| 182 | Effect.map(Option.getOrThrow), | ||
| 183 | ); | ||
| 184 | expect( | ||
| 185 | [...checking.environments.values()].every((entry) => entry.availability === "checking"), | ||
| 186 | ).toBe(true); | ||
| 187 | |||
| 188 | const requests = yield* Ref.get(harness.statusRequests); | ||
| 189 | yield* Deferred.succeed( | ||
| 190 | requests.get(environments[1]!.environmentId)!, | ||
| 191 | status(environments[1]!, "online"), | ||
| 192 | ); | ||
| 193 | |||
| 194 | const partiallyResolved = yield* SubscriptionRef.changes(discovery.state).pipe( | ||
| 195 | Stream.filter( | ||
| 196 | (state) => | ||
| 197 | state.environments.get(environments[1]!.environmentId)?.availability === "online", | ||
| 198 | ), | ||
| 199 | Stream.runHead, | ||
| 200 | Effect.map(Option.getOrThrow), | ||
| 201 | ); | ||
| 202 | expect( | ||
| 203 | partiallyResolved.environments.get(environments[0]!.environmentId)?.availability, | ||
| 204 | ).toBe("checking"); | ||
| 205 | |||
| 206 | yield* Deferred.succeed( | ||
| 207 | requests.get(environments[0]!.environmentId)!, | ||
| 208 | status(environments[0]!, "offline"), | ||
| 209 | ); | ||
| 210 | yield* Fiber.join(refreshFiber); | ||
| 211 | |||
| 212 | const complete = yield* SubscriptionRef.get(discovery.state); | ||
| 213 | expect(complete.environments.get(environments[0]!.environmentId)?.availability).toBe( | ||
| 214 | "offline", | ||
| 215 | ); | ||
| 216 | expect(complete.refreshing).toBe(false); | ||
| 217 | }).pipe(Effect.provide(harness.layer)); | ||
| 218 | }), | ||
| 219 | ); | ||
| 220 | |||
| 221 | it.effect( | ||
| 222 | "preserves discovered rows while offline and refreshes after connectivity returns", | ||
| 223 | () => | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const harness = yield* makeHarness(); | ||
| 226 | yield* Effect.gen(function* () { | ||
| 227 | const discovery = yield* RelayEnvironmentDiscovery; | ||
| 228 | const requests = yield* Ref.get(harness.statusRequests); | ||
| 229 | for (const environment of environments) { | ||
| 230 | yield* Deferred.succeed( | ||
| 231 | requests.get(environment.environmentId)!, | ||
| 232 | status(environment, "online"), | ||
| 233 | ); | ||
| 234 | } | ||
| 235 | yield* discovery.refresh; | ||
| 236 | |||
| 237 | const offlineFiber = yield* SubscriptionRef.changes(discovery.state).pipe( | ||
| 238 | Stream.filter((state) => state.offline), | ||
| 239 | Stream.runHead, | ||
| 240 | Effect.forkChild, | ||
| 241 | ); | ||
| 242 | yield* SubscriptionRef.set(harness.networkStatus, "offline"); | ||
| 243 | yield* Fiber.join(offlineFiber); | ||
| 244 | expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(2); | ||
| 245 | |||
| 246 | yield* SubscriptionRef.set(harness.networkStatus, "online"); | ||
| 247 | yield* Deferred.await(harness.secondListCall); | ||
| 248 | expect(yield* Ref.get(harness.listCalls)).toBe(2); | ||
| 249 | }).pipe(Effect.provide(harness.layer)); | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("publishes listing failures without rejecting the refresh command", () => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const networkStatus = yield* SubscriptionRef.make<NetworkStatus>("online"); | ||
| 256 | const client = ManagedRelayClient.of({ | ||
| 257 | relayUrl: "https://relay.example.test", | ||
| 258 | listEnvironments: () => | ||
| 259 | Effect.fail( | ||
| 260 | new ManagedRelayClientError({ | ||
| 261 | message: "Relay environment listing timed out.", | ||
| 262 | cause: new ManagedRelayRequestTimeoutError({ | ||
| 263 | message: "Relay environment listing timed out.", | ||
| 264 | }), | ||
| 265 | }), | ||
| 266 | ), | ||
| 267 | getEnvironmentStatus: () => Effect.die("unused"), | ||
| 268 | listDevices: () => Effect.die("unused"), | ||
| 269 | createEnvironmentLinkChallenge: () => Effect.die("unused"), | ||
| 270 | linkEnvironment: () => Effect.die("unused"), | ||
| 271 | unlinkEnvironment: () => Effect.die("unused"), | ||
| 272 | connectEnvironment: () => Effect.die("unused"), | ||
| 273 | registerDevice: () => Effect.die("unused"), | ||
| 274 | unregisterDevice: () => Effect.die("unused"), | ||
| 275 | registerLiveActivity: () => Effect.die("unused"), | ||
| 276 | resetTokenCache: Effect.void, | ||
| 277 | } satisfies ManagedRelayClientShape); | ||
| 278 | const layer = relayEnvironmentDiscoveryLayer.pipe( | ||
| 279 | Layer.provide( | ||
| 280 | Layer.mergeAll( | ||
| 281 | Layer.succeed(ManagedRelayClient, client), | ||
| 282 | Layer.succeed(CloudSession, { | ||
| 283 | clerkToken: Effect.succeed("clerk-token"), | ||
| 284 | }), | ||
| 285 | Layer.succeed(Connectivity, { | ||
| 286 | status: SubscriptionRef.get(networkStatus), | ||
| 287 | changes: SubscriptionRef.changes(networkStatus), | ||
| 288 | }), | ||
| 289 | Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), | ||
| 290 | ), | ||
| 291 | ), | ||
| 292 | ); | ||
| 293 | |||
| 294 | yield* Effect.gen(function* () { | ||
| 295 | const discovery = yield* RelayEnvironmentDiscovery; | ||
| 296 | yield* discovery.refresh; | ||
| 297 | |||
| 298 | const state = yield* SubscriptionRef.get(discovery.state); | ||
| 299 | expect(state.refreshing).toBe(false); | ||
| 300 | expect(Option.getOrThrow(state.error)).toMatchObject({ | ||
| 301 | _tag: "ConnectionTransientError", | ||
| 302 | reason: "timeout", | ||
| 303 | message: "Relay environment listing timed out.", | ||
| 304 | }); | ||
| 305 | }).pipe(Effect.provide(layer)); | ||
| 306 | }), | ||
| 307 | ); | ||
| 308 | |||
| 309 | it.effect("clears previously discovered rows when a refresh fails", () => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const harness = yield* makeHarness(); | ||
| 312 | yield* Effect.gen(function* () { | ||
| 313 | const discovery = yield* RelayEnvironmentDiscovery; | ||
| 314 | const requests = yield* Ref.get(harness.statusRequests); | ||
| 315 | for (const environment of environments) { | ||
| 316 | yield* Deferred.succeed( | ||
| 317 | requests.get(environment.environmentId)!, | ||
| 318 | status(environment, "online"), | ||
| 319 | ); | ||
| 320 | } | ||
| 321 | yield* discovery.refresh; | ||
| 322 | expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(2); | ||
| 323 | |||
| 324 | yield* Ref.set( | ||
| 325 | harness.listFailure, | ||
| 326 | new ManagedRelayClientError({ | ||
| 327 | message: "Relay environment listing failed.", | ||
| 328 | }), | ||
| 329 | ); | ||
| 330 | yield* discovery.refresh; | ||
| 331 | |||
| 332 | const failed = yield* SubscriptionRef.get(discovery.state); | ||
| 333 | expect(failed.environments.size).toBe(0); | ||
| 334 | expect(Option.isSome(failed.error)).toBe(true); | ||
| 335 | }).pipe(Effect.provide(harness.layer)); | ||
| 336 | }), | ||
| 337 | ); | ||
| 338 | |||
| 339 | it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () => | ||
| 340 | Effect.gen(function* () { | ||
| 341 | const harness = yield* makeHarness(); | ||
| 342 | yield* Effect.gen(function* () { | ||
| 343 | const discovery = yield* RelayEnvironmentDiscovery; | ||
| 344 | const refreshFiber = yield* Effect.forkChild(discovery.refresh); | ||
| 345 | yield* SubscriptionRef.changes(discovery.state).pipe( | ||
| 346 | Stream.filter((state) => state.environments.size === environments.length), | ||
| 347 | Stream.runHead, | ||
| 348 | ); | ||
| 349 | |||
| 350 | yield* Ref.set(harness.clerkToken, null); | ||
| 351 | yield* harness.wake("credentials-changed"); | ||
| 352 | yield* SubscriptionRef.changes(discovery.state).pipe( | ||
| 353 | Stream.filter((state) => state.environments.size === 0), | ||
| 354 | Stream.runHead, | ||
| 355 | ); | ||
| 356 | |||
| 357 | const requests = yield* Ref.get(harness.statusRequests); | ||
| 358 | for (const environment of environments) { | ||
| 359 | yield* Deferred.succeed( | ||
| 360 | requests.get(environment.environmentId)!, | ||
| 361 | status(environment, "online"), | ||
| 362 | ); | ||
| 363 | } | ||
| 364 | yield* Fiber.join(refreshFiber); | ||
| 365 | yield* Effect.yieldNow; | ||
| 366 | |||
| 367 | expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(0); | ||
| 368 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 369 | }), | ||
| 370 | ); | ||
| 371 | }); | ||
packages/client-runtime/src/relay/discovery.ts created+333| ... | @@ -0,0 +1,333 @@ | ||
| 1 | import type { | ||
| 2 | RelayClientEnvironmentRecord, | ||
| 3 | RelayEnvironmentStatusResponse, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; | ||
| 6 | import { | ||
| 7 | RelayEnvironmentConnectScope, | ||
| 8 | RelayEnvironmentStatusScope, | ||
| 9 | } from "@t3tools/contracts/relay"; | ||
| 10 | import * as Context from "effect/Context"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Ref from "effect/Ref"; | ||
| 15 | import * as Semaphore from "effect/Semaphore"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 18 | |||
| 19 | import { ManagedRelayClient } from "./managedRelay.ts"; | ||
| 20 | import { CloudSession } from "../platform/capabilities.ts"; | ||
| 21 | import { Connectivity } from "../connection/connectivity.ts"; | ||
| 22 | import { mapManagedRelayError } from "../connection/errors.ts"; | ||
| 23 | import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; | ||
| 24 | import { ConnectionWakeups } from "../connection/wakeups.ts"; | ||
| 25 | |||
| 26 | export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error"; | ||
| 27 | |||
| 28 | export interface RelayDiscoveredEnvironment { | ||
| 29 | readonly environment: RelayClientEnvironmentRecord; | ||
| 30 | readonly availability: RelayEnvironmentAvailability; | ||
| 31 | readonly status: Option.Option<RelayEnvironmentStatusResponse>; | ||
| 32 | readonly error: Option.Option<ConnectionAttemptError>; | ||
| 33 | } | ||
| 34 | |||
| 35 | export interface RelayEnvironmentDiscoveryState { | ||
| 36 | readonly environments: ReadonlyMap<string, RelayDiscoveredEnvironment>; | ||
| 37 | readonly refreshing: boolean; | ||
| 38 | readonly offline: boolean; | ||
| 39 | readonly error: Option.Option<ConnectionAttemptError>; | ||
| 40 | } | ||
| 41 | |||
| 42 | export interface RelayEnvironmentDiscoveryService { | ||
| 43 | readonly state: SubscriptionRef.SubscriptionRef<RelayEnvironmentDiscoveryState>; | ||
| 44 | readonly refresh: Effect.Effect<void>; | ||
| 45 | } | ||
| 46 | |||
| 47 | export class RelayEnvironmentDiscovery extends Context.Service< | ||
| 48 | RelayEnvironmentDiscovery, | ||
| 49 | RelayEnvironmentDiscoveryService | ||
| 50 | >()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {} | ||
| 51 | |||
| 52 | export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = { | ||
| 53 | environments: new Map(), | ||
| 54 | refreshing: false, | ||
| 55 | offline: false, | ||
| 56 | error: Option.none(), | ||
| 57 | }; | ||
| 58 | |||
| 59 | function validateStatus( | ||
| 60 | environment: RelayClientEnvironmentRecord, | ||
| 61 | status: RelayEnvironmentStatusResponse, | ||
| 62 | ): Effect.Effect<RelayEnvironmentStatusResponse, ConnectionAttemptError> { | ||
| 63 | if (status.environmentId !== environment.environmentId) { | ||
| 64 | return Effect.fail( | ||
| 65 | new ConnectionBlockedError({ | ||
| 66 | reason: "configuration", | ||
| 67 | message: "Relay returned status for a different environment.", | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | if ( | ||
| 72 | status.endpoint.httpBaseUrl !== environment.endpoint.httpBaseUrl || | ||
| 73 | status.endpoint.wsBaseUrl !== environment.endpoint.wsBaseUrl || | ||
| 74 | status.endpoint.providerKind !== environment.endpoint.providerKind | ||
| 75 | ) { | ||
| 76 | return Effect.fail( | ||
| 77 | new ConnectionBlockedError({ | ||
| 78 | reason: "configuration", | ||
| 79 | message: "Relay returned status for a different environment endpoint.", | ||
| 80 | }), | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | if ( | ||
| 84 | status.descriptor !== undefined && | ||
| 85 | status.descriptor.environmentId !== environment.environmentId | ||
| 86 | ) { | ||
| 87 | return Effect.fail( | ||
| 88 | new ConnectionBlockedError({ | ||
| 89 | reason: "configuration", | ||
| 90 | message: "Relay returned a descriptor for a different environment.", | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | return Effect.succeed(status); | ||
| 95 | } | ||
| 96 | |||
| 97 | function relayAccountId(clerkToken: string): Option.Option<string> { | ||
| 98 | try { | ||
| 99 | return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe( | ||
| 100 | Option.filter((subject) => subject.length > 0), | ||
| 101 | ); | ||
| 102 | } catch { | ||
| 103 | return Option.none(); | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { | ||
| 108 | const relay = yield* ManagedRelayClient; | ||
| 109 | const session = yield* CloudSession; | ||
| 110 | const connectivity = yield* Connectivity; | ||
| 111 | const wakeups = yield* ConnectionWakeups; | ||
| 112 | const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); | ||
| 113 | const refreshLock = yield* Semaphore.make(1); | ||
| 114 | const hasRefreshed = yield* Ref.make(false); | ||
| 115 | const accountGeneration = yield* Ref.make(0); | ||
| 116 | const activeAccountId = yield* Ref.make<Option.Option<string>>(Option.none()); | ||
| 117 | const refreshGeneration = yield* Ref.make(0); | ||
| 118 | const offlineReportFingerprints = yield* Ref.make<ReadonlyMap<string, string>>(new Map()); | ||
| 119 | |||
| 120 | const clearOfflineReport = Effect.fn("RelayEnvironmentDiscovery.clearOfflineReport")(function* ( | ||
| 121 | environmentId: string, | ||
| 122 | ) { | ||
| 123 | yield* Ref.update(offlineReportFingerprints, (current) => { | ||
| 124 | if (!current.has(environmentId)) { | ||
| 125 | return current; | ||
| 126 | } | ||
| 127 | const next = new Map(current); | ||
| 128 | next.delete(environmentId); | ||
| 129 | return next; | ||
| 130 | }); | ||
| 131 | }); | ||
| 132 | |||
| 133 | const updateEnvironment = Effect.fn("RelayEnvironmentDiscovery.updateEnvironment")(function* ( | ||
| 134 | generation: number, | ||
| 135 | environmentId: string, | ||
| 136 | update: (current: RelayDiscoveredEnvironment) => RelayDiscoveredEnvironment, | ||
| 137 | ) { | ||
| 138 | if ((yield* Ref.get(accountGeneration)) !== generation) { | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | yield* SubscriptionRef.update(state, (current) => { | ||
| 142 | const entry = current.environments.get(environmentId); | ||
| 143 | if (entry === undefined) { | ||
| 144 | return current; | ||
| 145 | } | ||
| 146 | const environments = new Map(current.environments); | ||
| 147 | environments.set(environmentId, update(entry)); | ||
| 148 | return { ...current, environments }; | ||
| 149 | }); | ||
| 150 | }); | ||
| 151 | |||
| 152 | const refreshStatus = Effect.fn("RelayEnvironmentDiscovery.refreshStatus")(function* ( | ||
| 153 | generation: number, | ||
| 154 | clerkToken: string, | ||
| 155 | environment: RelayClientEnvironmentRecord, | ||
| 156 | ) { | ||
| 157 | const result = yield* relay | ||
| 158 | .getEnvironmentStatus({ | ||
| 159 | clerkToken, | ||
| 160 | scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], | ||
| 161 | environmentId: environment.environmentId, | ||
| 162 | }) | ||
| 163 | .pipe( | ||
| 164 | Effect.mapError(mapManagedRelayError), | ||
| 165 | Effect.flatMap((status) => validateStatus(environment, status)), | ||
| 166 | Effect.result, | ||
| 167 | ); | ||
| 168 | |||
| 169 | if (result._tag === "Success") { | ||
| 170 | if (result.success.status === "offline") { | ||
| 171 | const fingerprint = `${result.success.endpoint.httpBaseUrl}\n${result.success.error ?? ""}`; | ||
| 172 | const shouldReport = yield* Ref.modify(offlineReportFingerprints, (current) => { | ||
| 173 | if (current.get(environment.environmentId) === fingerprint) { | ||
| 174 | return [false, current]; | ||
| 175 | } | ||
| 176 | return [true, new Map(current).set(environment.environmentId, fingerprint)]; | ||
| 177 | }); | ||
| 178 | if (shouldReport) { | ||
| 179 | yield* Effect.logWarning("Relay environment health check reported offline", { | ||
| 180 | environmentId: result.success.environmentId, | ||
| 181 | endpoint: result.success.endpoint.httpBaseUrl, | ||
| 182 | message: result.success.error, | ||
| 183 | traceId: result.success.traceId, | ||
| 184 | }); | ||
| 185 | } | ||
| 186 | } else { | ||
| 187 | yield* clearOfflineReport(environment.environmentId); | ||
| 188 | } | ||
| 189 | yield* updateEnvironment(generation, environment.environmentId, (current) => ({ | ||
| 190 | ...current, | ||
| 191 | availability: result.success.status, | ||
| 192 | status: Option.some(result.success), | ||
| 193 | error: Option.none(), | ||
| 194 | })); | ||
| 195 | return; | ||
| 196 | } | ||
| 197 | |||
| 198 | yield* clearOfflineReport(environment.environmentId); | ||
| 199 | yield* updateEnvironment(generation, environment.environmentId, (current) => ({ | ||
| 200 | ...current, | ||
| 201 | availability: "error", | ||
| 202 | error: Option.some(result.failure), | ||
| 203 | })); | ||
| 204 | }); | ||
| 205 | |||
| 206 | const refresh = refreshLock.withPermits(1)( | ||
| 207 | Effect.gen(function* () { | ||
| 208 | yield* Ref.set(hasRefreshed, true); | ||
| 209 | if ((yield* connectivity.status) === "offline") { | ||
| 210 | yield* SubscriptionRef.update(state, (current) => ({ | ||
| 211 | ...current, | ||
| 212 | refreshing: false, | ||
| 213 | offline: true, | ||
| 214 | })); | ||
| 215 | return; | ||
| 216 | } | ||
| 217 | |||
| 218 | let generation = yield* Ref.get(accountGeneration); | ||
| 219 | yield* Ref.set(refreshGeneration, generation); | ||
| 220 | yield* SubscriptionRef.set(state, { | ||
| 221 | environments: new Map(), | ||
| 222 | refreshing: true, | ||
| 223 | offline: false, | ||
| 224 | error: Option.none(), | ||
| 225 | }); | ||
| 226 | |||
| 227 | const clerkToken = yield* session.clerkToken; | ||
| 228 | if ((yield* Ref.get(accountGeneration)) !== generation) { | ||
| 229 | return; | ||
| 230 | } | ||
| 231 | const accountId = relayAccountId(clerkToken); | ||
| 232 | const previousAccountId = yield* Ref.get(activeAccountId); | ||
| 233 | if ( | ||
| 234 | Option.isSome(previousAccountId) && | ||
| 235 | (!Option.isSome(accountId) || previousAccountId.value !== accountId.value) | ||
| 236 | ) { | ||
| 237 | generation = yield* Ref.updateAndGet(accountGeneration, (current) => current + 1); | ||
| 238 | yield* Ref.set(refreshGeneration, generation); | ||
| 239 | } | ||
| 240 | yield* Ref.set(activeAccountId, accountId); | ||
| 241 | |||
| 242 | const environments = yield* relay | ||
| 243 | .listEnvironments({ clerkToken }) | ||
| 244 | .pipe(Effect.mapError(mapManagedRelayError)); | ||
| 245 | if ((yield* Ref.get(accountGeneration)) !== generation) { | ||
| 246 | return; | ||
| 247 | } | ||
| 248 | const next = new Map<string, RelayDiscoveredEnvironment>(); | ||
| 249 | for (const environment of environments) { | ||
| 250 | next.set(environment.environmentId, { | ||
| 251 | environment, | ||
| 252 | availability: "checking", | ||
| 253 | status: Option.none(), | ||
| 254 | error: Option.none(), | ||
| 255 | }); | ||
| 256 | } | ||
| 257 | yield* SubscriptionRef.update(state, (current) => ({ | ||
| 258 | ...current, | ||
| 259 | environments: next, | ||
| 260 | })); | ||
| 261 | |||
| 262 | yield* Effect.forEach( | ||
| 263 | environments, | ||
| 264 | (environment) => refreshStatus(generation, clerkToken, environment), | ||
| 265 | { | ||
| 266 | concurrency: "unbounded", | ||
| 267 | discard: true, | ||
| 268 | }, | ||
| 269 | ); | ||
| 270 | if ((yield* Ref.get(accountGeneration)) !== generation) { | ||
| 271 | return; | ||
| 272 | } | ||
| 273 | yield* SubscriptionRef.update(state, (current) => ({ | ||
| 274 | ...current, | ||
| 275 | refreshing: false, | ||
| 276 | })); | ||
| 277 | }).pipe( | ||
| 278 | Effect.catch((error) => | ||
| 279 | Effect.gen(function* () { | ||
| 280 | const generation = yield* Ref.get(refreshGeneration); | ||
| 281 | if ((yield* Ref.get(accountGeneration)) !== generation) { | ||
| 282 | return; | ||
| 283 | } | ||
| 284 | yield* SubscriptionRef.update(state, (current) => ({ | ||
| 285 | ...current, | ||
| 286 | refreshing: false, | ||
| 287 | error: Option.some(error), | ||
| 288 | })); | ||
| 289 | }), | ||
| 290 | ), | ||
| 291 | ), | ||
| 292 | ); | ||
| 293 | |||
| 294 | yield* connectivity.changes.pipe( | ||
| 295 | Stream.changes, | ||
| 296 | Stream.runForEach((networkStatus) => | ||
| 297 | networkStatus === "offline" | ||
| 298 | ? SubscriptionRef.update(state, (current) => ({ | ||
| 299 | ...current, | ||
| 300 | refreshing: false, | ||
| 301 | offline: true, | ||
| 302 | })) | ||
| 303 | : Ref.get(hasRefreshed).pipe( | ||
| 304 | Effect.flatMap((shouldRefresh) => (shouldRefresh ? refresh : Effect.void)), | ||
| 305 | ), | ||
| 306 | ), | ||
| 307 | Effect.forkScoped, | ||
| 308 | ); | ||
| 309 | yield* wakeups.changes.pipe( | ||
| 310 | Stream.runForEach((reason) => | ||
| 311 | reason === "credentials-changed" | ||
| 312 | ? Effect.gen(function* () { | ||
| 313 | yield* Ref.update(accountGeneration, (current) => current + 1); | ||
| 314 | yield* Ref.set(activeAccountId, Option.none()); | ||
| 315 | yield* Ref.set(offlineReportFingerprints, new Map()); | ||
| 316 | const shouldRefresh = yield* Ref.get(hasRefreshed); | ||
| 317 | yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); | ||
| 318 | if (shouldRefresh) { | ||
| 319 | yield* refresh.pipe(Effect.forkScoped); | ||
| 320 | } | ||
| 321 | }) | ||
| 322 | : Effect.void, | ||
| 323 | ), | ||
| 324 | Effect.forkScoped, | ||
| 325 | ); | ||
| 326 | |||
| 327 | return RelayEnvironmentDiscovery.of({ state, refresh }); | ||
| 328 | }); | ||
| 329 | |||
| 330 | export const relayEnvironmentDiscoveryLayer = Layer.effect( | ||
| 331 | RelayEnvironmentDiscovery, | ||
| 332 | makeRelayEnvironmentDiscovery(), | ||
| 333 | ); | ||
packages/client-runtime/src/relay/index.ts created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | export * from "./discovery.ts"; | ||
| 2 | export * from "./managedRelay.ts"; | ||
| 3 | export * from "./managedRelayState.ts"; | ||
packages/client-runtime/src/relay/managedRelay.test.ts created+515| ... | @@ -0,0 +1,515 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as Duration from "effect/Duration"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Tracer from "effect/Tracer"; | ||
| 9 | import * as TestClock from "effect/testing/TestClock"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | MANAGED_RELAY_REQUEST_TIMEOUT_MS, | ||
| 13 | ManagedRelayClient, | ||
| 14 | ManagedRelayDpopSigner, | ||
| 15 | managedRelayClientLayer, | ||
| 16 | type ManagedRelayAccessTokenCacheEntry, | ||
| 17 | type ManagedRelayAccessTokenStore, | ||
| 18 | type ManagedRelayDpopProofInput, | ||
| 19 | } from "./managedRelay.ts"; | ||
| 20 | import { remoteHttpClientLayer } from "../rpc/http.ts"; | ||
| 21 | |||
| 22 | function managedRelayTestLayer( | ||
| 23 | fetchFn: typeof globalThis.fetch, | ||
| 24 | relayUrl = "https://relay.example.test", | ||
| 25 | accessTokenStore?: ManagedRelayAccessTokenStore, | ||
| 26 | ) { | ||
| 27 | const httpClientLayer = remoteHttpClientLayer(fetchFn); | ||
| 28 | const signerLayer = Layer.succeed( | ||
| 29 | ManagedRelayDpopSigner, | ||
| 30 | ManagedRelayDpopSigner.of({ | ||
| 31 | thumbprint: Effect.succeed("client-thumbprint"), | ||
| 32 | createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), | ||
| 33 | }), | ||
| 34 | ); | ||
| 35 | return managedRelayClientLayer({ | ||
| 36 | relayUrl, | ||
| 37 | clientId: "t3-mobile", | ||
| 38 | ...(accessTokenStore ? { accessTokenStore } : {}), | ||
| 39 | }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); | ||
| 40 | } | ||
| 41 | |||
| 42 | function clerkToken(subject: string, nonce: string): string { | ||
| 43 | const encode = (value: unknown) => | ||
| 44 | btoa(JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); | ||
| 45 | return `${encode({ alg: "none" })}.${encode({ sub: subject, nonce })}.signature`; | ||
| 46 | } | ||
| 47 | |||
| 48 | describe("ManagedRelayClient", () => { | ||
| 49 | it.effect("owns tracing at service and implementation boundaries", () => { | ||
| 50 | const spanNames: Array<string> = []; | ||
| 51 | const tracer = Tracer.make({ | ||
| 52 | span: (options) => { | ||
| 53 | const span = new Tracer.NativeSpan(options); | ||
| 54 | spanNames.push(span.name); | ||
| 55 | return span; | ||
| 56 | }, | ||
| 57 | }); | ||
| 58 | const fetchFn = ((input) => { | ||
| 59 | const url = String(input); | ||
| 60 | if (url.endsWith("/v1/client/dpop-token")) { | ||
| 61 | return Promise.resolve( | ||
| 62 | Response.json({ | ||
| 63 | access_token: "relay-token", | ||
| 64 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 65 | token_type: "DPoP", | ||
| 66 | expires_in: 1_800, | ||
| 67 | scope: RelayEnvironmentStatusScope, | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | return Promise.resolve( | ||
| 72 | Response.json({ | ||
| 73 | environmentId: "env-1", | ||
| 74 | endpoint: { | ||
| 75 | httpBaseUrl: "https://desktop.example.test/", | ||
| 76 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 77 | providerKind: "cloudflare_tunnel", | ||
| 78 | }, | ||
| 79 | status: "online", | ||
| 80 | checkedAt: "2026-06-05T20:00:00.000Z", | ||
| 81 | descriptor: { | ||
| 82 | environmentId: "env-1", | ||
| 83 | label: "Desktop", | ||
| 84 | platform: { os: "darwin", arch: "arm64" }, | ||
| 85 | serverVersion: "0.0.0-test", | ||
| 86 | capabilities: { repositoryIdentity: true }, | ||
| 87 | }, | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | }) satisfies typeof globalThis.fetch; | ||
| 91 | |||
| 92 | return Effect.gen(function* () { | ||
| 93 | const relayClient = yield* ManagedRelayClient; | ||
| 94 | yield* relayClient.getEnvironmentStatus({ | ||
| 95 | clerkToken: clerkToken("user-1", "session-1"), | ||
| 96 | scopes: [RelayEnvironmentStatusScope], | ||
| 97 | environmentId: EnvironmentId.make("env-1"), | ||
| 98 | }); | ||
| 99 | |||
| 100 | expect(spanNames).toEqual( | ||
| 101 | expect.arrayContaining([ | ||
| 102 | "clientRuntime.managedRelay.getEnvironmentStatus", | ||
| 103 | "clientRuntime.managedRelay.authorize", | ||
| 104 | "clientRuntime.managedRelay.obtainAccessToken", | ||
| 105 | "clientRuntime.managedRelay.tokenCacheCriticalSection", | ||
| 106 | "clientRuntime.managedRelay.exchangeAccessToken", | ||
| 107 | ]), | ||
| 108 | ); | ||
| 109 | expect(spanNames).not.toEqual( | ||
| 110 | expect.arrayContaining([ | ||
| 111 | "clientRuntime.managedRelay.createTokenExchangeProof", | ||
| 112 | "clientRuntime.managedRelay.exchangeAccessTokenRequest", | ||
| 113 | "clientRuntime.managedRelay.createRequestProof", | ||
| 114 | ]), | ||
| 115 | ); | ||
| 116 | }).pipe(Effect.withTracer(tracer), Effect.provide(managedRelayTestLayer(fetchFn))); | ||
| 117 | }); | ||
| 118 | |||
| 119 | it.effect("rejects unsafe relay URLs before sending credentials", () => { | ||
| 120 | let requestCount = 0; | ||
| 121 | const fetchFn = (() => { | ||
| 122 | requestCount += 1; | ||
| 123 | return Promise.resolve(Response.json({})); | ||
| 124 | }) satisfies typeof globalThis.fetch; | ||
| 125 | |||
| 126 | return Effect.gen(function* () { | ||
| 127 | const relayClient = yield* ManagedRelayClient; | ||
| 128 | const error = yield* relayClient | ||
| 129 | .listEnvironments({ clerkToken: "clerk-token" }) | ||
| 130 | .pipe(Effect.flip); | ||
| 131 | |||
| 132 | expect(error).toMatchObject({ | ||
| 133 | _tag: "ManagedRelayClientError", | ||
| 134 | message: "Relay URL must be a secure absolute HTTPS origin.", | ||
| 135 | }); | ||
| 136 | expect(requestCount).toBe(0); | ||
| 137 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { | ||
| 141 | let tokenExchangeCount = 0; | ||
| 142 | const fetchFn = ((input) => { | ||
| 143 | const url = String(input); | ||
| 144 | if (url.endsWith("/v1/client/dpop-token")) { | ||
| 145 | tokenExchangeCount += 1; | ||
| 146 | return Promise.resolve( | ||
| 147 | Response.json({ | ||
| 148 | access_token: `relay-token-${tokenExchangeCount}`, | ||
| 149 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 150 | token_type: "DPoP", | ||
| 151 | expires_in: 10, | ||
| 152 | scope: RelayEnvironmentStatusScope, | ||
| 153 | }), | ||
| 154 | ); | ||
| 155 | } | ||
| 156 | return Promise.resolve( | ||
| 157 | Response.json({ | ||
| 158 | environmentId: "env-1", | ||
| 159 | endpoint: { | ||
| 160 | httpBaseUrl: "https://desktop.example.test/", | ||
| 161 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 162 | providerKind: "cloudflare_tunnel", | ||
| 163 | }, | ||
| 164 | status: "online", | ||
| 165 | checkedAt: "2026-05-25T00:01:00.000Z", | ||
| 166 | descriptor: { | ||
| 167 | environmentId: "env-1", | ||
| 168 | label: "Desktop", | ||
| 169 | platform: { os: "darwin", arch: "arm64" }, | ||
| 170 | serverVersion: "0.0.0-test", | ||
| 171 | capabilities: { repositoryIdentity: true }, | ||
| 172 | }, | ||
| 173 | }), | ||
| 174 | ); | ||
| 175 | }) satisfies typeof globalThis.fetch; | ||
| 176 | |||
| 177 | return Effect.gen(function* () { | ||
| 178 | const relayClient = yield* ManagedRelayClient; | ||
| 179 | const statusInput = { | ||
| 180 | clerkToken: clerkToken("user-1", "session-1"), | ||
| 181 | scopes: [RelayEnvironmentStatusScope], | ||
| 182 | environmentId: EnvironmentId.make("env-1"), | ||
| 183 | } as const; | ||
| 184 | |||
| 185 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 186 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 187 | expect(tokenExchangeCount).toBe(1); | ||
| 188 | |||
| 189 | yield* TestClock.adjust(Duration.seconds(6)); | ||
| 190 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 191 | expect(tokenExchangeCount).toBe(2); | ||
| 192 | |||
| 193 | yield* relayClient.resetTokenCache; | ||
| 194 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 195 | expect(tokenExchangeCount).toBe(3); | ||
| 196 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); | ||
| 197 | }); | ||
| 198 | |||
| 199 | it.effect("reuses a persisted token across runtimes and Clerk session token rotation", () => { | ||
| 200 | let tokenExchangeCount = 0; | ||
| 201 | let persistedTokens: ReadonlyArray<ManagedRelayAccessTokenCacheEntry> = []; | ||
| 202 | const accessTokenStore: ManagedRelayAccessTokenStore = { | ||
| 203 | load: Effect.sync(() => persistedTokens), | ||
| 204 | save: (entries) => | ||
| 205 | Effect.sync(() => { | ||
| 206 | persistedTokens = entries; | ||
| 207 | }), | ||
| 208 | clear: Effect.sync(() => { | ||
| 209 | persistedTokens = []; | ||
| 210 | }), | ||
| 211 | }; | ||
| 212 | const fetchFn = ((input) => { | ||
| 213 | const url = String(input); | ||
| 214 | if (url.endsWith("/v1/client/dpop-token")) { | ||
| 215 | tokenExchangeCount += 1; | ||
| 216 | return Promise.resolve( | ||
| 217 | Response.json({ | ||
| 218 | access_token: "persisted-relay-token", | ||
| 219 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 220 | token_type: "DPoP", | ||
| 221 | expires_in: 1_800, | ||
| 222 | scope: RelayEnvironmentStatusScope, | ||
| 223 | }), | ||
| 224 | ); | ||
| 225 | } | ||
| 226 | return Promise.resolve( | ||
| 227 | Response.json({ | ||
| 228 | environmentId: "env-1", | ||
| 229 | endpoint: { | ||
| 230 | httpBaseUrl: "https://desktop.example.test/", | ||
| 231 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 232 | providerKind: "cloudflare_tunnel", | ||
| 233 | }, | ||
| 234 | status: "online", | ||
| 235 | checkedAt: "2026-06-05T20:00:00.000Z", | ||
| 236 | descriptor: { | ||
| 237 | environmentId: "env-1", | ||
| 238 | label: "Desktop", | ||
| 239 | platform: { os: "darwin", arch: "arm64" }, | ||
| 240 | serverVersion: "0.0.0-test", | ||
| 241 | capabilities: { repositoryIdentity: true }, | ||
| 242 | }, | ||
| 243 | }), | ||
| 244 | ); | ||
| 245 | }) satisfies typeof globalThis.fetch; | ||
| 246 | const statusInput = (token: string) => | ||
| 247 | ({ | ||
| 248 | clerkToken: token, | ||
| 249 | scopes: [RelayEnvironmentStatusScope], | ||
| 250 | environmentId: EnvironmentId.make("env-1"), | ||
| 251 | }) as const; | ||
| 252 | |||
| 253 | return Effect.gen(function* () { | ||
| 254 | yield* Effect.gen(function* () { | ||
| 255 | const relayClient = yield* ManagedRelayClient; | ||
| 256 | yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-1"))); | ||
| 257 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); | ||
| 258 | |||
| 259 | expect(tokenExchangeCount).toBe(1); | ||
| 260 | expect(persistedTokens).toHaveLength(1); | ||
| 261 | |||
| 262 | yield* Effect.gen(function* () { | ||
| 263 | const relayClient = yield* ManagedRelayClient; | ||
| 264 | yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-2"))); | ||
| 265 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); | ||
| 266 | |||
| 267 | expect(tokenExchangeCount).toBe(1); | ||
| 268 | }); | ||
| 269 | }); | ||
| 270 | |||
| 271 | it.effect("refreshes a persisted DPoP token once when the relay rejects it", () => { | ||
| 272 | let tokenExchangeCount = 0; | ||
| 273 | const statusTokens: Array<string | null> = []; | ||
| 274 | let persistedTokens: ReadonlyArray<ManagedRelayAccessTokenCacheEntry> = [ | ||
| 275 | { | ||
| 276 | accountId: "user-1", | ||
| 277 | clientId: "t3-mobile", | ||
| 278 | relayUrl: "https://relay.example.test", | ||
| 279 | thumbprint: "client-thumbprint", | ||
| 280 | scopes: [RelayEnvironmentStatusScope], | ||
| 281 | accessToken: "stale-relay-token", | ||
| 282 | expiresAtMillis: Number.MAX_SAFE_INTEGER, | ||
| 283 | }, | ||
| 284 | ]; | ||
| 285 | const accessTokenStore: ManagedRelayAccessTokenStore = { | ||
| 286 | load: Effect.sync(() => persistedTokens), | ||
| 287 | save: (entries) => | ||
| 288 | Effect.sync(() => { | ||
| 289 | persistedTokens = entries; | ||
| 290 | }), | ||
| 291 | clear: Effect.sync(() => { | ||
| 292 | persistedTokens = []; | ||
| 293 | }), | ||
| 294 | }; | ||
| 295 | const fetchFn = ((input, init) => { | ||
| 296 | const url = String(input); | ||
| 297 | if (url.endsWith("/v1/client/dpop-token")) { | ||
| 298 | tokenExchangeCount += 1; | ||
| 299 | return Promise.resolve( | ||
| 300 | Response.json({ | ||
| 301 | access_token: "fresh-relay-token", | ||
| 302 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 303 | token_type: "DPoP", | ||
| 304 | expires_in: 1_800, | ||
| 305 | scope: RelayEnvironmentStatusScope, | ||
| 306 | }), | ||
| 307 | ); | ||
| 308 | } | ||
| 309 | |||
| 310 | const authorization = new Headers(init?.headers).get("authorization"); | ||
| 311 | statusTokens.push(authorization); | ||
| 312 | if (authorization === "DPoP stale-relay-token") { | ||
| 313 | return Promise.resolve( | ||
| 314 | Response.json( | ||
| 315 | { | ||
| 316 | _tag: "RelayAuthInvalidError", | ||
| 317 | code: "auth_invalid", | ||
| 318 | reason: "invalid_bearer", | ||
| 319 | traceId: "trace-stale-token", | ||
| 320 | }, | ||
| 321 | { status: 401 }, | ||
| 322 | ), | ||
| 323 | ); | ||
| 324 | } | ||
| 325 | return Promise.resolve( | ||
| 326 | Response.json({ | ||
| 327 | environmentId: "env-1", | ||
| 328 | endpoint: { | ||
| 329 | httpBaseUrl: "https://desktop.example.test/", | ||
| 330 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 331 | providerKind: "cloudflare_tunnel", | ||
| 332 | }, | ||
| 333 | status: "online", | ||
| 334 | checkedAt: "2026-06-05T20:00:00.000Z", | ||
| 335 | descriptor: { | ||
| 336 | environmentId: "env-1", | ||
| 337 | label: "Desktop", | ||
| 338 | platform: { os: "darwin", arch: "arm64" }, | ||
| 339 | serverVersion: "0.0.0-test", | ||
| 340 | capabilities: { repositoryIdentity: true }, | ||
| 341 | }, | ||
| 342 | }), | ||
| 343 | ); | ||
| 344 | }) satisfies typeof globalThis.fetch; | ||
| 345 | |||
| 346 | return Effect.gen(function* () { | ||
| 347 | const relayClient = yield* ManagedRelayClient; | ||
| 348 | const result = yield* relayClient.getEnvironmentStatus({ | ||
| 349 | clerkToken: clerkToken("user-1", "session-1"), | ||
| 350 | scopes: [RelayEnvironmentStatusScope], | ||
| 351 | environmentId: EnvironmentId.make("env-1"), | ||
| 352 | }); | ||
| 353 | |||
| 354 | expect(result.status).toBe("online"); | ||
| 355 | expect(statusTokens).toEqual(["DPoP stale-relay-token", "DPoP fresh-relay-token"]); | ||
| 356 | expect(tokenExchangeCount).toBe(1); | ||
| 357 | expect(persistedTokens).toMatchObject([ | ||
| 358 | { | ||
| 359 | accessToken: "fresh-relay-token", | ||
| 360 | }, | ||
| 361 | ]); | ||
| 362 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); | ||
| 363 | }); | ||
| 364 | |||
| 365 | it.effect("does not persist tokens when the Clerk subject cannot be decoded", () => { | ||
| 366 | let persistedTokens: ReadonlyArray<ManagedRelayAccessTokenCacheEntry> = []; | ||
| 367 | const accessTokenStore: ManagedRelayAccessTokenStore = { | ||
| 368 | load: Effect.succeed([]), | ||
| 369 | save: (entries) => | ||
| 370 | Effect.sync(() => { | ||
| 371 | persistedTokens = entries; | ||
| 372 | }), | ||
| 373 | clear: Effect.void, | ||
| 374 | }; | ||
| 375 | const fetchFn = ((input) => { | ||
| 376 | const url = String(input); | ||
| 377 | if (url.endsWith("/v1/client/dpop-token")) { | ||
| 378 | return Promise.resolve( | ||
| 379 | Response.json({ | ||
| 380 | access_token: "relay-token", | ||
| 381 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 382 | token_type: "DPoP", | ||
| 383 | expires_in: 1_800, | ||
| 384 | scope: RelayEnvironmentStatusScope, | ||
| 385 | }), | ||
| 386 | ); | ||
| 387 | } | ||
| 388 | return Promise.resolve( | ||
| 389 | Response.json({ | ||
| 390 | environmentId: "env-1", | ||
| 391 | endpoint: { | ||
| 392 | httpBaseUrl: "https://desktop.example.test/", | ||
| 393 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 394 | providerKind: "cloudflare_tunnel", | ||
| 395 | }, | ||
| 396 | status: "online", | ||
| 397 | checkedAt: "2026-06-05T20:00:00.000Z", | ||
| 398 | descriptor: { | ||
| 399 | environmentId: "env-1", | ||
| 400 | label: "Desktop", | ||
| 401 | platform: { os: "darwin", arch: "arm64" }, | ||
| 402 | serverVersion: "0.0.0-test", | ||
| 403 | capabilities: { repositoryIdentity: true }, | ||
| 404 | }, | ||
| 405 | }), | ||
| 406 | ); | ||
| 407 | }) satisfies typeof globalThis.fetch; | ||
| 408 | |||
| 409 | return Effect.gen(function* () { | ||
| 410 | const relayClient = yield* ManagedRelayClient; | ||
| 411 | yield* relayClient.getEnvironmentStatus({ | ||
| 412 | clerkToken: "not-a-jwt", | ||
| 413 | scopes: [RelayEnvironmentStatusScope], | ||
| 414 | environmentId: EnvironmentId.make("env-1"), | ||
| 415 | }); | ||
| 416 | |||
| 417 | expect(persistedTokens).toEqual([]); | ||
| 418 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); | ||
| 419 | }); | ||
| 420 | |||
| 421 | it.effect("times out stalled relay environment listing requests", () => { | ||
| 422 | const fetchFn = (() => | ||
| 423 | new Promise<Response>(() => undefined)) satisfies typeof globalThis.fetch; | ||
| 424 | |||
| 425 | return Effect.gen(function* () { | ||
| 426 | const relayClient = yield* ManagedRelayClient; | ||
| 427 | const errorFiber = yield* relayClient | ||
| 428 | .listEnvironments({ clerkToken: "clerk-token" }) | ||
| 429 | .pipe(Effect.flip, Effect.forkScoped); | ||
| 430 | |||
| 431 | yield* Effect.yieldNow; | ||
| 432 | yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); | ||
| 433 | const error = yield* Fiber.join(errorFiber); | ||
| 434 | |||
| 435 | expect(error).toMatchObject({ | ||
| 436 | _tag: "ManagedRelayClientError", | ||
| 437 | message: "Relay environment listing timed out.", | ||
| 438 | }); | ||
| 439 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); | ||
| 440 | }); | ||
| 441 | |||
| 442 | it.effect("preserves typed relay trace IDs on client errors", () => { | ||
| 443 | const fetchFn = (() => | ||
| 444 | Promise.resolve( | ||
| 445 | Response.json( | ||
| 446 | { | ||
| 447 | _tag: "RelayAuthInvalidError", | ||
| 448 | code: "auth_invalid", | ||
| 449 | reason: "invalid_bearer", | ||
| 450 | traceId: "trace-managed-relay", | ||
| 451 | }, | ||
| 452 | { status: 401 }, | ||
| 453 | ), | ||
| 454 | )) satisfies typeof globalThis.fetch; | ||
| 455 | |||
| 456 | return Effect.gen(function* () { | ||
| 457 | const relayClient = yield* ManagedRelayClient; | ||
| 458 | const error = yield* relayClient | ||
| 459 | .listEnvironments({ clerkToken: "clerk-token" }) | ||
| 460 | .pipe(Effect.flip); | ||
| 461 | |||
| 462 | expect(error).toMatchObject({ | ||
| 463 | _tag: "ManagedRelayClientError", | ||
| 464 | traceId: "trace-managed-relay", | ||
| 465 | }); | ||
| 466 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); | ||
| 467 | }); | ||
| 468 | |||
| 469 | it.effect("lists account devices through the Clerk bearer client endpoint", () => { | ||
| 470 | const fetchFn = ((input, init) => { | ||
| 471 | expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); | ||
| 472 | expect(init?.headers).toMatchObject({ | ||
| 473 | authorization: "Bearer clerk-token", | ||
| 474 | }); | ||
| 475 | return Promise.resolve( | ||
| 476 | Response.json({ | ||
| 477 | devices: [ | ||
| 478 | { | ||
| 479 | deviceId: "device-1", | ||
| 480 | label: "Julius's iPhone", | ||
| 481 | platform: "ios", | ||
| 482 | iosMajorVersion: 18, | ||
| 483 | appVersion: "1.0.0", | ||
| 484 | notifications: { | ||
| 485 | enabled: false, | ||
| 486 | notifyOnApproval: true, | ||
| 487 | notifyOnInput: true, | ||
| 488 | notifyOnCompletion: true, | ||
| 489 | notifyOnFailure: true, | ||
| 490 | }, | ||
| 491 | liveActivities: { | ||
| 492 | enabled: true, | ||
| 493 | }, | ||
| 494 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 495 | }, | ||
| 496 | ], | ||
| 497 | }), | ||
| 498 | ); | ||
| 499 | }) satisfies typeof globalThis.fetch; | ||
| 500 | |||
| 501 | return Effect.gen(function* () { | ||
| 502 | const relayClient = yield* ManagedRelayClient; | ||
| 503 | const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); | ||
| 504 | expect(devices).toMatchObject([ | ||
| 505 | { | ||
| 506 | deviceId: "device-1", | ||
| 507 | label: "Julius's iPhone", | ||
| 508 | notifications: { | ||
| 509 | enabled: false, | ||
| 510 | }, | ||
| 511 | }, | ||
| 512 | ]); | ||
| 513 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); | ||
| 514 | }); | ||
| 515 | }); | ||
packages/client-runtime/src/relay/managedRelay.ts created+764| ... | @@ -0,0 +1,764 @@ | ||
| 1 | import { | ||
| 2 | RelayAccessTokenType, | ||
| 3 | RelayApi, | ||
| 4 | type RelayClientEnvironmentRecord, | ||
| 5 | type RelayClientDeviceRecord, | ||
| 6 | RelayConnectEnvironmentEndpoint, | ||
| 7 | type RelayDeviceRegistrationRequest, | ||
| 8 | type RelayDpopAccessTokenScope, | ||
| 9 | RelayDpopTokenExchangeGrantType, | ||
| 10 | type RelayEnvironmentConnectRequest, | ||
| 11 | type RelayEnvironmentConnectResponse, | ||
| 12 | type RelayEnvironmentLinkChallengeRequest, | ||
| 13 | type RelayEnvironmentLinkChallengeResponse, | ||
| 14 | type RelayEnvironmentLinkRequest, | ||
| 15 | type RelayEnvironmentLinkResponse, | ||
| 16 | type RelayEnvironmentStatusResponse, | ||
| 17 | RelayExchangeDpopAccessTokenEndpoint, | ||
| 18 | RelayGetEnvironmentStatusEndpoint, | ||
| 19 | RelayJwtSubjectTokenType, | ||
| 20 | type RelayLiveActivityRegistrationRequest, | ||
| 21 | RelayMobileRegistrationScope, | ||
| 22 | type RelayOkResponse, | ||
| 23 | type RelayPublicClientId, | ||
| 24 | RelayRegisterDeviceEndpoint, | ||
| 25 | RelayRegisterLiveActivityEndpoint, | ||
| 26 | RelayProtectedError, | ||
| 27 | type RelayProtectedError as RelayProtectedErrorType, | ||
| 28 | RelayUnregisterDeviceEndpoint, | ||
| 29 | } from "@t3tools/contracts/relay"; | ||
| 30 | import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; | ||
| 31 | import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; | ||
| 32 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 33 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 34 | import * as Clock from "effect/Clock"; | ||
| 35 | import * as Context from "effect/Context"; | ||
| 36 | import * as Data from "effect/Data"; | ||
| 37 | import * as Duration from "effect/Duration"; | ||
| 38 | import * as Effect from "effect/Effect"; | ||
| 39 | import * as Layer from "effect/Layer"; | ||
| 40 | import * as Option from "effect/Option"; | ||
| 41 | import * as Schema from "effect/Schema"; | ||
| 42 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 43 | import { HttpClientError } from "effect/unstable/http"; | ||
| 44 | import type { HttpMethod } from "effect/unstable/http/HttpMethod"; | ||
| 45 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | ||
| 46 | |||
| 47 | export interface ManagedRelayDpopProofInput { | ||
| 48 | readonly method: HttpMethod; | ||
| 49 | readonly url: string; | ||
| 50 | readonly accessToken?: string; | ||
| 51 | } | ||
| 52 | |||
| 53 | export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ | ||
| 54 | readonly cause: unknown; | ||
| 55 | }> {} | ||
| 56 | |||
| 57 | export class ManagedRelayRequestTimeoutError extends Data.TaggedError( | ||
| 58 | "ManagedRelayRequestTimeoutError", | ||
| 59 | )<{ | ||
| 60 | readonly message: string; | ||
| 61 | }> {} | ||
| 62 | |||
| 63 | type RelayHttpRequestError = | ||
| 64 | | RelayProtectedErrorType | ||
| 65 | | HttpClientError.HttpClientError | ||
| 66 | | Schema.SchemaError | ||
| 67 | | ManagedRelayRequestTimeoutError; | ||
| 68 | |||
| 69 | export interface ManagedRelayDpopSignerShape { | ||
| 70 | readonly thumbprint: Effect.Effect<string, ManagedRelayDpopSignerError>; | ||
| 71 | readonly createProof: ( | ||
| 72 | input: ManagedRelayDpopProofInput, | ||
| 73 | ) => Effect.Effect<string, ManagedRelayDpopSignerError>; | ||
| 74 | } | ||
| 75 | |||
| 76 | export class ManagedRelayDpopSigner extends Context.Service< | ||
| 77 | ManagedRelayDpopSigner, | ||
| 78 | ManagedRelayDpopSignerShape | ||
| 79 | >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {} | ||
| 80 | |||
| 81 | export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ | ||
| 82 | readonly message: string; | ||
| 83 | readonly cause?: RelayHttpRequestError | ManagedRelayDpopSignerError; | ||
| 84 | readonly relayError?: RelayProtectedErrorType; | ||
| 85 | readonly traceId?: string; | ||
| 86 | }> {} | ||
| 87 | |||
| 88 | export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; | ||
| 89 | |||
| 90 | export interface ManagedRelayAccessTokenCacheEntry { | ||
| 91 | readonly accountId: string; | ||
| 92 | readonly clientId: RelayPublicClientId; | ||
| 93 | readonly relayUrl: string; | ||
| 94 | readonly thumbprint: string; | ||
| 95 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 96 | readonly accessToken: string; | ||
| 97 | readonly expiresAtMillis: number; | ||
| 98 | } | ||
| 99 | |||
| 100 | export interface ManagedRelayAccessTokenStore { | ||
| 101 | readonly load: Effect.Effect<ReadonlyArray<ManagedRelayAccessTokenCacheEntry>>; | ||
| 102 | readonly save: (entries: ReadonlyArray<ManagedRelayAccessTokenCacheEntry>) => Effect.Effect<void>; | ||
| 103 | readonly clear: Effect.Effect<void>; | ||
| 104 | } | ||
| 105 | |||
| 106 | export interface ManagedRelayAuthorization { | ||
| 107 | readonly accessToken: string; | ||
| 108 | readonly proof: string; | ||
| 109 | readonly thumbprint: string; | ||
| 110 | } | ||
| 111 | |||
| 112 | export interface ManagedRelayClientLayerOptions { | ||
| 113 | readonly relayUrl: string; | ||
| 114 | readonly clientId: RelayPublicClientId; | ||
| 115 | readonly accessTokenStore?: ManagedRelayAccessTokenStore; | ||
| 116 | } | ||
| 117 | |||
| 118 | export interface ManagedRelayClientShape { | ||
| 119 | readonly relayUrl: string; | ||
| 120 | readonly listEnvironments: (input: { | ||
| 121 | readonly clerkToken: string; | ||
| 122 | }) => Effect.Effect<ReadonlyArray<RelayClientEnvironmentRecord>, ManagedRelayClientError>; | ||
| 123 | readonly listDevices: (input: { | ||
| 124 | readonly clerkToken: string; | ||
| 125 | }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, ManagedRelayClientError>; | ||
| 126 | readonly createEnvironmentLinkChallenge: (input: { | ||
| 127 | readonly clerkToken: string; | ||
| 128 | readonly payload: RelayEnvironmentLinkChallengeRequest; | ||
| 129 | }) => Effect.Effect<RelayEnvironmentLinkChallengeResponse, ManagedRelayClientError>; | ||
| 130 | readonly linkEnvironment: (input: { | ||
| 131 | readonly clerkToken: string; | ||
| 132 | readonly payload: RelayEnvironmentLinkRequest; | ||
| 133 | }) => Effect.Effect<RelayEnvironmentLinkResponse, ManagedRelayClientError>; | ||
| 134 | readonly unlinkEnvironment: (input: { | ||
| 135 | readonly clerkToken: string; | ||
| 136 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 137 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 138 | readonly getEnvironmentStatus: (input: { | ||
| 139 | readonly clerkToken: string; | ||
| 140 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 141 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 142 | }) => Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelayClientError>; | ||
| 143 | readonly connectEnvironment: (input: { | ||
| 144 | readonly clerkToken: string; | ||
| 145 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 146 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 147 | readonly deviceId?: string; | ||
| 148 | }) => Effect.Effect<RelayEnvironmentConnectResponse, ManagedRelayClientError>; | ||
| 149 | readonly registerDevice: (input: { | ||
| 150 | readonly clerkToken: string; | ||
| 151 | readonly payload: RelayDeviceRegistrationRequest; | ||
| 152 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 153 | readonly unregisterDevice: (input: { | ||
| 154 | readonly clerkToken: string; | ||
| 155 | readonly deviceId: string; | ||
| 156 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 157 | readonly registerLiveActivity: (input: { | ||
| 158 | readonly clerkToken: string; | ||
| 159 | readonly payload: RelayLiveActivityRegistrationRequest; | ||
| 160 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 161 | readonly resetTokenCache: Effect.Effect<void>; | ||
| 162 | } | ||
| 163 | |||
| 164 | export class ManagedRelayClient extends Context.Service< | ||
| 165 | ManagedRelayClient, | ||
| 166 | ManagedRelayClientShape | ||
| 167 | >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} | ||
| 168 | |||
| 169 | const isRelayProtectedError = Schema.is(RelayProtectedError); | ||
| 170 | |||
| 171 | function relayClientError(message: string, cause?: RelayHttpRequestError): ManagedRelayClientError { | ||
| 172 | return new ManagedRelayClientError({ | ||
| 173 | message, | ||
| 174 | ...(cause === undefined ? {} : { cause }), | ||
| 175 | }); | ||
| 176 | } | ||
| 177 | |||
| 178 | function relayLocalError( | ||
| 179 | message: string, | ||
| 180 | cause: ManagedRelayDpopSignerError, | ||
| 181 | ): ManagedRelayClientError { | ||
| 182 | return new ManagedRelayClientError({ message, cause }); | ||
| 183 | } | ||
| 184 | |||
| 185 | function relayRequestError(message: string) { | ||
| 186 | return (cause: RelayHttpRequestError): ManagedRelayClientError => | ||
| 187 | new ManagedRelayClientError({ | ||
| 188 | message, | ||
| 189 | cause, | ||
| 190 | ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}), | ||
| 191 | }); | ||
| 192 | } | ||
| 193 | |||
| 194 | function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean { | ||
| 195 | return ( | ||
| 196 | error.relayError?._tag === "RelayAuthInvalidError" && | ||
| 197 | error.relayError.reason === "invalid_bearer" | ||
| 198 | ); | ||
| 199 | } | ||
| 200 | |||
| 201 | function timeoutRelayRequest(message: string) { | ||
| 202 | return <A, E, R>( | ||
| 203 | request: Effect.Effect<A, E, R>, | ||
| 204 | ): Effect.Effect<A, E | ManagedRelayClientError, R> => | ||
| 205 | request.pipe( | ||
| 206 | Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), | ||
| 207 | Effect.flatMap( | ||
| 208 | Option.match({ | ||
| 209 | onNone: () => | ||
| 210 | Effect.fail( | ||
| 211 | relayClientError(message, new ManagedRelayRequestTimeoutError({ message })), | ||
| 212 | ), | ||
| 213 | onSome: Effect.succeed, | ||
| 214 | }), | ||
| 215 | ), | ||
| 216 | ); | ||
| 217 | } | ||
| 218 | |||
| 219 | function tokenMatches( | ||
| 220 | token: ManagedRelayAccessTokenCacheEntry, | ||
| 221 | input: { | ||
| 222 | readonly accountId: string; | ||
| 223 | readonly clientId: RelayPublicClientId; | ||
| 224 | readonly relayUrl: string; | ||
| 225 | readonly thumbprint: string; | ||
| 226 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 227 | readonly nowMillis: number; | ||
| 228 | }, | ||
| 229 | ): boolean { | ||
| 230 | return ( | ||
| 231 | token.accountId === input.accountId && | ||
| 232 | token.clientId === input.clientId && | ||
| 233 | token.relayUrl === input.relayUrl && | ||
| 234 | token.thumbprint === input.thumbprint && | ||
| 235 | token.expiresAtMillis > input.nowMillis + 5_000 && | ||
| 236 | input.scopes.every((scope) => token.scopes.includes(scope)) | ||
| 237 | ); | ||
| 238 | } | ||
| 239 | |||
| 240 | function relayAccountId(clerkToken: string): Option.Option<string> { | ||
| 241 | try { | ||
| 242 | return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe( | ||
| 243 | Option.filter((subject) => subject.length > 0), | ||
| 244 | ); | ||
| 245 | } catch { | ||
| 246 | return Option.none(); | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | function bearerHeaders(clerkToken: string) { | ||
| 251 | return { authorization: `Bearer ${clerkToken}` }; | ||
| 252 | } | ||
| 253 | |||
| 254 | function dpopHeaders(authorization: ManagedRelayAuthorization) { | ||
| 255 | return { | ||
| 256 | authorization: `DPoP ${authorization.accessToken}`, | ||
| 257 | dpop: authorization.proof, | ||
| 258 | }; | ||
| 259 | } | ||
| 260 | |||
| 261 | function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { | ||
| 262 | const unavailable = (spanName: string) => | ||
| 263 | Effect.fn(spanName)(function* () { | ||
| 264 | return yield* relayClientError("Relay URL must be a secure absolute HTTPS origin."); | ||
| 265 | }); | ||
| 266 | return ManagedRelayClient.of({ | ||
| 267 | relayUrl, | ||
| 268 | listEnvironments: unavailable("clientRuntime.managedRelay.listEnvironments"), | ||
| 269 | listDevices: unavailable("clientRuntime.managedRelay.listDevices"), | ||
| 270 | createEnvironmentLinkChallenge: unavailable( | ||
| 271 | "clientRuntime.managedRelay.createEnvironmentLinkChallenge", | ||
| 272 | ), | ||
| 273 | linkEnvironment: unavailable("clientRuntime.managedRelay.linkEnvironment"), | ||
| 274 | unlinkEnvironment: unavailable("clientRuntime.managedRelay.unlinkEnvironment"), | ||
| 275 | getEnvironmentStatus: unavailable("clientRuntime.managedRelay.getEnvironmentStatus"), | ||
| 276 | connectEnvironment: unavailable("clientRuntime.managedRelay.connectEnvironment"), | ||
| 277 | registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), | ||
| 278 | unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), | ||
| 279 | registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), | ||
| 280 | resetTokenCache: Effect.void.pipe( | ||
| 281 | Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), | ||
| 282 | ), | ||
| 283 | }); | ||
| 284 | } | ||
| 285 | |||
| 286 | export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { | ||
| 287 | return Layer.effect( | ||
| 288 | ManagedRelayClient, | ||
| 289 | Effect.gen(function* () { | ||
| 290 | const relayUrl = normalizeSecureRelayUrl(options.relayUrl); | ||
| 291 | if (relayUrl === null) { | ||
| 292 | return disabledManagedRelayClient(options.relayUrl); | ||
| 293 | } | ||
| 294 | const signer = yield* ManagedRelayDpopSigner; | ||
| 295 | const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); | ||
| 296 | const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : []; | ||
| 297 | const cachedTokens = yield* SynchronizedRef.make< | ||
| 298 | ReadonlyArray<ManagedRelayAccessTokenCacheEntry> | ||
| 299 | >(initialTokens.filter((token) => token.clientId === options.clientId)); | ||
| 300 | const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); | ||
| 301 | |||
| 302 | type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">; | ||
| 303 | const dpopProofTargets = { | ||
| 304 | exchangeAccessToken: (): DpopProofTarget => ({ | ||
| 305 | method: RelayExchangeDpopAccessTokenEndpoint.method, | ||
| 306 | url: urlBuilder.token.exchangeDpopAccessToken(), | ||
| 307 | }), | ||
| 308 | getEnvironmentStatus: ( | ||
| 309 | environmentId: RelayClientEnvironmentRecord["environmentId"], | ||
| 310 | ): DpopProofTarget => ({ | ||
| 311 | method: RelayGetEnvironmentStatusEndpoint.method, | ||
| 312 | url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), | ||
| 313 | }), | ||
| 314 | connectEnvironment: ( | ||
| 315 | environmentId: RelayClientEnvironmentRecord["environmentId"], | ||
| 316 | ): DpopProofTarget => ({ | ||
| 317 | method: RelayConnectEnvironmentEndpoint.method, | ||
| 318 | url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), | ||
| 319 | }), | ||
| 320 | registerDevice: (): DpopProofTarget => ({ | ||
| 321 | method: RelayRegisterDeviceEndpoint.method, | ||
| 322 | url: urlBuilder.mobile.registerDevice(), | ||
| 323 | }), | ||
| 324 | unregisterDevice: (deviceId: string): DpopProofTarget => ({ | ||
| 325 | method: RelayUnregisterDeviceEndpoint.method, | ||
| 326 | url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), | ||
| 327 | }), | ||
| 328 | registerLiveActivity: (): DpopProofTarget => ({ | ||
| 329 | method: RelayRegisterLiveActivityEndpoint.method, | ||
| 330 | url: urlBuilder.mobile.registerLiveActivity(), | ||
| 331 | }), | ||
| 332 | }; | ||
| 333 | |||
| 334 | const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( | ||
| 335 | function* (input: { | ||
| 336 | readonly clerkToken: string; | ||
| 337 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 338 | }) { | ||
| 339 | yield* Effect.annotateCurrentSpan({ | ||
| 340 | "relay.client_id": options.clientId, | ||
| 341 | "relay.scopes": input.scopes.join(" "), | ||
| 342 | }); | ||
| 343 | const proof = yield* signer | ||
| 344 | .createProof(dpopProofTargets.exchangeAccessToken()) | ||
| 345 | .pipe( | ||
| 346 | Effect.mapError((cause) => | ||
| 347 | relayLocalError("Could not create relay token DPoP proof.", cause), | ||
| 348 | ), | ||
| 349 | ); | ||
| 350 | const response = yield* client.token | ||
| 351 | .exchangeDpopAccessToken({ | ||
| 352 | headers: { dpop: proof }, | ||
| 353 | payload: { | ||
| 354 | grant_type: RelayDpopTokenExchangeGrantType, | ||
| 355 | subject_token: input.clerkToken, | ||
| 356 | subject_token_type: RelayJwtSubjectTokenType, | ||
| 357 | requested_token_type: RelayAccessTokenType, | ||
| 358 | resource: relayUrl, | ||
| 359 | scope: encodeOAuthScope(input.scopes), | ||
| 360 | client_id: options.clientId, | ||
| 361 | }, | ||
| 362 | }) | ||
| 363 | .pipe( | ||
| 364 | Effect.mapError(relayRequestError("Could not exchange relay DPoP access token.")), | ||
| 365 | timeoutRelayRequest("Relay DPoP access token exchange timed out."), | ||
| 366 | ); | ||
| 367 | if (!oauthScopeSetEquals(response.scope, input.scopes)) { | ||
| 368 | return yield* relayClientError("Relay granted unexpected DPoP access token scopes."); | ||
| 369 | } | ||
| 370 | return response; | ||
| 371 | }, | ||
| 372 | ); | ||
| 373 | |||
| 374 | const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( | ||
| 375 | function* (input: { | ||
| 376 | readonly clerkToken: string; | ||
| 377 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 378 | readonly thumbprint: string; | ||
| 379 | }) { | ||
| 380 | yield* Effect.annotateCurrentSpan({ | ||
| 381 | "relay.client_id": options.clientId, | ||
| 382 | "relay.scopes": input.scopes.join(" "), | ||
| 383 | }); | ||
| 384 | const nowMillis = yield* Clock.currentTimeMillis; | ||
| 385 | const accountId = relayAccountId(input.clerkToken); | ||
| 386 | if (Option.isNone(accountId)) { | ||
| 387 | yield* Effect.annotateCurrentSpan({ | ||
| 388 | "relay.token_cache.result": "bypass", | ||
| 389 | "relay.token_cache.bypass_reason": "invalid_subject_token", | ||
| 390 | }); | ||
| 391 | const response = yield* exchangeAccessToken(input); | ||
| 392 | return { | ||
| 393 | accountId: "", | ||
| 394 | clientId: options.clientId, | ||
| 395 | relayUrl, | ||
| 396 | thumbprint: input.thumbprint, | ||
| 397 | scopes: input.scopes, | ||
| 398 | accessToken: response.access_token, | ||
| 399 | expiresAtMillis: nowMillis + response.expires_in * 1_000, | ||
| 400 | } satisfies ManagedRelayAccessTokenCacheEntry; | ||
| 401 | } | ||
| 402 | return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => | ||
| 403 | Effect.gen(function* () { | ||
| 404 | const activeTokens = tokens.filter( | ||
| 405 | (token) => token.expiresAtMillis > nowMillis + 5_000, | ||
| 406 | ); | ||
| 407 | const cached = activeTokens.find((token) => | ||
| 408 | tokenMatches(token, { | ||
| 409 | accountId: accountId.value, | ||
| 410 | clientId: options.clientId, | ||
| 411 | relayUrl, | ||
| 412 | thumbprint: input.thumbprint, | ||
| 413 | scopes: input.scopes, | ||
| 414 | nowMillis, | ||
| 415 | }), | ||
| 416 | ); | ||
| 417 | if (cached) { | ||
| 418 | yield* Effect.annotateCurrentSpan({ | ||
| 419 | "relay.token_cache.result": "hit", | ||
| 420 | }); | ||
| 421 | return [cached, activeTokens] as const; | ||
| 422 | } | ||
| 423 | yield* Effect.annotateCurrentSpan({ | ||
| 424 | "relay.token_cache.result": "miss", | ||
| 425 | }); | ||
| 426 | const response = yield* exchangeAccessToken(input); | ||
| 427 | const next: ManagedRelayAccessTokenCacheEntry = { | ||
| 428 | accountId: accountId.value, | ||
| 429 | clientId: options.clientId, | ||
| 430 | relayUrl, | ||
| 431 | thumbprint: input.thumbprint, | ||
| 432 | scopes: input.scopes, | ||
| 433 | accessToken: response.access_token, | ||
| 434 | expiresAtMillis: nowMillis + response.expires_in * 1_000, | ||
| 435 | }; | ||
| 436 | const nextTokens = [...activeTokens, next]; | ||
| 437 | if (options.accessTokenStore) { | ||
| 438 | yield* options.accessTokenStore.save(nextTokens); | ||
| 439 | } | ||
| 440 | return [next, nextTokens] as const; | ||
| 441 | }), | ||
| 442 | ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection")); | ||
| 443 | }, | ||
| 444 | ); | ||
| 445 | |||
| 446 | const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: { | ||
| 447 | readonly clerkToken: string; | ||
| 448 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 449 | readonly target: DpopProofTarget; | ||
| 450 | }) { | ||
| 451 | yield* Effect.annotateCurrentSpan({ | ||
| 452 | "relay.client_id": options.clientId, | ||
| 453 | "relay.scopes": input.scopes.join(" "), | ||
| 454 | "http.request.method": input.target.method, | ||
| 455 | "url.full": input.target.url, | ||
| 456 | }); | ||
| 457 | const thumbprint = yield* signer.thumbprint.pipe( | ||
| 458 | Effect.mapError((cause) => | ||
| 459 | relayLocalError("Could not load relay DPoP proof key.", cause), | ||
| 460 | ), | ||
| 461 | ); | ||
| 462 | const token = yield* obtainAccessToken({ | ||
| 463 | clerkToken: input.clerkToken, | ||
| 464 | scopes: input.scopes, | ||
| 465 | thumbprint, | ||
| 466 | }); | ||
| 467 | const proof = yield* signer | ||
| 468 | .createProof({ | ||
| 469 | ...input.target, | ||
| 470 | accessToken: token.accessToken, | ||
| 471 | }) | ||
| 472 | .pipe( | ||
| 473 | Effect.mapError((cause) => | ||
| 474 | relayLocalError("Could not create relay request DPoP proof.", cause), | ||
| 475 | ), | ||
| 476 | ); | ||
| 477 | return { accessToken: token.accessToken, proof, thumbprint }; | ||
| 478 | }); | ||
| 479 | |||
| 480 | const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")( | ||
| 481 | function* (accessToken: string) { | ||
| 482 | return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { | ||
| 483 | const nextTokens = tokens.filter((token) => token.accessToken !== accessToken); | ||
| 484 | if (nextTokens.length === tokens.length) { | ||
| 485 | return Effect.succeed([false, tokens] as const); | ||
| 486 | } | ||
| 487 | return ( | ||
| 488 | options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void | ||
| 489 | ).pipe(Effect.as([true, nextTokens] as const)); | ||
| 490 | }); | ||
| 491 | }, | ||
| 492 | ); | ||
| 493 | |||
| 494 | const runDpopRequest = <A>( | ||
| 495 | input: { | ||
| 496 | readonly clerkToken: string; | ||
| 497 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 498 | readonly target: DpopProofTarget; | ||
| 499 | }, | ||
| 500 | request: ( | ||
| 501 | authorization: ManagedRelayAuthorization, | ||
| 502 | ) => Effect.Effect<A, ManagedRelayClientError>, | ||
| 503 | ): Effect.Effect<A, ManagedRelayClientError> => { | ||
| 504 | const attempt = ( | ||
| 505 | refreshRejectedToken: boolean, | ||
| 506 | ): Effect.Effect<A, ManagedRelayClientError> => | ||
| 507 | authorize(input).pipe( | ||
| 508 | Effect.flatMap((authorization) => | ||
| 509 | request(authorization).pipe( | ||
| 510 | Effect.catch((error) => { | ||
| 511 | if (!isRejectedDpopAccessToken(error)) { | ||
| 512 | return Effect.fail(error); | ||
| 513 | } | ||
| 514 | return invalidateAccessToken(authorization.accessToken).pipe( | ||
| 515 | Effect.tap((invalidated) => | ||
| 516 | Effect.annotateCurrentSpan({ | ||
| 517 | "relay.token_cache.invalidated": invalidated, | ||
| 518 | "relay.token_cache.invalidation_reason": "invalid_bearer", | ||
| 519 | "relay.token_cache.retry_after_invalidation": refreshRejectedToken, | ||
| 520 | }), | ||
| 521 | ), | ||
| 522 | Effect.tap((invalidated) => | ||
| 523 | invalidated && refreshRejectedToken | ||
| 524 | ? Effect.logWarning( | ||
| 525 | "Relay rejected a cached DPoP access token; refreshing it once.", | ||
| 526 | ) | ||
| 527 | : Effect.void, | ||
| 528 | ), | ||
| 529 | Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)), | ||
| 530 | ); | ||
| 531 | }), | ||
| 532 | ), | ||
| 533 | ), | ||
| 534 | ); | ||
| 535 | return attempt(true); | ||
| 536 | }; | ||
| 537 | |||
| 538 | const mobileRegistrationRequest = <A>( | ||
| 539 | input: { | ||
| 540 | readonly clerkToken: string; | ||
| 541 | readonly target: DpopProofTarget; | ||
| 542 | }, | ||
| 543 | request: ( | ||
| 544 | authorization: ManagedRelayAuthorization, | ||
| 545 | ) => Effect.Effect<A, ManagedRelayClientError>, | ||
| 546 | ) => | ||
| 547 | runDpopRequest( | ||
| 548 | { | ||
| 549 | ...input, | ||
| 550 | scopes: [RelayMobileRegistrationScope], | ||
| 551 | }, | ||
| 552 | request, | ||
| 553 | ); | ||
| 554 | |||
| 555 | return ManagedRelayClient.of({ | ||
| 556 | relayUrl, | ||
| 557 | listEnvironments: Effect.fnUntraced( | ||
| 558 | function* (input) { | ||
| 559 | return yield* client.client | ||
| 560 | .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) | ||
| 561 | .pipe( | ||
| 562 | Effect.map((response) => response.environments), | ||
| 563 | Effect.mapError(relayRequestError("Could not list relay-managed environments.")), | ||
| 564 | timeoutRelayRequest("Relay environment listing timed out."), | ||
| 565 | ); | ||
| 566 | }, | ||
| 567 | Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), | ||
| 568 | withRelayClientTracing, | ||
| 569 | ), | ||
| 570 | listDevices: Effect.fnUntraced( | ||
| 571 | function* (input) { | ||
| 572 | return yield* client.client | ||
| 573 | .listDevices({ | ||
| 574 | headers: bearerHeaders(input.clerkToken), | ||
| 575 | }) | ||
| 576 | .pipe( | ||
| 577 | Effect.map((response) => response.devices), | ||
| 578 | Effect.mapError(relayRequestError("Could not list relay client devices.")), | ||
| 579 | timeoutRelayRequest("Relay client device listing timed out."), | ||
| 580 | ); | ||
| 581 | }, | ||
| 582 | Effect.withSpan("clientRuntime.managedRelay.listDevices"), | ||
| 583 | withRelayClientTracing, | ||
| 584 | ), | ||
| 585 | createEnvironmentLinkChallenge: Effect.fnUntraced( | ||
| 586 | function* (input) { | ||
| 587 | return yield* client.client | ||
| 588 | .createEnvironmentLinkChallenge({ | ||
| 589 | headers: bearerHeaders(input.clerkToken), | ||
| 590 | payload: input.payload, | ||
| 591 | }) | ||
| 592 | .pipe( | ||
| 593 | Effect.mapError( | ||
| 594 | relayRequestError("Could not create relay environment link challenge."), | ||
| 595 | ), | ||
| 596 | timeoutRelayRequest("Relay environment link challenge timed out."), | ||
| 597 | ); | ||
| 598 | }, | ||
| 599 | Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), | ||
| 600 | withRelayClientTracing, | ||
| 601 | ), | ||
| 602 | linkEnvironment: Effect.fnUntraced( | ||
| 603 | function* (input) { | ||
| 604 | return yield* client.client | ||
| 605 | .linkEnvironment({ | ||
| 606 | headers: bearerHeaders(input.clerkToken), | ||
| 607 | payload: input.payload, | ||
| 608 | }) | ||
| 609 | .pipe( | ||
| 610 | Effect.mapError(relayRequestError("Could not link relay environment.")), | ||
| 611 | timeoutRelayRequest("Relay environment linking timed out."), | ||
| 612 | ); | ||
| 613 | }, | ||
| 614 | Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), | ||
| 615 | withRelayClientTracing, | ||
| 616 | ), | ||
| 617 | unlinkEnvironment: Effect.fnUntraced( | ||
| 618 | function* (input) { | ||
| 619 | return yield* client.client | ||
| 620 | .unlinkEnvironment({ | ||
| 621 | headers: bearerHeaders(input.clerkToken), | ||
| 622 | params: { environmentId: input.environmentId }, | ||
| 623 | }) | ||
| 624 | .pipe( | ||
| 625 | Effect.mapError(relayRequestError("Could not unlink relay environment.")), | ||
| 626 | timeoutRelayRequest("Relay environment unlinking timed out."), | ||
| 627 | ); | ||
| 628 | }, | ||
| 629 | Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), | ||
| 630 | withRelayClientTracing, | ||
| 631 | ), | ||
| 632 | getEnvironmentStatus: Effect.fnUntraced( | ||
| 633 | function* (input) { | ||
| 634 | yield* Effect.annotateCurrentSpan({ | ||
| 635 | "environment.id": input.environmentId, | ||
| 636 | }); | ||
| 637 | return yield* runDpopRequest( | ||
| 638 | { | ||
| 639 | clerkToken: input.clerkToken, | ||
| 640 | scopes: input.scopes, | ||
| 641 | target: dpopProofTargets.getEnvironmentStatus(input.environmentId), | ||
| 642 | }, | ||
| 643 | (authorization) => | ||
| 644 | client.dpopClient | ||
| 645 | .getEnvironmentStatus({ | ||
| 646 | headers: dpopHeaders(authorization), | ||
| 647 | params: { environmentId: input.environmentId }, | ||
| 648 | }) | ||
| 649 | .pipe( | ||
| 650 | Effect.mapError(relayRequestError("Could not get relay environment status.")), | ||
| 651 | timeoutRelayRequest("Relay environment status request timed out."), | ||
| 652 | ), | ||
| 653 | ); | ||
| 654 | }, | ||
| 655 | Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), | ||
| 656 | withRelayClientTracing, | ||
| 657 | ), | ||
| 658 | connectEnvironment: Effect.fnUntraced( | ||
| 659 | function* (input) { | ||
| 660 | yield* Effect.annotateCurrentSpan({ | ||
| 661 | "environment.id": input.environmentId, | ||
| 662 | }); | ||
| 663 | return yield* runDpopRequest( | ||
| 664 | { | ||
| 665 | clerkToken: input.clerkToken, | ||
| 666 | scopes: input.scopes, | ||
| 667 | target: dpopProofTargets.connectEnvironment(input.environmentId), | ||
| 668 | }, | ||
| 669 | (authorization) => { | ||
| 670 | const payload: RelayEnvironmentConnectRequest = { | ||
| 671 | ...(input.deviceId ? { deviceId: input.deviceId } : {}), | ||
| 672 | clientKeyThumbprint: authorization.thumbprint, | ||
| 673 | }; | ||
| 674 | return client.dpopClient | ||
| 675 | .connectEnvironment({ | ||
| 676 | headers: dpopHeaders(authorization), | ||
| 677 | params: { environmentId: input.environmentId }, | ||
| 678 | payload, | ||
| 679 | }) | ||
| 680 | .pipe( | ||
| 681 | Effect.mapError(relayRequestError("Could not connect relay environment.")), | ||
| 682 | timeoutRelayRequest("Relay environment connection timed out."), | ||
| 683 | ); | ||
| 684 | }, | ||
| 685 | ); | ||
| 686 | }, | ||
| 687 | Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), | ||
| 688 | withRelayClientTracing, | ||
| 689 | ), | ||
| 690 | registerDevice: Effect.fnUntraced( | ||
| 691 | function* (input) { | ||
| 692 | return yield* mobileRegistrationRequest( | ||
| 693 | { | ||
| 694 | clerkToken: input.clerkToken, | ||
| 695 | target: dpopProofTargets.registerDevice(), | ||
| 696 | }, | ||
| 697 | (authorization) => | ||
| 698 | client.mobile | ||
| 699 | .registerDevice({ | ||
| 700 | headers: dpopHeaders(authorization), | ||
| 701 | payload: input.payload, | ||
| 702 | }) | ||
| 703 | .pipe( | ||
| 704 | Effect.mapError(relayRequestError("Could not register relay mobile device.")), | ||
| 705 | timeoutRelayRequest("Relay mobile device registration timed out."), | ||
| 706 | ), | ||
| 707 | ); | ||
| 708 | }, | ||
| 709 | Effect.withSpan("clientRuntime.managedRelay.registerDevice"), | ||
| 710 | withRelayClientTracing, | ||
| 711 | ), | ||
| 712 | unregisterDevice: Effect.fnUntraced( | ||
| 713 | function* (input) { | ||
| 714 | return yield* mobileRegistrationRequest( | ||
| 715 | { | ||
| 716 | clerkToken: input.clerkToken, | ||
| 717 | target: dpopProofTargets.unregisterDevice(input.deviceId), | ||
| 718 | }, | ||
| 719 | (authorization) => | ||
| 720 | client.mobile | ||
| 721 | .unregisterDevice({ | ||
| 722 | headers: dpopHeaders(authorization), | ||
| 723 | params: { deviceId: input.deviceId }, | ||
| 724 | }) | ||
| 725 | .pipe( | ||
| 726 | Effect.mapError(relayRequestError("Could not unregister relay mobile device.")), | ||
| 727 | timeoutRelayRequest("Relay mobile device unregistration timed out."), | ||
| 728 | ), | ||
| 729 | ); | ||
| 730 | }, | ||
| 731 | Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), | ||
| 732 | withRelayClientTracing, | ||
| 733 | ), | ||
| 734 | registerLiveActivity: Effect.fnUntraced( | ||
| 735 | function* (input) { | ||
| 736 | return yield* mobileRegistrationRequest( | ||
| 737 | { | ||
| 738 | clerkToken: input.clerkToken, | ||
| 739 | target: dpopProofTargets.registerLiveActivity(), | ||
| 740 | }, | ||
| 741 | (authorization) => | ||
| 742 | client.mobile | ||
| 743 | .registerLiveActivity({ | ||
| 744 | headers: dpopHeaders(authorization), | ||
| 745 | payload: input.payload, | ||
| 746 | }) | ||
| 747 | .pipe( | ||
| 748 | Effect.mapError(relayRequestError("Could not register relay live activity.")), | ||
| 749 | timeoutRelayRequest("Relay Live Activity registration timed out."), | ||
| 750 | ), | ||
| 751 | ); | ||
| 752 | }, | ||
| 753 | Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), | ||
| 754 | withRelayClientTracing, | ||
| 755 | ), | ||
| 756 | resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( | ||
| 757 | Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), | ||
| 758 | Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), | ||
| 759 | withRelayClientTracing, | ||
| 760 | ), | ||
| 761 | }); | ||
| 762 | }), | ||
| 763 | ); | ||
| 764 | } | ||
packages/client-runtime/src/relay/managedRelayState.test.ts created+383| ... | @@ -0,0 +1,383 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { | ||
| 3 | RelayClientDeviceRecord, | ||
| 4 | RelayClientEnvironmentRecord, | ||
| 5 | RelayEnvironmentStatusResponse, | ||
| 6 | } from "@t3tools/contracts/relay"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Fiber from "effect/Fiber"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Stream from "effect/Stream"; | ||
| 12 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 13 | import { afterEach, vi } from "vite-plus/test"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | ManagedRelayClient, | ||
| 17 | ManagedRelayClientError, | ||
| 18 | type ManagedRelayClientShape, | ||
| 19 | } from "./managedRelay.ts"; | ||
| 20 | import { | ||
| 21 | createManagedRelayQueryManager, | ||
| 22 | createManagedRelaySession, | ||
| 23 | managedRelayAccountChanges, | ||
| 24 | type ManagedRelayQueryEvent, | ||
| 25 | managedRelaySessionAtom, | ||
| 26 | readManagedRelaySnapshotState, | ||
| 27 | setManagedRelaySession, | ||
| 28 | waitForManagedRelayClerkToken, | ||
| 29 | } from "./managedRelayState.ts"; | ||
| 30 | |||
| 31 | let registry = AtomRegistry.make(); | ||
| 32 | |||
| 33 | const environment = { | ||
| 34 | environmentId: EnvironmentId.make("environment-1"), | ||
| 35 | label: "Main environment", | ||
| 36 | endpoint: { | ||
| 37 | httpBaseUrl: "https://environment.example.test", | ||
| 38 | wsBaseUrl: "wss://environment.example.test", | ||
| 39 | providerKind: "cloudflare_tunnel", | ||
| 40 | }, | ||
| 41 | linkedAt: "2026-06-01T00:00:00.000Z", | ||
| 42 | } satisfies RelayClientEnvironmentRecord; | ||
| 43 | |||
| 44 | const device = { | ||
| 45 | deviceId: "device-1", | ||
| 46 | label: "Julius iPhone", | ||
| 47 | platform: "ios", | ||
| 48 | iosMajorVersion: 18, | ||
| 49 | appVersion: null, | ||
| 50 | notifications: { | ||
| 51 | enabled: true, | ||
| 52 | notifyOnApproval: true, | ||
| 53 | notifyOnInput: true, | ||
| 54 | notifyOnCompletion: true, | ||
| 55 | notifyOnFailure: true, | ||
| 56 | }, | ||
| 57 | liveActivities: { | ||
| 58 | enabled: true, | ||
| 59 | }, | ||
| 60 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 61 | } satisfies RelayClientDeviceRecord; | ||
| 62 | |||
| 63 | function resetRegistry() { | ||
| 64 | registry.dispose(); | ||
| 65 | registry = AtomRegistry.make(); | ||
| 66 | } | ||
| 67 | |||
| 68 | function createManager( | ||
| 69 | overrides?: Partial<ManagedRelayClientShape>, | ||
| 70 | onQueryEvent?: (event: ManagedRelayQueryEvent) => void, | ||
| 71 | ) { | ||
| 72 | const client = ManagedRelayClient.of({ | ||
| 73 | relayUrl: "https://relay.example.test", | ||
| 74 | listEnvironments: () => Effect.succeed([environment]), | ||
| 75 | listDevices: () => Effect.succeed([device]), | ||
| 76 | createEnvironmentLinkChallenge: () => Effect.die("unused"), | ||
| 77 | linkEnvironment: () => Effect.die("unused"), | ||
| 78 | unlinkEnvironment: () => Effect.die("unused"), | ||
| 79 | getEnvironmentStatus: () => | ||
| 80 | Effect.succeed({ | ||
| 81 | environmentId: environment.environmentId, | ||
| 82 | endpoint: environment.endpoint, | ||
| 83 | status: "online", | ||
| 84 | checkedAt: "2026-06-01T00:00:00.000Z", | ||
| 85 | }), | ||
| 86 | connectEnvironment: () => Effect.die("unused"), | ||
| 87 | registerDevice: () => Effect.die("unused"), | ||
| 88 | unregisterDevice: () => Effect.die("unused"), | ||
| 89 | registerLiveActivity: () => Effect.die("unused"), | ||
| 90 | resetTokenCache: Effect.void, | ||
| 91 | ...overrides, | ||
| 92 | }); | ||
| 93 | const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); | ||
| 94 | return createManagedRelayQueryManager(runtime, { | ||
| 95 | staleTimeMs: 60_000, | ||
| 96 | ...(onQueryEvent ? { onQueryEvent } : {}), | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | |||
| 100 | function setSession() { | ||
| 101 | setManagedRelaySession(registry, { | ||
| 102 | accountId: "account-1", | ||
| 103 | readClerkToken: () => Promise.resolve("clerk-token"), | ||
| 104 | }); | ||
| 105 | } | ||
| 106 | |||
| 107 | function clerkToken(expiresAtSeconds: number): string { | ||
| 108 | const encode = (value: unknown) => | ||
| 109 | btoa(JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); | ||
| 110 | return `${encode({ alg: "none" })}.${encode({ exp: expiresAtSeconds })}.signature`; | ||
| 111 | } | ||
| 112 | |||
| 113 | describe("createManagedRelayQueryManager", () => { | ||
| 114 | afterEach(resetRegistry); | ||
| 115 | |||
| 116 | it.effect("waits for the current cloud session before reading its token", () => | ||
| 117 | Effect.gen(function* () { | ||
| 118 | const tokenFiber = yield* waitForManagedRelayClerkToken(registry).pipe(Effect.forkChild); | ||
| 119 | |||
| 120 | setSession(); | ||
| 121 | |||
| 122 | expect(yield* Fiber.join(tokenFiber)).toBe("clerk-token"); | ||
| 123 | expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | const token = clerkToken(4_102_444_800); | ||
| 130 | let resolveToken!: (value: string) => void; | ||
| 131 | const readClerkToken = vi.fn( | ||
| 132 | () => | ||
| 133 | new Promise<string>((resolve) => { | ||
| 134 | resolveToken = resolve; | ||
| 135 | }), | ||
| 136 | ); | ||
| 137 | const session = createManagedRelaySession({ | ||
| 138 | accountId: "account-1", | ||
| 139 | readClerkToken, | ||
| 140 | }); | ||
| 141 | |||
| 142 | const readsFiber = yield* Effect.all([session.readClerkToken(), session.readClerkToken()], { | ||
| 143 | concurrency: "unbounded", | ||
| 144 | }).pipe(Effect.forkChild); | ||
| 145 | yield* Effect.yieldNow; | ||
| 146 | expect(readClerkToken).toHaveBeenCalledTimes(1); | ||
| 147 | |||
| 148 | resolveToken(token); | ||
| 149 | expect(yield* Fiber.join(readsFiber)).toEqual([token, token]); | ||
| 150 | expect(yield* session.readClerkToken()).toBe(token); | ||
| 151 | expect(readClerkToken).toHaveBeenCalledTimes(1); | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | it.effect("updates the token provider without replacing a same-account session", () => | ||
| 156 | Effect.gen(function* () { | ||
| 157 | const firstRead = vi.fn(() => Promise.resolve<string | null>(null)); | ||
| 158 | setManagedRelaySession(registry, { | ||
| 159 | accountId: "account-1", | ||
| 160 | readClerkToken: firstRead, | ||
| 161 | }); | ||
| 162 | const firstSession = registry.get(managedRelaySessionAtom); | ||
| 163 | expect(firstSession).not.toBeNull(); | ||
| 164 | expect(yield* firstSession!.readClerkToken()).toBeNull(); | ||
| 165 | |||
| 166 | const secondRead = vi.fn(() => Promise.resolve<string | null>("refreshed-token")); | ||
| 167 | setManagedRelaySession(registry, { | ||
| 168 | accountId: "account-1", | ||
| 169 | readClerkToken: secondRead, | ||
| 170 | }); | ||
| 171 | |||
| 172 | expect(registry.get(managedRelaySessionAtom)).toBe(firstSession); | ||
| 173 | expect(yield* firstSession!.readClerkToken()).toBe("refreshed-token"); | ||
| 174 | expect(firstRead).toHaveBeenCalledTimes(1); | ||
| 175 | expect(secondRead).toHaveBeenCalledTimes(1); | ||
| 176 | }), | ||
| 177 | ); | ||
| 178 | |||
| 179 | it.effect("does not pin a refreshed session to an older pending token read", () => | ||
| 180 | Effect.gen(function* () { | ||
| 181 | let resolveFirst!: (token: string) => void; | ||
| 182 | setManagedRelaySession(registry, { | ||
| 183 | accountId: "account-1", | ||
| 184 | readClerkToken: () => | ||
| 185 | new Promise<string>((resolve) => { | ||
| 186 | resolveFirst = resolve; | ||
| 187 | }), | ||
| 188 | }); | ||
| 189 | const session = registry.get(managedRelaySessionAtom); | ||
| 190 | const firstRead = yield* session!.readClerkToken().pipe(Effect.forkChild); | ||
| 191 | yield* Effect.yieldNow; | ||
| 192 | |||
| 193 | setManagedRelaySession(registry, { | ||
| 194 | accountId: "account-1", | ||
| 195 | readClerkToken: () => Promise.resolve("refreshed-token"), | ||
| 196 | }); | ||
| 197 | |||
| 198 | expect(yield* session!.readClerkToken()).toBe("refreshed-token"); | ||
| 199 | resolveFirst("older-token"); | ||
| 200 | expect(yield* Fiber.join(firstRead)).toBe("older-token"); | ||
| 201 | }), | ||
| 202 | ); | ||
| 203 | |||
| 204 | it("emits credential changes only when the managed relay account changes", async () => { | ||
| 205 | setManagedRelaySession(registry, { | ||
| 206 | accountId: "account-1", | ||
| 207 | readClerkToken: () => Promise.resolve("first-token"), | ||
| 208 | }); | ||
| 209 | const changes = Effect.runPromise( | ||
| 210 | managedRelayAccountChanges(registry).pipe(Stream.take(2), Stream.runCollect), | ||
| 211 | ); | ||
| 212 | await vi.waitFor(() => { | ||
| 213 | expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(0); | ||
| 214 | }); | ||
| 215 | |||
| 216 | setManagedRelaySession(registry, { | ||
| 217 | accountId: "account-1", | ||
| 218 | readClerkToken: () => Promise.resolve("refreshed-token"), | ||
| 219 | }); | ||
| 220 | setManagedRelaySession(registry, { | ||
| 221 | accountId: "account-2", | ||
| 222 | readClerkToken: () => Promise.resolve("second-token"), | ||
| 223 | }); | ||
| 224 | setManagedRelaySession(registry, null); | ||
| 225 | |||
| 226 | expect(Array.from(await changes)).toEqual(["account-2", null]); | ||
| 227 | }); | ||
| 228 | |||
| 229 | it("shares one Clerk token read across concurrent relay list and status queries", async () => { | ||
| 230 | const secondEnvironment = { | ||
| 231 | ...environment, | ||
| 232 | environmentId: EnvironmentId.make("environment-2"), | ||
| 233 | label: "Second environment", | ||
| 234 | endpoint: { | ||
| 235 | ...environment.endpoint, | ||
| 236 | httpBaseUrl: "https://environment-2.example.test", | ||
| 237 | wsBaseUrl: "wss://environment-2.example.test", | ||
| 238 | }, | ||
| 239 | } satisfies RelayClientEnvironmentRecord; | ||
| 240 | const token = clerkToken(4_102_444_800); | ||
| 241 | const readClerkToken = vi.fn(() => Promise.resolve(token)); | ||
| 242 | const manager = createManager({ | ||
| 243 | listEnvironments: () => Effect.succeed([environment, secondEnvironment]), | ||
| 244 | getEnvironmentStatus: ({ environmentId }) => { | ||
| 245 | const current = | ||
| 246 | environmentId === environment.environmentId ? environment : secondEnvironment; | ||
| 247 | return Effect.succeed({ | ||
| 248 | environmentId: current.environmentId, | ||
| 249 | endpoint: current.endpoint, | ||
| 250 | status: "online" as const, | ||
| 251 | checkedAt: "2026-06-01T00:00:00.000Z", | ||
| 252 | }); | ||
| 253 | }, | ||
| 254 | }); | ||
| 255 | setManagedRelaySession(registry, { | ||
| 256 | accountId: "account-1", | ||
| 257 | readClerkToken, | ||
| 258 | }); | ||
| 259 | |||
| 260 | const environmentsAtom = manager.environmentsAtom("account-1"); | ||
| 261 | const firstStatusAtom = manager.environmentStatusAtom({ | ||
| 262 | accountId: "account-1", | ||
| 263 | environment, | ||
| 264 | }); | ||
| 265 | const secondStatusAtom = manager.environmentStatusAtom({ | ||
| 266 | accountId: "account-1", | ||
| 267 | environment: secondEnvironment, | ||
| 268 | }); | ||
| 269 | registry.get(environmentsAtom); | ||
| 270 | registry.get(firstStatusAtom); | ||
| 271 | registry.get(secondStatusAtom); | ||
| 272 | |||
| 273 | await vi.waitFor(() => { | ||
| 274 | expect(readManagedRelaySnapshotState(registry.get(firstStatusAtom)).data?.status).toBe( | ||
| 275 | "online", | ||
| 276 | ); | ||
| 277 | expect(readManagedRelaySnapshotState(registry.get(secondStatusAtom)).data?.status).toBe( | ||
| 278 | "online", | ||
| 279 | ); | ||
| 280 | }); | ||
| 281 | expect(readClerkToken).toHaveBeenCalledTimes(1); | ||
| 282 | }); | ||
| 283 | |||
| 284 | it("keeps environment snapshots cached and refreshes them explicitly", async () => { | ||
| 285 | const listEnvironments = vi.fn(() => Effect.succeed([environment])); | ||
| 286 | const manager = createManager({ listEnvironments }); | ||
| 287 | setSession(); | ||
| 288 | const atom = manager.environmentsAtom("account-1"); | ||
| 289 | |||
| 290 | registry.get(atom); | ||
| 291 | await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); | ||
| 292 | |||
| 293 | registry.get(manager.environmentsAtom("account-1")); | ||
| 294 | expect(listEnvironments).toHaveBeenCalledTimes(1); | ||
| 295 | |||
| 296 | manager.refreshEnvironments(registry, "account-1"); | ||
| 297 | await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); | ||
| 298 | }); | ||
| 299 | |||
| 300 | it("loads device snapshots through the current account session", async () => { | ||
| 301 | const listDevices = vi.fn(() => Effect.succeed([device])); | ||
| 302 | const manager = createManager({ listDevices }); | ||
| 303 | setSession(); | ||
| 304 | const atom = manager.devicesAtom("account-1"); | ||
| 305 | |||
| 306 | registry.get(atom); | ||
| 307 | await vi.waitFor(() => { | ||
| 308 | expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); | ||
| 309 | }); | ||
| 310 | }); | ||
| 311 | |||
| 312 | it("reports token and relay request phases for environment status queries", async () => { | ||
| 313 | const onQueryEvent = vi.fn(); | ||
| 314 | const manager = createManager(undefined, onQueryEvent); | ||
| 315 | setSession(); | ||
| 316 | const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); | ||
| 317 | |||
| 318 | registry.get(atom); | ||
| 319 | await vi.waitFor(() => { | ||
| 320 | expect(readManagedRelaySnapshotState(registry.get(atom)).data?.status).toBe("online"); | ||
| 321 | }); | ||
| 322 | |||
| 323 | expect(onQueryEvent).toHaveBeenCalledWith({ | ||
| 324 | operation: "environment-status", | ||
| 325 | stage: "clerk-token", | ||
| 326 | phase: "start", | ||
| 327 | accountId: "account-1", | ||
| 328 | environmentId: environment.environmentId, | ||
| 329 | }); | ||
| 330 | expect(onQueryEvent).toHaveBeenCalledWith( | ||
| 331 | expect.objectContaining({ | ||
| 332 | operation: "environment-status", | ||
| 333 | stage: "relay-request", | ||
| 334 | phase: "success", | ||
| 335 | accountId: "account-1", | ||
| 336 | environmentId: environment.environmentId, | ||
| 337 | }), | ||
| 338 | ); | ||
| 339 | }); | ||
| 340 | |||
| 341 | it("rejects status responses for a different environment", async () => { | ||
| 342 | const mismatchedStatus = { | ||
| 343 | environmentId: EnvironmentId.make("environment-2"), | ||
| 344 | endpoint: environment.endpoint, | ||
| 345 | status: "online", | ||
| 346 | checkedAt: "2026-06-01T00:00:00.000Z", | ||
| 347 | } satisfies RelayEnvironmentStatusResponse; | ||
| 348 | const manager = createManager({ | ||
| 349 | getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), | ||
| 350 | }); | ||
| 351 | setSession(); | ||
| 352 | const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); | ||
| 353 | |||
| 354 | registry.get(atom); | ||
| 355 | await vi.waitFor(() => { | ||
| 356 | expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( | ||
| 357 | "Relay returned status for a different environment.", | ||
| 358 | ); | ||
| 359 | }); | ||
| 360 | }); | ||
| 361 | |||
| 362 | it("exposes relay trace IDs alongside snapshot errors", async () => { | ||
| 363 | const manager = createManager({ | ||
| 364 | getEnvironmentStatus: () => | ||
| 365 | Effect.fail( | ||
| 366 | new ManagedRelayClientError({ | ||
| 367 | message: "Could not get relay environment status.", | ||
| 368 | traceId: "trace-status", | ||
| 369 | }), | ||
| 370 | ), | ||
| 371 | }); | ||
| 372 | setSession(); | ||
| 373 | const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); | ||
| 374 | |||
| 375 | registry.get(atom); | ||
| 376 | await vi.waitFor(() => { | ||
| 377 | expect(readManagedRelaySnapshotState(registry.get(atom))).toMatchObject({ | ||
| 378 | error: "Could not get relay environment status.", | ||
| 379 | errorTraceId: "trace-status", | ||
| 380 | }); | ||
| 381 | }); | ||
| 382 | }); | ||
| 383 | }); | ||
packages/client-runtime/src/relay/managedRelayState.ts created+450| ... | @@ -0,0 +1,450 @@ | ||
| 1 | import type { | ||
| 2 | RelayClientEnvironmentRecord, | ||
| 3 | RelayEnvironmentStatusResponse, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import { | ||
| 6 | RelayEnvironmentConnectScope, | ||
| 7 | RelayEnvironmentStatusScope, | ||
| 8 | } from "@t3tools/contracts/relay"; | ||
| 9 | import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; | ||
| 10 | import * as Cause from "effect/Cause"; | ||
| 11 | import * as Clock from "effect/Clock"; | ||
| 12 | import * as Data from "effect/Data"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Option from "effect/Option"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 17 | |||
| 18 | import { findErrorTraceId } from "../errors/errorTrace.ts"; | ||
| 19 | import { ManagedRelayClient } from "./managedRelay.ts"; | ||
| 20 | |||
| 21 | const DEFAULT_STALE_TIME_MS = 15_000; | ||
| 22 | const DEFAULT_IDLE_TTL_MS = 5 * 60_000; | ||
| 23 | const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000; | ||
| 24 | |||
| 25 | export interface ManagedRelaySession { | ||
| 26 | readonly accountId: string; | ||
| 27 | readonly readClerkToken: () => Effect.Effect<string | null, ManagedRelaySessionError>; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface ManagedRelaySessionInput { | ||
| 31 | readonly accountId: string; | ||
| 32 | readonly readClerkToken: () => Promise<string | null>; | ||
| 33 | } | ||
| 34 | |||
| 35 | interface ManagedRelaySessionControl { | ||
| 36 | readonly updateReadClerkToken: ( | ||
| 37 | readClerkToken: ManagedRelaySessionInput["readClerkToken"], | ||
| 38 | ) => void; | ||
| 39 | } | ||
| 40 | |||
| 41 | export interface ManagedRelaySnapshotState<A> { | ||
| 42 | readonly data: A | null; | ||
| 43 | readonly error: string | null; | ||
| 44 | readonly errorTraceId: string | null; | ||
| 45 | readonly isPending: boolean; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface ManagedRelayQueryEvent { | ||
| 49 | readonly operation: "environments" | "devices" | "environment-status"; | ||
| 50 | readonly stage: "clerk-token" | "relay-request" | "validation"; | ||
| 51 | readonly phase: "start" | "success" | "failure"; | ||
| 52 | readonly accountId: string; | ||
| 53 | readonly environmentId?: string; | ||
| 54 | readonly message?: string; | ||
| 55 | readonly traceId?: string | null; | ||
| 56 | } | ||
| 57 | |||
| 58 | export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{ | ||
| 59 | readonly message: string; | ||
| 60 | readonly cause?: unknown; | ||
| 61 | }> {} | ||
| 62 | |||
| 63 | export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{ | ||
| 64 | readonly message: string; | ||
| 65 | }> {} | ||
| 66 | |||
| 67 | export const managedRelaySessionAtom = Atom.make<ManagedRelaySession | null>(null).pipe( | ||
| 68 | Atom.keepAlive, | ||
| 69 | Atom.withLabel("managed-relay:session"), | ||
| 70 | ); | ||
| 71 | |||
| 72 | const managedRelaySessionControls = new WeakMap<ManagedRelaySession, ManagedRelaySessionControl>(); | ||
| 73 | |||
| 74 | export function createManagedRelaySession(input: ManagedRelaySessionInput): ManagedRelaySession { | ||
| 75 | let cachedToken: { readonly token: string; readonly expiresAtMillis: number } | null = null; | ||
| 76 | let pendingToken: Promise<string | null> | null = null; | ||
| 77 | let readClerkToken = input.readClerkToken; | ||
| 78 | let tokenProviderGeneration = 0; | ||
| 79 | |||
| 80 | const readCachedClerkToken = async (nowMillis: number): Promise<string | null> => { | ||
| 81 | if (cachedToken && cachedToken.expiresAtMillis > nowMillis + CLERK_TOKEN_EXPIRY_SKEW_MS) { | ||
| 82 | return cachedToken.token; | ||
| 83 | } | ||
| 84 | if (pendingToken) { | ||
| 85 | return await pendingToken; | ||
| 86 | } | ||
| 87 | |||
| 88 | const operationGeneration = tokenProviderGeneration; | ||
| 89 | const operation = readClerkToken().then((token) => { | ||
| 90 | if (operationGeneration !== tokenProviderGeneration) { | ||
| 91 | return token; | ||
| 92 | } | ||
| 93 | if (!token) { | ||
| 94 | cachedToken = null; | ||
| 95 | return null; | ||
| 96 | } | ||
| 97 | try { | ||
| 98 | const expiresAtSeconds = decodeRelayJwt(token).exp; | ||
| 99 | cachedToken = | ||
| 100 | typeof expiresAtSeconds === "number" | ||
| 101 | ? { token, expiresAtMillis: expiresAtSeconds * 1_000 } | ||
| 102 | : null; | ||
| 103 | } catch { | ||
| 104 | cachedToken = null; | ||
| 105 | } | ||
| 106 | return token; | ||
| 107 | }); | ||
| 108 | pendingToken = operation; | ||
| 109 | try { | ||
| 110 | return await operation; | ||
| 111 | } finally { | ||
| 112 | if (pendingToken === operation) { | ||
| 113 | pendingToken = null; | ||
| 114 | } | ||
| 115 | } | ||
| 116 | }; | ||
| 117 | |||
| 118 | const session: ManagedRelaySession = { | ||
| 119 | accountId: input.accountId, | ||
| 120 | readClerkToken: Effect.fn("clientRuntime.managedRelaySession.readClerkToken")(function* () { | ||
| 121 | const nowMillis = yield* Clock.currentTimeMillis; | ||
| 122 | return yield* Effect.tryPromise({ | ||
| 123 | try: () => readCachedClerkToken(nowMillis), | ||
| 124 | catch: (cause) => | ||
| 125 | new ManagedRelaySessionError({ | ||
| 126 | message: "Could not obtain the T3 Cloud session token.", | ||
| 127 | cause, | ||
| 128 | }), | ||
| 129 | }); | ||
| 130 | }), | ||
| 131 | }; | ||
| 132 | managedRelaySessionControls.set(session, { | ||
| 133 | updateReadClerkToken: (nextReadClerkToken) => { | ||
| 134 | readClerkToken = nextReadClerkToken; | ||
| 135 | tokenProviderGeneration += 1; | ||
| 136 | pendingToken = null; | ||
| 137 | }, | ||
| 138 | }); | ||
| 139 | return session; | ||
| 140 | } | ||
| 141 | |||
| 142 | export function setManagedRelaySession( | ||
| 143 | registry: AtomRegistry.AtomRegistry, | ||
| 144 | input: ManagedRelaySessionInput | null, | ||
| 145 | ): void { | ||
| 146 | const current = registry.get(managedRelaySessionAtom); | ||
| 147 | if (input === null) { | ||
| 148 | if (current !== null) { | ||
| 149 | registry.set(managedRelaySessionAtom, null); | ||
| 150 | } | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | if (current?.accountId === input.accountId) { | ||
| 154 | const control = managedRelaySessionControls.get(current); | ||
| 155 | if (control) { | ||
| 156 | // Clerk can replace its token reader during routine same-account refreshes. | ||
| 157 | // Keep the session stable so those refreshes do not invalidate queries or reconnect leases. | ||
| 158 | control.updateReadClerkToken(input.readClerkToken); | ||
| 159 | return; | ||
| 160 | } | ||
| 161 | } | ||
| 162 | registry.set(managedRelaySessionAtom, createManagedRelaySession(input)); | ||
| 163 | } | ||
| 164 | |||
| 165 | export function managedRelayAccountChanges( | ||
| 166 | registry: AtomRegistry.AtomRegistry, | ||
| 167 | ): Stream.Stream<string | null> { | ||
| 168 | return AtomRegistry.toStream(registry, managedRelaySessionAtom).pipe( | ||
| 169 | Stream.map((session) => session?.accountId ?? null), | ||
| 170 | Stream.changes, | ||
| 171 | Stream.drop(1), | ||
| 172 | ); | ||
| 173 | } | ||
| 174 | |||
| 175 | function readSessionClerkToken( | ||
| 176 | session: ManagedRelaySession, | ||
| 177 | ): Effect.Effect<string, ManagedRelaySessionError> { | ||
| 178 | return session.readClerkToken().pipe( | ||
| 179 | Effect.flatMap((token) => | ||
| 180 | token | ||
| 181 | ? Effect.succeed(token) | ||
| 182 | : Effect.fail( | ||
| 183 | new ManagedRelaySessionError({ | ||
| 184 | message: "The T3 Cloud session token is unavailable.", | ||
| 185 | }), | ||
| 186 | ), | ||
| 187 | ), | ||
| 188 | ); | ||
| 189 | } | ||
| 190 | |||
| 191 | export const waitForManagedRelayClerkToken = Effect.fn( | ||
| 192 | "clientRuntime.managedRelaySession.waitForClerkToken", | ||
| 193 | )(function* (registry: AtomRegistry.AtomRegistry) { | ||
| 194 | return yield* Effect.callback<string, ManagedRelaySessionError>((resume) => { | ||
| 195 | let unsubscribe: (() => void) | undefined; | ||
| 196 | let completed = false; | ||
| 197 | const readCurrentSession = () => { | ||
| 198 | if (completed) { | ||
| 199 | return true; | ||
| 200 | } | ||
| 201 | const session = registry.get(managedRelaySessionAtom); | ||
| 202 | if (!session) { | ||
| 203 | return false; | ||
| 204 | } | ||
| 205 | completed = true; | ||
| 206 | unsubscribe?.(); | ||
| 207 | resume(readSessionClerkToken(session)); | ||
| 208 | return true; | ||
| 209 | }; | ||
| 210 | |||
| 211 | if (readCurrentSession()) { | ||
| 212 | return; | ||
| 213 | } | ||
| 214 | |||
| 215 | unsubscribe = registry.subscribe(managedRelaySessionAtom, readCurrentSession); | ||
| 216 | readCurrentSession(); | ||
| 217 | return Effect.sync(() => unsubscribe?.()); | ||
| 218 | }); | ||
| 219 | }); | ||
| 220 | |||
| 221 | function requireClerkToken( | ||
| 222 | get: Atom.AtomContext, | ||
| 223 | accountId: string, | ||
| 224 | ): Effect.Effect<string, ManagedRelaySessionError> { | ||
| 225 | const session = get(managedRelaySessionAtom); | ||
| 226 | if (!session || session.accountId !== accountId) { | ||
| 227 | return Effect.fail( | ||
| 228 | new ManagedRelaySessionError({ | ||
| 229 | message: "Sign in to T3 Cloud before loading relay data.", | ||
| 230 | }), | ||
| 231 | ); | ||
| 232 | } | ||
| 233 | return readSessionClerkToken(session); | ||
| 234 | } | ||
| 235 | |||
| 236 | function statusKey(input: { | ||
| 237 | readonly accountId: string; | ||
| 238 | readonly environment: RelayClientEnvironmentRecord; | ||
| 239 | }): string { | ||
| 240 | return JSON.stringify(input); | ||
| 241 | } | ||
| 242 | |||
| 243 | function parseStatusKey(key: string): { | ||
| 244 | readonly accountId: string; | ||
| 245 | readonly environment: RelayClientEnvironmentRecord; | ||
| 246 | } { | ||
| 247 | return JSON.parse(key) as { | ||
| 248 | readonly accountId: string; | ||
| 249 | readonly environment: RelayClientEnvironmentRecord; | ||
| 250 | }; | ||
| 251 | } | ||
| 252 | |||
| 253 | function endpointMatches( | ||
| 254 | left: RelayClientEnvironmentRecord["endpoint"], | ||
| 255 | right: RelayClientEnvironmentRecord["endpoint"], | ||
| 256 | ): boolean { | ||
| 257 | return ( | ||
| 258 | left.httpBaseUrl === right.httpBaseUrl && | ||
| 259 | left.wsBaseUrl === right.wsBaseUrl && | ||
| 260 | left.providerKind === right.providerKind | ||
| 261 | ); | ||
| 262 | } | ||
| 263 | |||
| 264 | function validateEnvironmentStatus( | ||
| 265 | environment: RelayClientEnvironmentRecord, | ||
| 266 | status: RelayEnvironmentStatusResponse, | ||
| 267 | ): Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelaySnapshotError> { | ||
| 268 | if (status.environmentId !== environment.environmentId) { | ||
| 269 | return Effect.fail( | ||
| 270 | new ManagedRelaySnapshotError({ | ||
| 271 | message: "Relay returned status for a different environment.", | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | } | ||
| 275 | if (!endpointMatches(status.endpoint, environment.endpoint)) { | ||
| 276 | return Effect.fail( | ||
| 277 | new ManagedRelaySnapshotError({ | ||
| 278 | message: "Relay returned status for a different endpoint.", | ||
| 279 | }), | ||
| 280 | ); | ||
| 281 | } | ||
| 282 | if (status.descriptor && status.descriptor.environmentId !== environment.environmentId) { | ||
| 283 | return Effect.fail( | ||
| 284 | new ManagedRelaySnapshotError({ | ||
| 285 | message: "Relay returned status descriptor for a different environment.", | ||
| 286 | }), | ||
| 287 | ); | ||
| 288 | } | ||
| 289 | return Effect.succeed(status); | ||
| 290 | } | ||
| 291 | |||
| 292 | export function readManagedRelaySnapshotState<A>( | ||
| 293 | result: AsyncResult.AsyncResult<A, unknown>, | ||
| 294 | ): ManagedRelaySnapshotState<A> { | ||
| 295 | let error: string | null = null; | ||
| 296 | let errorTraceId: string | null = null; | ||
| 297 | if (result._tag === "Failure") { | ||
| 298 | const cause = Cause.squash(result.cause); | ||
| 299 | error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; | ||
| 300 | errorTraceId = findErrorTraceId(cause); | ||
| 301 | } | ||
| 302 | return { | ||
| 303 | data: Option.getOrNull(AsyncResult.value(result)), | ||
| 304 | error, | ||
| 305 | errorTraceId, | ||
| 306 | isPending: result.waiting, | ||
| 307 | }; | ||
| 308 | } | ||
| 309 | |||
| 310 | export function createManagedRelayQueryManager( | ||
| 311 | runtime: Atom.AtomRuntime<ManagedRelayClient>, | ||
| 312 | options?: { | ||
| 313 | readonly staleTimeMs?: number; | ||
| 314 | readonly idleTtlMs?: number; | ||
| 315 | readonly onQueryEvent?: (event: ManagedRelayQueryEvent) => void; | ||
| 316 | }, | ||
| 317 | ) { | ||
| 318 | const staleTime = options?.staleTimeMs ?? DEFAULT_STALE_TIME_MS; | ||
| 319 | const idleTtl = options?.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; | ||
| 320 | const observe = <A, E, R>( | ||
| 321 | input: Omit<ManagedRelayQueryEvent, "phase" | "message" | "traceId">, | ||
| 322 | effect: Effect.Effect<A, E, R>, | ||
| 323 | ): Effect.Effect<A, E, R> => | ||
| 324 | Effect.gen(function* () { | ||
| 325 | options?.onQueryEvent?.({ ...input, phase: "start" }); | ||
| 326 | return yield* effect.pipe( | ||
| 327 | Effect.onExit((exit) => | ||
| 328 | Effect.sync(() => { | ||
| 329 | if (exit._tag === "Success") { | ||
| 330 | options?.onQueryEvent?.({ ...input, phase: "success" }); | ||
| 331 | return; | ||
| 332 | } | ||
| 333 | const error = Cause.squash(exit.cause); | ||
| 334 | options?.onQueryEvent?.({ | ||
| 335 | ...input, | ||
| 336 | phase: "failure", | ||
| 337 | message: error instanceof Error ? error.message : String(error), | ||
| 338 | traceId: findErrorTraceId(error), | ||
| 339 | }); | ||
| 340 | }), | ||
| 341 | ), | ||
| 342 | ); | ||
| 343 | }); | ||
| 344 | |||
| 345 | const environmentsAtom = Atom.family((accountId: string) => | ||
| 346 | runtime | ||
| 347 | .atom((get) => | ||
| 348 | Effect.gen(function* () { | ||
| 349 | const base = { operation: "environments" as const, accountId }; | ||
| 350 | const clerkToken = yield* observe( | ||
| 351 | { ...base, stage: "clerk-token" }, | ||
| 352 | requireClerkToken(get, accountId), | ||
| 353 | ); | ||
| 354 | const relay = yield* ManagedRelayClient; | ||
| 355 | return yield* observe( | ||
| 356 | { ...base, stage: "relay-request" }, | ||
| 357 | relay.listEnvironments({ clerkToken }), | ||
| 358 | ); | ||
| 359 | }), | ||
| 360 | ) | ||
| 361 | .pipe( | ||
| 362 | Atom.swr({ staleTime, revalidateOnMount: true }), | ||
| 363 | Atom.setIdleTTL(idleTtl), | ||
| 364 | Atom.withLabel(`managed-relay:environments:${accountId}`), | ||
| 365 | ), | ||
| 366 | ); | ||
| 367 | |||
| 368 | const devicesAtom = Atom.family((accountId: string) => | ||
| 369 | runtime | ||
| 370 | .atom((get) => | ||
| 371 | Effect.gen(function* () { | ||
| 372 | const base = { operation: "devices" as const, accountId }; | ||
| 373 | const clerkToken = yield* observe( | ||
| 374 | { ...base, stage: "clerk-token" }, | ||
| 375 | requireClerkToken(get, accountId), | ||
| 376 | ); | ||
| 377 | const relay = yield* ManagedRelayClient; | ||
| 378 | return yield* observe( | ||
| 379 | { ...base, stage: "relay-request" }, | ||
| 380 | relay.listDevices({ clerkToken }), | ||
| 381 | ); | ||
| 382 | }), | ||
| 383 | ) | ||
| 384 | .pipe( | ||
| 385 | Atom.swr({ staleTime, revalidateOnMount: true }), | ||
| 386 | Atom.setIdleTTL(idleTtl), | ||
| 387 | Atom.withLabel(`managed-relay:devices:${accountId}`), | ||
| 388 | ), | ||
| 389 | ); | ||
| 390 | |||
| 391 | const environmentStatusAtom = Atom.family((key: string) => { | ||
| 392 | const { accountId, environment } = parseStatusKey(key); | ||
| 393 | return runtime | ||
| 394 | .atom((get) => | ||
| 395 | Effect.gen(function* () { | ||
| 396 | const base = { | ||
| 397 | operation: "environment-status" as const, | ||
| 398 | accountId, | ||
| 399 | environmentId: environment.environmentId, | ||
| 400 | }; | ||
| 401 | const clerkToken = yield* observe( | ||
| 402 | { ...base, stage: "clerk-token" }, | ||
| 403 | requireClerkToken(get, accountId), | ||
| 404 | ); | ||
| 405 | const relay = yield* ManagedRelayClient; | ||
| 406 | const status = yield* observe( | ||
| 407 | { ...base, stage: "relay-request" }, | ||
| 408 | relay.getEnvironmentStatus({ | ||
| 409 | clerkToken, | ||
| 410 | scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], | ||
| 411 | environmentId: environment.environmentId, | ||
| 412 | }), | ||
| 413 | ); | ||
| 414 | return yield* observe( | ||
| 415 | { ...base, stage: "validation" }, | ||
| 416 | validateEnvironmentStatus(environment, status), | ||
| 417 | ); | ||
| 418 | }), | ||
| 419 | ) | ||
| 420 | .pipe( | ||
| 421 | Atom.swr({ staleTime, revalidateOnMount: true }), | ||
| 422 | Atom.setIdleTTL(idleTtl), | ||
| 423 | Atom.withLabel(`managed-relay:environment-status:${key}`), | ||
| 424 | ); | ||
| 425 | }); | ||
| 426 | |||
| 427 | return { | ||
| 428 | environmentsAtom, | ||
| 429 | devicesAtom, | ||
| 430 | environmentStatusAtom: (input: { | ||
| 431 | readonly accountId: string; | ||
| 432 | readonly environment: RelayClientEnvironmentRecord; | ||
| 433 | }) => environmentStatusAtom(statusKey(input)), | ||
| 434 | refreshEnvironments(registry: AtomRegistry.AtomRegistry, accountId: string): void { | ||
| 435 | registry.refresh(environmentsAtom(accountId)); | ||
| 436 | }, | ||
| 437 | refreshDevices(registry: AtomRegistry.AtomRegistry, accountId: string): void { | ||
| 438 | registry.refresh(devicesAtom(accountId)); | ||
| 439 | }, | ||
| 440 | refreshEnvironmentStatus( | ||
| 441 | registry: AtomRegistry.AtomRegistry, | ||
| 442 | input: { | ||
| 443 | readonly accountId: string; | ||
| 444 | readonly environment: RelayClientEnvironmentRecord; | ||
| 445 | }, | ||
| 446 | ): void { | ||
| 447 | registry.refresh(environmentStatusAtom(statusKey(input))); | ||
| 448 | }, | ||
| 449 | }; | ||
| 450 | } | ||
packages/client-runtime/src/remote.test.ts deleted-475| ... | @@ -1,475 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import * as TestClock from "effect/testing/TestClock"; | ||
| 7 | |||
| 8 | import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; | ||
| 9 | import { | ||
| 10 | bootstrapRemoteBearerSession, | ||
| 11 | exchangeRemoteDpopAccessToken, | ||
| 12 | fetchRemoteDpopSessionState, | ||
| 13 | fetchRemoteEnvironmentDescriptor, | ||
| 14 | fetchRemoteSessionState, | ||
| 15 | issueRemoteDpopWebSocketTicket, | ||
| 16 | issueRemoteWebSocketTicket, | ||
| 17 | remoteHttpClientLayer, | ||
| 18 | RemoteEnvironmentAuthInvalidJsonError, | ||
| 19 | RemoteEnvironmentAuthTimeoutError, | ||
| 20 | resolveRemoteWebSocketConnectionUrl, | ||
| 21 | } from "./remote.ts"; | ||
| 22 | |||
| 23 | const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); | ||
| 24 | |||
| 25 | type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; | ||
| 26 | |||
| 27 | const recordedFetch = (...responses: ReadonlyArray<Response>) => { | ||
| 28 | const calls: Array<FetchCall> = []; | ||
| 29 | let responseIndex = 0; | ||
| 30 | const fetchFn = ((input, init) => { | ||
| 31 | calls.push([input, init ?? {}]); | ||
| 32 | const response = responses[responseIndex++]; | ||
| 33 | if (!response) { | ||
| 34 | return Promise.reject(new Error("Unexpected fetch call")); | ||
| 35 | } | ||
| 36 | return Promise.resolve(response); | ||
| 37 | }) satisfies typeof fetch; | ||
| 38 | |||
| 39 | return { fetchFn, calls }; | ||
| 40 | }; | ||
| 41 | |||
| 42 | const hangingFetch = () => { | ||
| 43 | const calls: Array<FetchCall> = []; | ||
| 44 | const fetchFn = ((input, init) => { | ||
| 45 | calls.push([input, init ?? {}]); | ||
| 46 | return new Promise<Response>(() => undefined); | ||
| 47 | }) satisfies typeof fetch; | ||
| 48 | |||
| 49 | return { fetchFn, calls }; | ||
| 50 | }; | ||
| 51 | |||
| 52 | const provideRemoteHttp = (fetchFn: typeof fetch) => Effect.provide(remoteHttpClientLayer(fetchFn)); | ||
| 53 | |||
| 54 | const expectFetchCall = ( | ||
| 55 | calls: ReadonlyArray<FetchCall>, | ||
| 56 | index: number, | ||
| 57 | expected: { | ||
| 58 | readonly url: string; | ||
| 59 | readonly method: string; | ||
| 60 | readonly headers?: Record<string, string>; | ||
| 61 | readonly body?: string; | ||
| 62 | }, | ||
| 63 | ): void => { | ||
| 64 | const call = calls[index - 1]; | ||
| 65 | expect(call).toBeDefined(); | ||
| 66 | if (!call) { | ||
| 67 | return; | ||
| 68 | } | ||
| 69 | |||
| 70 | const [url, init] = call; | ||
| 71 | expect(String(url)).toBe(expected.url); | ||
| 72 | expect(init).toEqual( | ||
| 73 | expect.objectContaining({ | ||
| 74 | method: expected.method, | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | expect(init.headers).toEqual(expect.objectContaining(expected.headers ?? {})); | ||
| 78 | |||
| 79 | if ("body" in expected) { | ||
| 80 | const body = init.body; | ||
| 81 | if (typeof body === "string") { | ||
| 82 | expect(body).toBe(expected.body); | ||
| 83 | } else if (body instanceof Uint8Array) { | ||
| 84 | expect(new TextDecoder().decode(body)).toBe(expected.body); | ||
| 85 | } else { | ||
| 86 | throw new Error("Expected fetch request body"); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | }; | ||
| 90 | |||
| 91 | describe("remote", () => { | ||
| 92 | it.effect("bootstraps bearer auth against a remote backend", () => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const fetch = recordedFetch( | ||
| 95 | Response.json( | ||
| 96 | { | ||
| 97 | access_token: "bearer-token", | ||
| 98 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 99 | token_type: "Bearer", | ||
| 100 | expires_in: 3600, | ||
| 101 | scope: | ||
| 102 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 103 | }, | ||
| 104 | { status: 200 }, | ||
| 105 | ), | ||
| 106 | ); | ||
| 107 | |||
| 108 | const result = yield* bootstrapRemoteBearerSession({ | ||
| 109 | httpBaseUrl: "https://remote.example.com/", | ||
| 110 | credential: "pairing-token", | ||
| 111 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 112 | |||
| 113 | expect(result).toMatchObject({ | ||
| 114 | token_type: "Bearer", | ||
| 115 | access_token: "bearer-token", | ||
| 116 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 117 | }); | ||
| 118 | expectFetchCall(fetch.calls, 1, { | ||
| 119 | url: "https://remote.example.com/oauth/token", | ||
| 120 | method: "POST", | ||
| 121 | headers: { | ||
| 122 | "content-type": "application/x-www-form-urlencoded", | ||
| 123 | }, | ||
| 124 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token", | ||
| 125 | }); | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | |||
| 129 | it.effect("exchanges managed credentials and admits websocket requests with DPoP", () => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const fetch = recordedFetch( | ||
| 132 | Response.json({ | ||
| 133 | access_token: "dpop-access-token", | ||
| 134 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 135 | token_type: "DPoP", | ||
| 136 | expires_in: 3600, | ||
| 137 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 138 | }), | ||
| 139 | Response.json({ | ||
| 140 | ticket: "ws-ticket", | ||
| 141 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | |||
| 145 | const token = yield* exchangeRemoteDpopAccessToken({ | ||
| 146 | httpBaseUrl: "https://remote.example.com/", | ||
| 147 | credential: "one-time-credential", | ||
| 148 | dpopProof: "token-proof", | ||
| 149 | clientMetadata: { | ||
| 150 | label: "T3 Code Mobile", | ||
| 151 | deviceType: "mobile", | ||
| 152 | os: "iOS", | ||
| 153 | }, | ||
| 154 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 155 | yield* issueRemoteDpopWebSocketTicket({ | ||
| 156 | httpBaseUrl: "https://remote.example.com/", | ||
| 157 | accessToken: token.access_token, | ||
| 158 | dpopProof: "resource-proof", | ||
| 159 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 160 | |||
| 161 | expectFetchCall(fetch.calls, 1, { | ||
| 162 | url: "https://remote.example.com/oauth/token", | ||
| 163 | method: "POST", | ||
| 164 | headers: { dpop: "token-proof", "content-type": "application/x-www-form-urlencoded" }, | ||
| 165 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=one-time-credential&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS", | ||
| 166 | }); | ||
| 167 | expectFetchCall(fetch.calls, 2, { | ||
| 168 | url: "https://remote.example.com/api/auth/websocket-ticket", | ||
| 169 | method: "POST", | ||
| 170 | headers: { | ||
| 171 | authorization: "DPoP dpop-access-token", | ||
| 172 | dpop: "resource-proof", | ||
| 173 | }, | ||
| 174 | }); | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | |||
| 178 | it.effect("submits optional client display metadata during bearer token exchange", () => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const fetch = recordedFetch( | ||
| 181 | Response.json( | ||
| 182 | { | ||
| 183 | access_token: "bearer-token", | ||
| 184 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 185 | token_type: "Bearer", | ||
| 186 | expires_in: 3600, | ||
| 187 | scope: | ||
| 188 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 189 | }, | ||
| 190 | { status: 200 }, | ||
| 191 | ), | ||
| 192 | ); | ||
| 193 | |||
| 194 | yield* bootstrapRemoteBearerSession({ | ||
| 195 | httpBaseUrl: "https://remote.example.com/", | ||
| 196 | credential: "pairing-token", | ||
| 197 | clientMetadata: { | ||
| 198 | label: "T3 Code Mobile", | ||
| 199 | deviceType: "mobile", | ||
| 200 | os: "iOS", | ||
| 201 | }, | ||
| 202 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 203 | |||
| 204 | expectFetchCall(fetch.calls, 1, { | ||
| 205 | url: "https://remote.example.com/oauth/token", | ||
| 206 | method: "POST", | ||
| 207 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS", | ||
| 208 | }); | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | |||
| 212 | it.effect("allows a client to explicitly narrow a pairing grant", () => | ||
| 213 | Effect.gen(function* () { | ||
| 214 | const fetch = recordedFetch( | ||
| 215 | Response.json( | ||
| 216 | { | ||
| 217 | access_token: "read-only-token", | ||
| 218 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 219 | token_type: "Bearer", | ||
| 220 | expires_in: 3600, | ||
| 221 | scope: "orchestration:read", | ||
| 222 | }, | ||
| 223 | { status: 200 }, | ||
| 224 | ), | ||
| 225 | ); | ||
| 226 | |||
| 227 | yield* bootstrapRemoteBearerSession({ | ||
| 228 | httpBaseUrl: "https://remote.example.com/", | ||
| 229 | credential: "pairing-token", | ||
| 230 | scopes: ["orchestration:read"], | ||
| 231 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 232 | |||
| 233 | expectFetchCall(fetch.calls, 1, { | ||
| 234 | url: "https://remote.example.com/oauth/token", | ||
| 235 | method: "POST", | ||
| 236 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=orchestration%3Aread", | ||
| 237 | }); | ||
| 238 | }), | ||
| 239 | ); | ||
| 240 | |||
| 241 | it.effect("loads remote session state and websocket tickets over bearer auth", () => | ||
| 242 | Effect.gen(function* () { | ||
| 243 | const fetch = recordedFetch( | ||
| 244 | Response.json( | ||
| 245 | { | ||
| 246 | environmentId: "environment-remote", | ||
| 247 | label: "Remote environment", | ||
| 248 | platform: { | ||
| 249 | os: "linux", | ||
| 250 | arch: "x64", | ||
| 251 | }, | ||
| 252 | serverVersion: "0.0.0-test", | ||
| 253 | capabilities: { | ||
| 254 | repositoryIdentity: true, | ||
| 255 | }, | ||
| 256 | }, | ||
| 257 | { status: 200 }, | ||
| 258 | ), | ||
| 259 | Response.json( | ||
| 260 | { | ||
| 261 | authenticated: true, | ||
| 262 | auth: { | ||
| 263 | policy: "remote-reachable", | ||
| 264 | bootstrapMethods: ["one-time-token"], | ||
| 265 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | ||
| 266 | sessionCookieName: "t3_session", | ||
| 267 | }, | ||
| 268 | scopes: [ | ||
| 269 | "orchestration:read", | ||
| 270 | "orchestration:operate", | ||
| 271 | "terminal:operate", | ||
| 272 | "review:write", | ||
| 273 | "relay:read", | ||
| 274 | ], | ||
| 275 | sessionMethod: "bearer-access-token", | ||
| 276 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 277 | }, | ||
| 278 | { status: 200 }, | ||
| 279 | ), | ||
| 280 | Response.json( | ||
| 281 | { | ||
| 282 | ticket: "ws-ticket", | ||
| 283 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 284 | }, | ||
| 285 | { status: 200 }, | ||
| 286 | ), | ||
| 287 | ); | ||
| 288 | |||
| 289 | const environment = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 290 | httpBaseUrl: "https://remote.example.com/", | ||
| 291 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 292 | expect(environment).toMatchObject({ | ||
| 293 | environmentId: "environment-remote", | ||
| 294 | label: "Remote environment", | ||
| 295 | }); | ||
| 296 | |||
| 297 | const session = yield* fetchRemoteSessionState({ | ||
| 298 | httpBaseUrl: "https://remote.example.com/", | ||
| 299 | bearerToken: "bearer-token", | ||
| 300 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 301 | expect(session).toMatchObject({ | ||
| 302 | authenticated: true, | ||
| 303 | scopes: [ | ||
| 304 | "orchestration:read", | ||
| 305 | "orchestration:operate", | ||
| 306 | "terminal:operate", | ||
| 307 | "review:write", | ||
| 308 | "relay:read", | ||
| 309 | ], | ||
| 310 | }); | ||
| 311 | |||
| 312 | const ticket = yield* issueRemoteWebSocketTicket({ | ||
| 313 | httpBaseUrl: "https://remote.example.com/", | ||
| 314 | bearerToken: "bearer-token", | ||
| 315 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 316 | expect(ticket).toMatchObject({ | ||
| 317 | ticket: "ws-ticket", | ||
| 318 | }); | ||
| 319 | |||
| 320 | expectFetchCall(fetch.calls, 1, { | ||
| 321 | url: "https://remote.example.com/.well-known/t3/environment", | ||
| 322 | method: "GET", | ||
| 323 | }); | ||
| 324 | expectFetchCall(fetch.calls, 2, { | ||
| 325 | url: "https://remote.example.com/api/auth/session", | ||
| 326 | method: "GET", | ||
| 327 | headers: { | ||
| 328 | authorization: "Bearer bearer-token", | ||
| 329 | }, | ||
| 330 | }); | ||
| 331 | expectFetchCall(fetch.calls, 3, { | ||
| 332 | url: "https://remote.example.com/api/auth/websocket-ticket", | ||
| 333 | method: "POST", | ||
| 334 | headers: { | ||
| 335 | authorization: "Bearer bearer-token", | ||
| 336 | }, | ||
| 337 | }); | ||
| 338 | }), | ||
| 339 | ); | ||
| 340 | |||
| 341 | it.effect("loads remote session state with a DPoP-bound access token", () => | ||
| 342 | Effect.gen(function* () { | ||
| 343 | const fetch = recordedFetch( | ||
| 344 | Response.json({ | ||
| 345 | authenticated: true, | ||
| 346 | auth: { | ||
| 347 | policy: "remote-reachable", | ||
| 348 | bootstrapMethods: ["one-time-token"], | ||
| 349 | sessionMethods: ["dpop-access-token"], | ||
| 350 | sessionCookieName: "t3_session", | ||
| 351 | }, | ||
| 352 | sessionMethod: "dpop-access-token", | ||
| 353 | scopes: [ | ||
| 354 | "orchestration:read", | ||
| 355 | "orchestration:operate", | ||
| 356 | "terminal:operate", | ||
| 357 | "review:write", | ||
| 358 | ], | ||
| 359 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 360 | }), | ||
| 361 | ); | ||
| 362 | |||
| 363 | yield* fetchRemoteDpopSessionState({ | ||
| 364 | httpBaseUrl: "https://remote.example.com/", | ||
| 365 | accessToken: "dpop-access-token", | ||
| 366 | dpopProof: "dpop-proof", | ||
| 367 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 368 | |||
| 369 | expectFetchCall(fetch.calls, 1, { | ||
| 370 | url: "https://remote.example.com/api/auth/session", | ||
| 371 | method: "GET", | ||
| 372 | headers: { | ||
| 373 | authorization: "DPoP dpop-access-token", | ||
| 374 | dpop: "dpop-proof", | ||
| 375 | }, | ||
| 376 | }); | ||
| 377 | }), | ||
| 378 | ); | ||
| 379 | |||
| 380 | it.effect("fails hung fetch requests on the configured timeout", () => | ||
| 381 | Effect.gen(function* () { | ||
| 382 | const fetch = hangingFetch(); | ||
| 383 | |||
| 384 | const errorFiber = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 385 | httpBaseUrl: "http://remote.example.com/", | ||
| 386 | timeoutMs: 25, | ||
| 387 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip, Effect.forkScoped); | ||
| 388 | yield* Effect.yieldNow; | ||
| 389 | yield* TestClock.adjust(Duration.millis(25)); | ||
| 390 | const error = yield* Fiber.join(errorFiber); | ||
| 391 | |||
| 392 | expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError); | ||
| 393 | expect(error.message).toBe( | ||
| 394 | "Remote auth endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", | ||
| 395 | ); | ||
| 396 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 397 | ); | ||
| 398 | |||
| 399 | it.effect("revives declared typed errors from remote auth failures", () => | ||
| 400 | Effect.gen(function* () { | ||
| 401 | const fetch = recordedFetch( | ||
| 402 | Response.json( | ||
| 403 | { | ||
| 404 | _tag: "EnvironmentAuthInvalidError", | ||
| 405 | code: "auth_invalid", | ||
| 406 | reason: "missing_credential", | ||
| 407 | traceId: "trace-auth-test", | ||
| 408 | }, | ||
| 409 | { status: 401 }, | ||
| 410 | ), | ||
| 411 | ); | ||
| 412 | |||
| 413 | const error = yield* issueRemoteWebSocketTicket({ | ||
| 414 | httpBaseUrl: "https://remote.example.com/", | ||
| 415 | bearerToken: "expired-token", | ||
| 416 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip); | ||
| 417 | |||
| 418 | expect(isEnvironmentAuthInvalidError(error)).toBe(true); | ||
| 419 | if (isEnvironmentAuthInvalidError(error)) { | ||
| 420 | expect(error.reason).toBe("missing_credential"); | ||
| 421 | expect(error.traceId).toBe("trace-auth-test"); | ||
| 422 | } | ||
| 423 | }), | ||
| 424 | ); | ||
| 425 | |||
| 426 | it.effect("classifies malformed successful remote auth responses as invalid responses", () => | ||
| 427 | Effect.gen(function* () { | ||
| 428 | const fetch = recordedFetch( | ||
| 429 | Response.json( | ||
| 430 | { | ||
| 431 | access_token: "", | ||
| 432 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 433 | token_type: "Bearer", | ||
| 434 | expires_in: 3600, | ||
| 435 | scope: | ||
| 436 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 437 | }, | ||
| 438 | { status: 200 }, | ||
| 439 | ), | ||
| 440 | ); | ||
| 441 | |||
| 442 | const error = yield* bootstrapRemoteBearerSession({ | ||
| 443 | httpBaseUrl: "https://remote.example.com/", | ||
| 444 | credential: "pairing-token", | ||
| 445 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip); | ||
| 446 | |||
| 447 | expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError); | ||
| 448 | expect(error.message).toBe( | ||
| 449 | "Remote auth endpoint returned an invalid response from https://remote.example.com/oauth/token.", | ||
| 450 | ); | ||
| 451 | }), | ||
| 452 | ); | ||
| 453 | |||
| 454 | it.effect("mints a websocket url that targets the rpc route with a short-lived ticket", () => | ||
| 455 | Effect.gen(function* () { | ||
| 456 | const fetch = recordedFetch( | ||
| 457 | Response.json( | ||
| 458 | { | ||
| 459 | ticket: "ws-ticket", | ||
| 460 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 461 | }, | ||
| 462 | { status: 200 }, | ||
| 463 | ), | ||
| 464 | ); | ||
| 465 | |||
| 466 | const url = yield* resolveRemoteWebSocketConnectionUrl({ | ||
| 467 | wsBaseUrl: "wss://remote.example.com/", | ||
| 468 | httpBaseUrl: "https://remote.example.com/", | ||
| 469 | bearerToken: "bearer-token", | ||
| 470 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 471 | |||
| 472 | expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); | ||
| 473 | }), | ||
| 474 | ); | ||
| 475 | }); | ||
packages/client-runtime/src/remote.ts deleted-371| ... | @@ -1,371 +0,0 @@ | ||
| 1 | import { | ||
| 2 | AuthAccessTokenType, | ||
| 3 | type AuthClientPresentationMetadata, | ||
| 4 | AuthEnvironmentBootstrapTokenType, | ||
| 5 | AuthTokenExchangeGrantType, | ||
| 6 | type AuthEnvironmentScope, | ||
| 7 | EnvironmentHttpApi, | ||
| 8 | EnvironmentHttpCommonError, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import type { | ||
| 11 | EnvironmentAuthInvalidError, | ||
| 12 | EnvironmentInternalError, | ||
| 13 | EnvironmentOperationForbiddenError, | ||
| 14 | EnvironmentRequestInvalidError, | ||
| 15 | EnvironmentScopeRequiredError, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | ||
| 18 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | ||
| 19 | import * as Data from "effect/Data"; | ||
| 20 | import * as Duration from "effect/Duration"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 22 | import * as Layer from "effect/Layer"; | ||
| 23 | import * as Option from "effect/Option"; | ||
| 24 | import * as Schema from "effect/Schema"; | ||
| 25 | import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; | ||
| 26 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | ||
| 27 | |||
| 28 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; | ||
| 29 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); | ||
| 30 | |||
| 31 | export const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { | ||
| 32 | const url = new URL(httpBaseUrl); | ||
| 33 | url.pathname = pathname; | ||
| 34 | url.search = ""; | ||
| 35 | url.hash = ""; | ||
| 36 | return url.toString(); | ||
| 37 | }; | ||
| 38 | |||
| 39 | const remoteApiBaseUrl = (httpBaseUrl: string): string => { | ||
| 40 | const url = new URL(httpBaseUrl); | ||
| 41 | url.pathname = "/"; | ||
| 42 | url.search = ""; | ||
| 43 | url.hash = ""; | ||
| 44 | return url.toString(); | ||
| 45 | }; | ||
| 46 | |||
| 47 | const clientMetadataTokenExchangeFields = ( | ||
| 48 | clientMetadata: AuthClientPresentationMetadata | undefined, | ||
| 49 | ) => ({ | ||
| 50 | ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), | ||
| 51 | ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), | ||
| 52 | ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), | ||
| 53 | }); | ||
| 54 | |||
| 55 | export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( | ||
| 56 | "RemoteEnvironmentAuthFetchError", | ||
| 57 | )<{ | ||
| 58 | readonly message: string; | ||
| 59 | readonly cause: unknown; | ||
| 60 | }> {} | ||
| 61 | |||
| 62 | export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( | ||
| 63 | "RemoteEnvironmentAuthInvalidJsonError", | ||
| 64 | )<{ | ||
| 65 | readonly message: string; | ||
| 66 | readonly cause: unknown; | ||
| 67 | }> {} | ||
| 68 | |||
| 69 | export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( | ||
| 70 | "RemoteEnvironmentAuthUndeclaredStatusError", | ||
| 71 | )<{ | ||
| 72 | readonly message: string; | ||
| 73 | readonly status: number; | ||
| 74 | readonly requestUrl: string; | ||
| 75 | }> { | ||
| 76 | constructor(requestUrl: string, status: number) { | ||
| 77 | super({ | ||
| 78 | message: `Remote auth endpoint ${requestUrl} returned undeclared status ${status}.`, | ||
| 79 | requestUrl, | ||
| 80 | status, | ||
| 81 | }); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( | ||
| 86 | "RemoteEnvironmentAuthTimeoutError", | ||
| 87 | )<{ | ||
| 88 | readonly message: string; | ||
| 89 | readonly requestUrl: string; | ||
| 90 | readonly timeoutMs: number; | ||
| 91 | }> { | ||
| 92 | constructor(requestUrl: string, timeoutMs: number) { | ||
| 93 | super({ | ||
| 94 | message: `Remote auth endpoint ${requestUrl} timed out after ${timeoutMs}ms.`, | ||
| 95 | requestUrl, | ||
| 96 | timeoutMs, | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | export type RemoteEnvironmentAuthError = | ||
| 102 | | EnvironmentRequestInvalidError | ||
| 103 | | EnvironmentAuthInvalidError | ||
| 104 | | EnvironmentScopeRequiredError | ||
| 105 | | EnvironmentOperationForbiddenError | ||
| 106 | | EnvironmentInternalError | ||
| 107 | | RemoteEnvironmentAuthFetchError | ||
| 108 | | RemoteEnvironmentAuthInvalidJsonError | ||
| 109 | | RemoteEnvironmentAuthUndeclaredStatusError | ||
| 110 | | RemoteEnvironmentAuthTimeoutError; | ||
| 111 | |||
| 112 | export const remoteHttpClientLayer = ( | ||
| 113 | fetchFn: typeof globalThis.fetch, | ||
| 114 | ): Layer.Layer<HttpClient.HttpClient> => | ||
| 115 | Layer.merge( | ||
| 116 | FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), | ||
| 117 | httpHeaderRedactionLayer, | ||
| 118 | ); | ||
| 119 | |||
| 120 | const failRemoteRequest = ( | ||
| 121 | requestUrl: string, | ||
| 122 | cause: unknown, | ||
| 123 | ): Effect.Effect<never, RemoteEnvironmentAuthError> => { | ||
| 124 | if (cause instanceof RemoteEnvironmentAuthTimeoutError) { | ||
| 125 | return Effect.fail(cause); | ||
| 126 | } | ||
| 127 | if (isEnvironmentHttpCommonError(cause)) { | ||
| 128 | return Effect.fail(cause); | ||
| 129 | } | ||
| 130 | if (Schema.isSchemaError(cause)) { | ||
| 131 | return Effect.fail( | ||
| 132 | new RemoteEnvironmentAuthInvalidJsonError({ | ||
| 133 | message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, | ||
| 134 | cause, | ||
| 135 | }), | ||
| 136 | ); | ||
| 137 | } | ||
| 138 | if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { | ||
| 139 | const response = cause.response; | ||
| 140 | if (response.status < 200 || response.status >= 300) { | ||
| 141 | return Effect.fail( | ||
| 142 | new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), | ||
| 143 | ); | ||
| 144 | } | ||
| 145 | return Effect.fail( | ||
| 146 | new RemoteEnvironmentAuthInvalidJsonError({ | ||
| 147 | message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, | ||
| 148 | cause, | ||
| 149 | }), | ||
| 150 | ); | ||
| 151 | } | ||
| 152 | return Effect.fail( | ||
| 153 | new RemoteEnvironmentAuthFetchError({ | ||
| 154 | message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`, | ||
| 155 | cause, | ||
| 156 | }), | ||
| 157 | ); | ||
| 158 | }; | ||
| 159 | |||
| 160 | const executeRemoteRequest = <A, E, R>( | ||
| 161 | requestUrl: string, | ||
| 162 | timeoutMs: number, | ||
| 163 | request: Effect.Effect<A, E, R>, | ||
| 164 | ): Effect.Effect<A, RemoteEnvironmentAuthError, R> => | ||
| 165 | request.pipe( | ||
| 166 | Effect.timeoutOption(Duration.millis(timeoutMs)), | ||
| 167 | Effect.flatMap( | ||
| 168 | Option.match({ | ||
| 169 | onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)), | ||
| 170 | onSome: Effect.succeed, | ||
| 171 | }), | ||
| 172 | ), | ||
| 173 | Effect.catch((cause) => failRemoteRequest(requestUrl, cause)), | ||
| 174 | ); | ||
| 175 | |||
| 176 | export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => | ||
| 177 | HttpApiClient.make(EnvironmentHttpApi, { | ||
| 178 | baseUrl: remoteApiBaseUrl(httpBaseUrl), | ||
| 179 | }); | ||
| 180 | |||
| 181 | export const exchangeRemoteDpopAccessToken = Effect.fn( | ||
| 182 | "clientRuntime.remote.exchangeRemoteDpopAccessToken", | ||
| 183 | )(function* (input: { | ||
| 184 | readonly httpBaseUrl: string; | ||
| 185 | readonly credential: string; | ||
| 186 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 187 | readonly clientMetadata?: AuthClientPresentationMetadata; | ||
| 188 | readonly dpopProof: string; | ||
| 189 | readonly timeoutMs?: number; | ||
| 190 | }) { | ||
| 191 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 192 | const response = yield* executeRemoteRequest( | ||
| 193 | remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), | ||
| 194 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 195 | client.auth.token({ | ||
| 196 | headers: { dpop: input.dpopProof }, | ||
| 197 | payload: { | ||
| 198 | grant_type: AuthTokenExchangeGrantType, | ||
| 199 | subject_token: input.credential, | ||
| 200 | subject_token_type: AuthEnvironmentBootstrapTokenType, | ||
| 201 | requested_token_type: AuthAccessTokenType, | ||
| 202 | ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), | ||
| 203 | ...clientMetadataTokenExchangeFields(input.clientMetadata), | ||
| 204 | }, | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | return response; | ||
| 208 | }); | ||
| 209 | |||
| 210 | export const bootstrapRemoteBearerSession = Effect.fn( | ||
| 211 | "clientRuntime.remote.bootstrapRemoteBearerSession", | ||
| 212 | )(function* (input: { | ||
| 213 | readonly httpBaseUrl: string; | ||
| 214 | readonly credential: string; | ||
| 215 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 216 | readonly clientMetadata?: AuthClientPresentationMetadata; | ||
| 217 | readonly timeoutMs?: number; | ||
| 218 | }) { | ||
| 219 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 220 | return yield* executeRemoteRequest( | ||
| 221 | remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), | ||
| 222 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 223 | client.auth.token({ | ||
| 224 | headers: {}, | ||
| 225 | payload: { | ||
| 226 | grant_type: AuthTokenExchangeGrantType, | ||
| 227 | subject_token: input.credential, | ||
| 228 | subject_token_type: AuthEnvironmentBootstrapTokenType, | ||
| 229 | requested_token_type: AuthAccessTokenType, | ||
| 230 | ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), | ||
| 231 | ...clientMetadataTokenExchangeFields(input.clientMetadata), | ||
| 232 | }, | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | }); | ||
| 236 | |||
| 237 | export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")( | ||
| 238 | function* (input: { | ||
| 239 | readonly httpBaseUrl: string; | ||
| 240 | readonly bearerToken: string; | ||
| 241 | readonly timeoutMs?: number; | ||
| 242 | }) { | ||
| 243 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 244 | return yield* executeRemoteRequest( | ||
| 245 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), | ||
| 246 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 247 | client.auth.session({ | ||
| 248 | headers: { | ||
| 249 | authorization: `Bearer ${input.bearerToken}`, | ||
| 250 | }, | ||
| 251 | }), | ||
| 252 | ); | ||
| 253 | }, | ||
| 254 | ); | ||
| 255 | |||
| 256 | export const fetchRemoteDpopSessionState = Effect.fn( | ||
| 257 | "clientRuntime.remote.fetchRemoteDpopSessionState", | ||
| 258 | )(function* (input: { | ||
| 259 | readonly httpBaseUrl: string; | ||
| 260 | readonly accessToken: string; | ||
| 261 | readonly dpopProof: string; | ||
| 262 | readonly timeoutMs?: number; | ||
| 263 | }) { | ||
| 264 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 265 | return yield* executeRemoteRequest( | ||
| 266 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), | ||
| 267 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 268 | client.auth.session({ | ||
| 269 | headers: { | ||
| 270 | authorization: `DPoP ${input.accessToken}`, | ||
| 271 | dpop: input.dpopProof, | ||
| 272 | }, | ||
| 273 | }), | ||
| 274 | ); | ||
| 275 | }); | ||
| 276 | |||
| 277 | export const fetchRemoteEnvironmentDescriptor = Effect.fn( | ||
| 278 | "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", | ||
| 279 | )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { | ||
| 280 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 281 | return yield* executeRemoteRequest( | ||
| 282 | remoteEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), | ||
| 283 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 284 | client.metadata.descriptor(), | ||
| 285 | ); | ||
| 286 | }); | ||
| 287 | |||
| 288 | export const issueRemoteWebSocketTicket = Effect.fn( | ||
| 289 | "clientRuntime.remote.issueRemoteWebSocketTicket", | ||
| 290 | )(function* (input: { | ||
| 291 | readonly httpBaseUrl: string; | ||
| 292 | readonly bearerToken: string; | ||
| 293 | readonly timeoutMs?: number; | ||
| 294 | }) { | ||
| 295 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 296 | return yield* executeRemoteRequest( | ||
| 297 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), | ||
| 298 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 299 | client.auth.webSocketTicket({ | ||
| 300 | headers: { | ||
| 301 | authorization: `Bearer ${input.bearerToken}`, | ||
| 302 | }, | ||
| 303 | }), | ||
| 304 | ); | ||
| 305 | }); | ||
| 306 | |||
| 307 | export const issueRemoteDpopWebSocketTicket = Effect.fn( | ||
| 308 | "clientRuntime.remote.issueRemoteDpopWebSocketTicket", | ||
| 309 | )(function* (input: { | ||
| 310 | readonly httpBaseUrl: string; | ||
| 311 | readonly accessToken: string; | ||
| 312 | readonly dpopProof: string; | ||
| 313 | readonly timeoutMs?: number; | ||
| 314 | }) { | ||
| 315 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 316 | return yield* executeRemoteRequest( | ||
| 317 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), | ||
| 318 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 319 | client.auth.webSocketTicket({ | ||
| 320 | headers: { | ||
| 321 | authorization: `DPoP ${input.accessToken}`, | ||
| 322 | dpop: input.dpopProof, | ||
| 323 | }, | ||
| 324 | }), | ||
| 325 | ); | ||
| 326 | }); | ||
| 327 | |||
| 328 | export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ||
| 329 | "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", | ||
| 330 | )(function* (input: { | ||
| 331 | readonly wsBaseUrl: string; | ||
| 332 | readonly httpBaseUrl: string; | ||
| 333 | readonly bearerToken: string; | ||
| 334 | readonly timeoutMs?: number; | ||
| 335 | }) { | ||
| 336 | const issued = yield* issueRemoteWebSocketTicket({ | ||
| 337 | httpBaseUrl: input.httpBaseUrl, | ||
| 338 | bearerToken: input.bearerToken, | ||
| 339 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 340 | }); | ||
| 341 | |||
| 342 | const url = new URL(input.wsBaseUrl); | ||
| 343 | if (url.pathname === "" || url.pathname === "/") { | ||
| 344 | url.pathname = "/ws"; | ||
| 345 | } | ||
| 346 | url.searchParams.set("wsTicket", issued.ticket); | ||
| 347 | return url.toString(); | ||
| 348 | }); | ||
| 349 | |||
| 350 | export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( | ||
| 351 | "clientRuntime.remote.resolveRemoteDpopWebSocketConnectionUrl", | ||
| 352 | )(function* (input: { | ||
| 353 | readonly wsBaseUrl: string; | ||
| 354 | readonly httpBaseUrl: string; | ||
| 355 | readonly accessToken: string; | ||
| 356 | readonly dpopProof: string; | ||
| 357 | readonly timeoutMs?: number; | ||
| 358 | }) { | ||
| 359 | const issued = yield* issueRemoteDpopWebSocketTicket({ | ||
| 360 | httpBaseUrl: input.httpBaseUrl, | ||
| 361 | accessToken: input.accessToken, | ||
| 362 | dpopProof: input.dpopProof, | ||
| 363 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 364 | }); | ||
| 365 | const url = new URL(input.wsBaseUrl); | ||
| 366 | if (url.pathname === "" || url.pathname === "/") { | ||
| 367 | url.pathname = "/ws"; | ||
| 368 | } | ||
| 369 | url.searchParams.set("wsTicket", issued.ticket); | ||
| 370 | return url.toString(); | ||
| 371 | }); | ||
packages/client-runtime/src/rpc/client.test.ts created+391| ... | @@ -0,0 +1,391 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | type RelayClientInstallProgressEvent, | ||
| 4 | WS_METHODS, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Cause from "effect/Cause"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Exit from "effect/Exit"; | ||
| 10 | import * as Fiber from "effect/Fiber"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Queue from "effect/Queue"; | ||
| 13 | import * as Ref from "effect/Ref"; | ||
| 14 | import * as Stream from "effect/Stream"; | ||
| 15 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 16 | import * as TestClock from "effect/testing/TestClock"; | ||
| 17 | import { RpcClientError } from "effect/unstable/rpc"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | AVAILABLE_CONNECTION_STATE, | ||
| 21 | PrimaryConnectionTarget, | ||
| 22 | type PreparedConnection, | ||
| 23 | type SupervisorConnectionState, | ||
| 24 | } from "../connection/model.ts"; | ||
| 25 | import { | ||
| 26 | EnvironmentSupervisor, | ||
| 27 | type EnvironmentSupervisorService, | ||
| 28 | } from "../connection/supervisor.ts"; | ||
| 29 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 30 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | ||
| 31 | import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; | ||
| 32 | |||
| 33 | const TARGET = new PrimaryConnectionTarget({ | ||
| 34 | environmentId: EnvironmentId.make("environment-1"), | ||
| 35 | label: "Test environment", | ||
| 36 | httpBaseUrl: "https://environment.example.test", | ||
| 37 | wsBaseUrl: "wss://environment.example.test", | ||
| 38 | }); | ||
| 39 | |||
| 40 | const INSTALL_CHECKING: RelayClientInstallProgressEvent = { | ||
| 41 | type: "progress", | ||
| 42 | stage: "checking", | ||
| 43 | }; | ||
| 44 | const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = { | ||
| 45 | type: "progress", | ||
| 46 | stage: "downloading", | ||
| 47 | }; | ||
| 48 | |||
| 49 | function session(client: WsRpcProtocolClient): RpcSession { | ||
| 50 | return { | ||
| 51 | client, | ||
| 52 | initialConfig: Effect.never, | ||
| 53 | ready: Effect.void, | ||
| 54 | probe: Effect.void, | ||
| 55 | closed: Effect.never, | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | |||
| 59 | const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { | ||
| 60 | const state = yield* SubscriptionRef.make<SupervisorConnectionState>(AVAILABLE_CONNECTION_STATE); | ||
| 61 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession>>(Option.none()); | ||
| 62 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none()); | ||
| 63 | const retryCount = yield* Ref.make(0); | ||
| 64 | const supervisor = EnvironmentSupervisor.of({ | ||
| 65 | target: TARGET, | ||
| 66 | state, | ||
| 67 | session: activeSession, | ||
| 68 | prepared, | ||
| 69 | connect: Effect.void, | ||
| 70 | disconnect: Effect.void, | ||
| 71 | retryNow: Ref.update(retryCount, (count) => count + 1), | ||
| 72 | } satisfies EnvironmentSupervisorService); | ||
| 73 | return { | ||
| 74 | activeSession, | ||
| 75 | retryCount, | ||
| 76 | supervisor, | ||
| 77 | }; | ||
| 78 | }); | ||
| 79 | |||
| 80 | describe("environment RPC", () => { | ||
| 81 | it.effect("observes unary requests until they complete", () => | ||
| 82 | Effect.gen(function* () { | ||
| 83 | const observations: string[] = []; | ||
| 84 | const client = { | ||
| 85 | [WS_METHODS.cloudGetRelayClientStatus]: () => | ||
| 86 | Effect.succeed({ status: "available", version: "2026.6.0" }), | ||
| 87 | } as unknown as WsRpcProtocolClient; | ||
| 88 | const { activeSession, supervisor } = yield* makeHarness(); | ||
| 89 | yield* SubscriptionRef.set(activeSession, Option.some(session(client))); | ||
| 90 | |||
| 91 | const result = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe( | ||
| 92 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 93 | Effect.provideService( | ||
| 94 | EnvironmentRpcRequestObserver, | ||
| 95 | EnvironmentRpcRequestObserver.of({ | ||
| 96 | observe: ({ environmentId, method }) => | ||
| 97 | Effect.sync(() => { | ||
| 98 | observations.push(`start:${environmentId}:${method}`); | ||
| 99 | return Effect.sync(() => { | ||
| 100 | observations.push(`finish:${environmentId}:${method}`); | ||
| 101 | }); | ||
| 102 | }), | ||
| 103 | }), | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | |||
| 107 | expect(result).toEqual({ status: "available", version: "2026.6.0" }); | ||
| 108 | expect(observations).toEqual([ | ||
| 109 | `start:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, | ||
| 110 | `finish:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, | ||
| 111 | ]); | ||
| 112 | }), | ||
| 113 | ); | ||
| 114 | |||
| 115 | it.effect("binds finite streaming commands to one active session", () => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const firstEvents = yield* Queue.unbounded<RelayClientInstallProgressEvent>(); | ||
| 118 | const secondEvents = yield* Queue.unbounded<RelayClientInstallProgressEvent>(); | ||
| 119 | const firstClient = { | ||
| 120 | [WS_METHODS.cloudInstallRelayClient]: () => Stream.fromQueue(firstEvents), | ||
| 121 | } as unknown as WsRpcProtocolClient; | ||
| 122 | const secondClient = { | ||
| 123 | [WS_METHODS.cloudInstallRelayClient]: () => Stream.fromQueue(secondEvents), | ||
| 124 | } as unknown as WsRpcProtocolClient; | ||
| 125 | const { activeSession, supervisor } = yield* makeHarness(); | ||
| 126 | |||
| 127 | yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); | ||
| 128 | const resultFiber = yield* runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( | ||
| 129 | Stream.take(2), | ||
| 130 | Stream.runCollect, | ||
| 131 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 132 | Effect.forkChild, | ||
| 133 | ); | ||
| 134 | yield* Effect.yieldNow; | ||
| 135 | |||
| 136 | yield* Queue.offer(firstEvents, INSTALL_CHECKING); | ||
| 137 | yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); | ||
| 138 | yield* Queue.offer(secondEvents, INSTALL_DOWNLOADING); | ||
| 139 | yield* Queue.offer(firstEvents, INSTALL_DOWNLOADING); | ||
| 140 | |||
| 141 | expect(yield* Fiber.join(resultFiber)).toEqual([INSTALL_CHECKING, INSTALL_DOWNLOADING]); | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | |||
| 145 | it.effect("switches durable subscriptions when the supervisor replaces the session", () => | ||
| 146 | Effect.gen(function* () { | ||
| 147 | const subscriptions: string[] = []; | ||
| 148 | const firstClient = { | ||
| 149 | [WS_METHODS.subscribeTerminalEvents]: () => { | ||
| 150 | subscriptions.push("first"); | ||
| 151 | return Stream.never; | ||
| 152 | }, | ||
| 153 | } as unknown as WsRpcProtocolClient; | ||
| 154 | const secondClient = { | ||
| 155 | [WS_METHODS.subscribeTerminalEvents]: () => { | ||
| 156 | subscriptions.push("second"); | ||
| 157 | return Stream.never; | ||
| 158 | }, | ||
| 159 | } as unknown as WsRpcProtocolClient; | ||
| 160 | const { activeSession, retryCount, supervisor } = yield* makeHarness(); | ||
| 161 | const awaitSubscriptions = Effect.fn("TestEnvironmentRpc.awaitSubscriptions")(function* ( | ||
| 162 | count: number, | ||
| 163 | ) { | ||
| 164 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 165 | if (subscriptions.length >= count) { | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | yield* Effect.yieldNow; | ||
| 169 | } | ||
| 170 | return yield* Effect.die(new Error(`Expected ${count} durable subscriptions.`)); | ||
| 171 | }); | ||
| 172 | |||
| 173 | const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( | ||
| 174 | Stream.runDrain, | ||
| 175 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 176 | Effect.forkChild, | ||
| 177 | ); | ||
| 178 | yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); | ||
| 179 | yield* awaitSubscriptions(1); | ||
| 180 | yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); | ||
| 181 | yield* awaitSubscriptions(2); | ||
| 182 | yield* Fiber.interrupt(subscriptionFiber); | ||
| 183 | |||
| 184 | expect(subscriptions).toEqual(["first", "second"]); | ||
| 185 | expect(yield* Ref.get(retryCount)).toBe(0); | ||
| 186 | }), | ||
| 187 | ); | ||
| 188 | |||
| 189 | it.effect("keeps durable subscriptions alive across a transport failure and new session", () => | ||
| 190 | Effect.gen(function* () { | ||
| 191 | const subscriptions: string[] = []; | ||
| 192 | const firstClient = { | ||
| 193 | [WS_METHODS.subscribeTerminalEvents]: () => { | ||
| 194 | subscriptions.push("first"); | ||
| 195 | return Stream.fail( | ||
| 196 | new RpcClientError.RpcClientError({ | ||
| 197 | reason: new RpcClientError.RpcClientDefect({ | ||
| 198 | message: "socket closed", | ||
| 199 | cause: new Error("socket closed"), | ||
| 200 | }), | ||
| 201 | }), | ||
| 202 | ); | ||
| 203 | }, | ||
| 204 | } as unknown as WsRpcProtocolClient; | ||
| 205 | const secondClient = { | ||
| 206 | [WS_METHODS.subscribeTerminalEvents]: () => { | ||
| 207 | subscriptions.push("second"); | ||
| 208 | return Stream.never; | ||
| 209 | }, | ||
| 210 | } as unknown as WsRpcProtocolClient; | ||
| 211 | const { activeSession, retryCount, supervisor } = yield* makeHarness(); | ||
| 212 | |||
| 213 | const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( | ||
| 214 | Stream.runDrain, | ||
| 215 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 216 | Effect.forkChild, | ||
| 217 | ); | ||
| 218 | yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); | ||
| 219 | for (let attempt = 0; attempt < 100 && subscriptions.length < 1; attempt += 1) { | ||
| 220 | yield* Effect.yieldNow; | ||
| 221 | } | ||
| 222 | yield* SubscriptionRef.set(activeSession, Option.none()); | ||
| 223 | yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); | ||
| 224 | |||
| 225 | for (let attempt = 0; attempt < 100 && subscriptions.length < 2; attempt += 1) { | ||
| 226 | yield* Effect.yieldNow; | ||
| 227 | } | ||
| 228 | yield* Fiber.interrupt(subscriptionFiber); | ||
| 229 | |||
| 230 | expect(subscriptions).toEqual(["first", "second"]); | ||
| 231 | expect(yield* Ref.get(retryCount)).toBe(0); | ||
| 232 | }), | ||
| 233 | ); | ||
| 234 | |||
| 235 | it.effect("surfaces domain subscription failures without reconnecting", () => | ||
| 236 | Effect.gen(function* () { | ||
| 237 | const domainError = new Error("terminal subscription rejected"); | ||
| 238 | const client = { | ||
| 239 | [WS_METHODS.subscribeTerminalEvents]: () => Stream.fail(domainError), | ||
| 240 | } as unknown as WsRpcProtocolClient; | ||
| 241 | const { activeSession, retryCount, supervisor } = yield* makeHarness(); | ||
| 242 | |||
| 243 | yield* SubscriptionRef.set(activeSession, Option.some(session(client))); | ||
| 244 | const error = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( | ||
| 245 | Stream.runDrain, | ||
| 246 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 247 | Effect.flip, | ||
| 248 | ); | ||
| 249 | |||
| 250 | expect(error).toBe(domainError); | ||
| 251 | expect(yield* Ref.get(retryCount)).toBe(0); | ||
| 252 | }), | ||
| 253 | ); | ||
| 254 | |||
| 255 | it.effect("keeps handled domain failures dormant until a replacement session arrives", () => | ||
| 256 | Effect.gen(function* () { | ||
| 257 | const domainError = new Error("terminal subscription rejected"); | ||
| 258 | const subscriptions: string[] = []; | ||
| 259 | const observedFailures: Error[] = []; | ||
| 260 | const firstClient = { | ||
| 261 | [WS_METHODS.subscribeTerminalEvents]: () => { | ||
| 262 | subscriptions.push("first"); | ||
| 263 | return Stream.fail(domainError); | ||
| 264 | }, | ||
| 265 | } as unknown as WsRpcProtocolClient; | ||
| 266 | const secondClient = { | ||
| 267 | [WS_METHODS.subscribeTerminalEvents]: () => { | ||
| 268 | subscriptions.push("second"); | ||
| 269 | return Stream.never; | ||
| 270 | }, | ||
| 271 | } as unknown as WsRpcProtocolClient; | ||
| 272 | const { activeSession, retryCount, supervisor } = yield* makeHarness(); | ||
| 273 | |||
| 274 | yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); | ||
| 275 | const subscriptionFiber = yield* subscribe( | ||
| 276 | WS_METHODS.subscribeTerminalEvents, | ||
| 277 | {}, | ||
| 278 | { | ||
| 279 | onExpectedFailure: (cause) => | ||
| 280 | Effect.sync(() => { | ||
| 281 | observedFailures.push(Cause.squash(cause) as Error); | ||
| 282 | }), | ||
| 283 | }, | ||
| 284 | ).pipe( | ||
| 285 | Stream.runDrain, | ||
| 286 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 287 | Effect.forkChild, | ||
| 288 | ); | ||
| 289 | for (let attempt = 0; attempt < 100 && observedFailures.length < 1; attempt += 1) { | ||
| 290 | yield* Effect.yieldNow; | ||
| 291 | } | ||
| 292 | |||
| 293 | expect(subscriptions).toEqual(["first"]); | ||
| 294 | expect(observedFailures).toEqual([domainError]); | ||
| 295 | |||
| 296 | yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); | ||
| 297 | for (let attempt = 0; attempt < 100 && subscriptions.length < 2; attempt += 1) { | ||
| 298 | yield* Effect.yieldNow; | ||
| 299 | } | ||
| 300 | yield* Fiber.interrupt(subscriptionFiber); | ||
| 301 | |||
| 302 | expect(subscriptions).toEqual(["first", "second"]); | ||
| 303 | expect(yield* Ref.get(retryCount)).toBe(0); | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | |||
| 307 | it.effect("retries handled domain failures within the same session when configured", () => | ||
| 308 | Effect.gen(function* () { | ||
| 309 | const domainError = new Error("thread not found yet"); | ||
| 310 | const subscriptionCount = yield* Ref.make(0); | ||
| 311 | const expectedFailureCount = yield* Ref.make(0); | ||
| 312 | const client = { | ||
| 313 | [WS_METHODS.subscribeTerminalEvents]: () => | ||
| 314 | Stream.unwrap( | ||
| 315 | Ref.getAndUpdate(subscriptionCount, (count) => count + 1).pipe( | ||
| 316 | Effect.map((count) => (count === 0 ? Stream.fail(domainError) : Stream.never)), | ||
| 317 | ), | ||
| 318 | ), | ||
| 319 | } as unknown as WsRpcProtocolClient; | ||
| 320 | const { activeSession, supervisor } = yield* makeHarness(); | ||
| 321 | |||
| 322 | yield* SubscriptionRef.set(activeSession, Option.some(session(client))); | ||
| 323 | const subscriptionFiber = yield* subscribe( | ||
| 324 | WS_METHODS.subscribeTerminalEvents, | ||
| 325 | {}, | ||
| 326 | { | ||
| 327 | onExpectedFailure: () => Ref.update(expectedFailureCount, (count) => count + 1), | ||
| 328 | retryExpectedFailureAfter: "100 millis", | ||
| 329 | }, | ||
| 330 | ).pipe( | ||
| 331 | Stream.runDrain, | ||
| 332 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 333 | Effect.forkChild, | ||
| 334 | ); | ||
| 335 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 336 | if ((yield* Ref.get(expectedFailureCount)) >= 1) { | ||
| 337 | break; | ||
| 338 | } | ||
| 339 | yield* Effect.yieldNow; | ||
| 340 | } | ||
| 341 | |||
| 342 | expect(yield* Ref.get(subscriptionCount)).toBe(1); | ||
| 343 | expect(yield* Ref.get(expectedFailureCount)).toBe(1); | ||
| 344 | |||
| 345 | yield* TestClock.adjust("100 millis"); | ||
| 346 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 347 | if ((yield* Ref.get(subscriptionCount)) >= 2) { | ||
| 348 | break; | ||
| 349 | } | ||
| 350 | yield* Effect.yieldNow; | ||
| 351 | } | ||
| 352 | yield* Fiber.interrupt(subscriptionFiber); | ||
| 353 | |||
| 354 | expect(yield* Ref.get(subscriptionCount)).toBe(2); | ||
| 355 | expect(yield* Ref.get(expectedFailureCount)).toBe(1); | ||
| 356 | }), | ||
| 357 | ); | ||
| 358 | |||
| 359 | it.effect("does not classify subscription defects as expected failures", () => | ||
| 360 | Effect.gen(function* () { | ||
| 361 | const defect = new Error("subscription invariant failed"); | ||
| 362 | let expectedFailureCount = 0; | ||
| 363 | const client = { | ||
| 364 | [WS_METHODS.subscribeTerminalEvents]: () => Stream.die(defect), | ||
| 365 | } as unknown as WsRpcProtocolClient; | ||
| 366 | const { activeSession, supervisor } = yield* makeHarness(); | ||
| 367 | |||
| 368 | yield* SubscriptionRef.set(activeSession, Option.some(session(client))); | ||
| 369 | const exit = yield* subscribe( | ||
| 370 | WS_METHODS.subscribeTerminalEvents, | ||
| 371 | {}, | ||
| 372 | { | ||
| 373 | onExpectedFailure: () => | ||
| 374 | Effect.sync(() => { | ||
| 375 | expectedFailureCount += 1; | ||
| 376 | }), | ||
| 377 | }, | ||
| 378 | ).pipe( | ||
| 379 | Stream.runDrain, | ||
| 380 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 381 | Effect.exit, | ||
| 382 | ); | ||
| 383 | |||
| 384 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 385 | if (Exit.isFailure(exit)) { | ||
| 386 | expect(Cause.hasDies(exit.cause)).toBe(true); | ||
| 387 | } | ||
| 388 | expect(expectedFailureCount).toBe(0); | ||
| 389 | }), | ||
| 390 | ); | ||
| 391 | }); | ||
packages/client-runtime/src/rpc/client.ts created+247| ... | @@ -0,0 +1,247 @@ | ||
| 1 | import { ORCHESTRATION_WS_METHODS, type ServerConfig, WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import type * as Duration from "effect/Duration"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 10 | import { RpcClientError } from "effect/unstable/rpc"; | ||
| 11 | |||
| 12 | import type { ConnectionAttemptError } from "../connection/model.ts"; | ||
| 13 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | ||
| 14 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | ||
| 15 | |||
| 16 | export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass<EnvironmentRpcUnavailableError>()( | ||
| 17 | "EnvironmentRpcUnavailableError", | ||
| 18 | { | ||
| 19 | environmentId: Schema.String, | ||
| 20 | message: Schema.String, | ||
| 21 | }, | ||
| 22 | ) {} | ||
| 23 | |||
| 24 | export interface EnvironmentRpcRequestObservation { | ||
| 25 | readonly environmentId: string; | ||
| 26 | readonly method: string; | ||
| 27 | } | ||
| 28 | |||
| 29 | export class EnvironmentRpcRequestObserver extends Context.Reference<{ | ||
| 30 | readonly observe: ( | ||
| 31 | request: EnvironmentRpcRequestObservation, | ||
| 32 | ) => Effect.Effect<Effect.Effect<void>>; | ||
| 33 | }>("@t3tools/client-runtime/rpc/EnvironmentRpcRequestObserver", { | ||
| 34 | defaultValue: () => ({ | ||
| 35 | observe: () => Effect.succeed(Effect.void), | ||
| 36 | }), | ||
| 37 | }) {} | ||
| 38 | |||
| 39 | export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; | ||
| 40 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; | ||
| 41 | |||
| 42 | export type EnvironmentSubscriptionRpcTag = | ||
| 43 | | typeof ORCHESTRATION_WS_METHODS.subscribeShell | ||
| 44 | | typeof ORCHESTRATION_WS_METHODS.subscribeThread | ||
| 45 | | typeof WS_METHODS.subscribeAuthAccess | ||
| 46 | | typeof WS_METHODS.subscribeServerConfig | ||
| 47 | | typeof WS_METHODS.subscribeServerLifecycle | ||
| 48 | | typeof WS_METHODS.subscribeTerminalEvents | ||
| 49 | | typeof WS_METHODS.subscribeTerminalMetadata | ||
| 50 | | typeof WS_METHODS.subscribePreviewEvents | ||
| 51 | | typeof WS_METHODS.subscribeDiscoveredLocalServers | ||
| 52 | | typeof WS_METHODS.previewAutomationConnect | ||
| 53 | | typeof WS_METHODS.subscribeVcsStatus | ||
| 54 | | typeof WS_METHODS.terminalAttach; | ||
| 55 | |||
| 56 | export type EnvironmentStreamCommandRpcTag = | ||
| 57 | | typeof WS_METHODS.cloudInstallRelayClient | ||
| 58 | | typeof WS_METHODS.gitRunStackedAction; | ||
| 59 | |||
| 60 | export type EnvironmentStreamRpcTag = | ||
| 61 | | EnvironmentSubscriptionRpcTag | ||
| 62 | | EnvironmentStreamCommandRpcTag; | ||
| 63 | |||
| 64 | export type EnvironmentUnaryRpcTag = Exclude<EnvironmentRpcTag, EnvironmentStreamRpcTag>; | ||
| 65 | const isRpcClientError = Schema.is(RpcClientError.RpcClientError); | ||
| 66 | |||
| 67 | export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0]; | ||
| 68 | |||
| 69 | export type EnvironmentRpcSuccess<TTag extends EnvironmentUnaryRpcTag> = | ||
| 70 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer A, any, any> | ||
| 71 | ? A | ||
| 72 | : never; | ||
| 73 | |||
| 74 | export type EnvironmentRpcFailure<TTag extends EnvironmentUnaryRpcTag> = | ||
| 75 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<any, infer E, any> | ||
| 76 | ? E | ||
| 77 | : never; | ||
| 78 | |||
| 79 | export type EnvironmentRpcStreamValue<TTag extends EnvironmentStreamRpcTag> = | ||
| 80 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer A, any, any> | ||
| 81 | ? A | ||
| 82 | : never; | ||
| 83 | |||
| 84 | export type EnvironmentRpcStreamFailure<TTag extends EnvironmentStreamRpcTag> = | ||
| 85 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<any, infer E, any> | ||
| 86 | ? E | ||
| 87 | : never; | ||
| 88 | |||
| 89 | const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { | ||
| 90 | const supervisor = yield* EnvironmentSupervisor; | ||
| 91 | return yield* SubscriptionRef.get(supervisor.session).pipe( | ||
| 92 | Effect.flatMap( | ||
| 93 | Option.match({ | ||
| 94 | onNone: () => | ||
| 95 | Effect.fail( | ||
| 96 | new EnvironmentRpcUnavailableError({ | ||
| 97 | environmentId: supervisor.target.environmentId, | ||
| 98 | message: `${supervisor.target.label} is not connected.`, | ||
| 99 | }), | ||
| 100 | ), | ||
| 101 | onSome: Effect.succeed, | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | ); | ||
| 105 | }); | ||
| 106 | |||
| 107 | export const request = Effect.fn("EnvironmentRpc.request")(function* < | ||
| 108 | TTag extends EnvironmentUnaryRpcTag, | ||
| 109 | >(tag: TTag, input: EnvironmentRpcInput<TTag>) { | ||
| 110 | const supervisor = yield* EnvironmentSupervisor; | ||
| 111 | yield* Effect.annotateCurrentSpan({ | ||
| 112 | "environment.id": supervisor.target.environmentId, | ||
| 113 | "rpc.method": tag, | ||
| 114 | }); | ||
| 115 | const session = yield* currentSession(); | ||
| 116 | const observer = yield* EnvironmentRpcRequestObserver; | ||
| 117 | const method = session.client[tag] as ( | ||
| 118 | input: EnvironmentRpcInput<TTag>, | ||
| 119 | ) => Effect.Effect<EnvironmentRpcSuccess<TTag>, EnvironmentRpcFailure<TTag>>; | ||
| 120 | const completeObservation = yield* observer.observe({ | ||
| 121 | environmentId: supervisor.target.environmentId, | ||
| 122 | method: tag, | ||
| 123 | }); | ||
| 124 | return yield* method(input).pipe(Effect.ensuring(completeObservation)); | ||
| 125 | }); | ||
| 126 | |||
| 127 | export function runStream<TTag extends EnvironmentStreamCommandRpcTag>( | ||
| 128 | tag: TTag, | ||
| 129 | input: EnvironmentRpcInput<TTag>, | ||
| 130 | ): Stream.Stream< | ||
| 131 | EnvironmentRpcStreamValue<TTag>, | ||
| 132 | EnvironmentRpcStreamFailure<TTag> | EnvironmentRpcUnavailableError, | ||
| 133 | EnvironmentSupervisor | ||
| 134 | > { | ||
| 135 | return Stream.unwrap( | ||
| 136 | currentSession().pipe( | ||
| 137 | Effect.map((session) => { | ||
| 138 | const method = session.client[tag] as ( | ||
| 139 | input: EnvironmentRpcInput<TTag>, | ||
| 140 | ) => Stream.Stream<EnvironmentRpcStreamValue<TTag>, EnvironmentRpcStreamFailure<TTag>>; | ||
| 141 | return method(input); | ||
| 142 | }), | ||
| 143 | ), | ||
| 144 | ).pipe( | ||
| 145 | Stream.withSpan("EnvironmentRpc.runStream", { | ||
| 146 | attributes: { "rpc.method": tag }, | ||
| 147 | }), | ||
| 148 | ); | ||
| 149 | } | ||
| 150 | |||
| 151 | export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>( | ||
| 152 | tag: TTag, | ||
| 153 | input: EnvironmentRpcInput<TTag>, | ||
| 154 | options?: { | ||
| 155 | readonly onExpectedFailure?: ( | ||
| 156 | cause: Cause.Cause<EnvironmentRpcStreamFailure<TTag>>, | ||
| 157 | ) => Effect.Effect<void, never, never>; | ||
| 158 | readonly retryExpectedFailureAfter?: Duration.Input; | ||
| 159 | }, | ||
| 160 | ): Stream.Stream< | ||
| 161 | EnvironmentRpcStreamValue<TTag>, | ||
| 162 | EnvironmentRpcStreamFailure<TTag>, | ||
| 163 | EnvironmentSupervisor | ||
| 164 | > { | ||
| 165 | return Stream.unwrap( | ||
| 166 | EnvironmentSupervisor.pipe( | ||
| 167 | Effect.map((supervisor) => | ||
| 168 | SubscriptionRef.changes(supervisor.session).pipe( | ||
| 169 | Stream.switchMap( | ||
| 170 | Option.match({ | ||
| 171 | onNone: () => Stream.empty, | ||
| 172 | onSome: (session) => { | ||
| 173 | const method = session.client[tag] as ( | ||
| 174 | input: EnvironmentRpcInput<TTag>, | ||
| 175 | ) => Stream.Stream< | ||
| 176 | EnvironmentRpcStreamValue<TTag>, | ||
| 177 | EnvironmentRpcStreamFailure<TTag> | ||
| 178 | >; | ||
| 179 | const subscribeToSession = (): Stream.Stream< | ||
| 180 | EnvironmentRpcStreamValue<TTag>, | ||
| 181 | EnvironmentRpcStreamFailure<TTag> | ||
| 182 | > => | ||
| 183 | Stream.suspend(() => | ||
| 184 | method(input).pipe( | ||
| 185 | Stream.catchCause((cause) => { | ||
| 186 | const hasOnlyExpectedFailures = | ||
| 187 | cause.reasons.length > 0 && | ||
| 188 | cause.reasons.every((reason) => reason._tag === "Fail"); | ||
| 189 | const isTransportFailure = | ||
| 190 | hasOnlyExpectedFailures && | ||
| 191 | cause.reasons.every( | ||
| 192 | (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), | ||
| 193 | ); | ||
| 194 | if (isTransportFailure) { | ||
| 195 | return Stream.fromEffect( | ||
| 196 | Effect.logWarning( | ||
| 197 | "Durable RPC subscription lost its transport; waiting for the next session.", | ||
| 198 | { | ||
| 199 | cause: Cause.pretty(cause), | ||
| 200 | method: tag, | ||
| 201 | environmentId: supervisor.target.environmentId, | ||
| 202 | }, | ||
| 203 | ), | ||
| 204 | ).pipe(Stream.drain); | ||
| 205 | } | ||
| 206 | if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { | ||
| 207 | const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( | ||
| 208 | Stream.drain, | ||
| 209 | ); | ||
| 210 | if (options.retryExpectedFailureAfter === undefined) { | ||
| 211 | return handled; | ||
| 212 | } | ||
| 213 | return handled.pipe( | ||
| 214 | Stream.concat( | ||
| 215 | Stream.fromEffect( | ||
| 216 | Effect.sleep(options.retryExpectedFailureAfter), | ||
| 217 | ).pipe(Stream.drain), | ||
| 218 | ), | ||
| 219 | Stream.concat(subscribeToSession()), | ||
| 220 | ); | ||
| 221 | } | ||
| 222 | return Stream.failCause(cause); | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | ); | ||
| 226 | return subscribeToSession(); | ||
| 227 | }, | ||
| 228 | }), | ||
| 229 | ), | ||
| 230 | ), | ||
| 231 | ), | ||
| 232 | ), | ||
| 233 | ).pipe( | ||
| 234 | Stream.withSpan("EnvironmentRpc.subscribe", { | ||
| 235 | attributes: { "rpc.method": tag }, | ||
| 236 | }), | ||
| 237 | ); | ||
| 238 | } | ||
| 239 | |||
| 240 | export const config: Effect.Effect< | ||
| 241 | ServerConfig, | ||
| 242 | EnvironmentRpcUnavailableError | ConnectionAttemptError, | ||
| 243 | EnvironmentSupervisor | ||
| 244 | > = Effect.gen(function* () { | ||
| 245 | const session = yield* currentSession(); | ||
| 246 | return yield* session.initialConfig; | ||
| 247 | }).pipe(Effect.withSpan("EnvironmentRpc.config")); | ||
packages/client-runtime/src/rpc/http.ts created+154| ... | @@ -0,0 +1,154 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentHttpApi, | ||
| 3 | EnvironmentHttpCommonError, | ||
| 4 | type EnvironmentAuthInvalidError, | ||
| 5 | type EnvironmentInternalError, | ||
| 6 | type EnvironmentOperationForbiddenError, | ||
| 7 | type EnvironmentRequestInvalidError, | ||
| 8 | type EnvironmentScopeRequiredError, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | ||
| 11 | import * as Data from "effect/Data"; | ||
| 12 | import * as Duration from "effect/Duration"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; | ||
| 18 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | ||
| 19 | |||
| 20 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); | ||
| 21 | |||
| 22 | export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( | ||
| 23 | "RemoteEnvironmentAuthFetchError", | ||
| 24 | )<{ | ||
| 25 | readonly message: string; | ||
| 26 | readonly cause: unknown; | ||
| 27 | }> {} | ||
| 28 | |||
| 29 | export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( | ||
| 30 | "RemoteEnvironmentAuthInvalidJsonError", | ||
| 31 | )<{ | ||
| 32 | readonly message: string; | ||
| 33 | readonly cause: unknown; | ||
| 34 | }> {} | ||
| 35 | |||
| 36 | export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( | ||
| 37 | "RemoteEnvironmentAuthUndeclaredStatusError", | ||
| 38 | )<{ | ||
| 39 | readonly message: string; | ||
| 40 | readonly status: number; | ||
| 41 | readonly requestUrl: string; | ||
| 42 | }> { | ||
| 43 | constructor(requestUrl: string, status: number) { | ||
| 44 | super({ | ||
| 45 | message: `Remote environment endpoint ${requestUrl} returned undeclared status ${status}.`, | ||
| 46 | requestUrl, | ||
| 47 | status, | ||
| 48 | }); | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( | ||
| 53 | "RemoteEnvironmentAuthTimeoutError", | ||
| 54 | )<{ | ||
| 55 | readonly message: string; | ||
| 56 | readonly requestUrl: string; | ||
| 57 | readonly timeoutMs: number; | ||
| 58 | }> { | ||
| 59 | constructor(requestUrl: string, timeoutMs: number) { | ||
| 60 | super({ | ||
| 61 | message: `Remote environment endpoint ${requestUrl} timed out after ${timeoutMs}ms.`, | ||
| 62 | requestUrl, | ||
| 63 | timeoutMs, | ||
| 64 | }); | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | export type RemoteEnvironmentRequestError = | ||
| 69 | | EnvironmentRequestInvalidError | ||
| 70 | | EnvironmentAuthInvalidError | ||
| 71 | | EnvironmentScopeRequiredError | ||
| 72 | | EnvironmentOperationForbiddenError | ||
| 73 | | EnvironmentInternalError | ||
| 74 | | RemoteEnvironmentAuthFetchError | ||
| 75 | | RemoteEnvironmentAuthInvalidJsonError | ||
| 76 | | RemoteEnvironmentAuthUndeclaredStatusError | ||
| 77 | | RemoteEnvironmentAuthTimeoutError; | ||
| 78 | |||
| 79 | export const remoteHttpClientLayer = ( | ||
| 80 | fetchFn: typeof globalThis.fetch, | ||
| 81 | ): Layer.Layer<HttpClient.HttpClient> => | ||
| 82 | Layer.merge( | ||
| 83 | FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), | ||
| 84 | httpHeaderRedactionLayer, | ||
| 85 | ); | ||
| 86 | |||
| 87 | const remoteApiBaseUrl = (httpBaseUrl: string): string => { | ||
| 88 | const url = new URL(httpBaseUrl); | ||
| 89 | url.pathname = "/"; | ||
| 90 | url.search = ""; | ||
| 91 | url.hash = ""; | ||
| 92 | return url.toString(); | ||
| 93 | }; | ||
| 94 | |||
| 95 | export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => | ||
| 96 | HttpApiClient.make(EnvironmentHttpApi, { | ||
| 97 | baseUrl: remoteApiBaseUrl(httpBaseUrl), | ||
| 98 | }); | ||
| 99 | |||
| 100 | const failRemoteRequest = ( | ||
| 101 | requestUrl: string, | ||
| 102 | cause: unknown, | ||
| 103 | ): Effect.Effect<never, RemoteEnvironmentRequestError> => { | ||
| 104 | if (cause instanceof RemoteEnvironmentAuthTimeoutError) { | ||
| 105 | return Effect.fail(cause); | ||
| 106 | } | ||
| 107 | if (isEnvironmentHttpCommonError(cause)) { | ||
| 108 | return Effect.fail(cause); | ||
| 109 | } | ||
| 110 | if (Schema.isSchemaError(cause)) { | ||
| 111 | return Effect.fail( | ||
| 112 | new RemoteEnvironmentAuthInvalidJsonError({ | ||
| 113 | message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`, | ||
| 114 | cause, | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | } | ||
| 118 | if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { | ||
| 119 | const response = cause.response; | ||
| 120 | if (response.status < 200 || response.status >= 300) { | ||
| 121 | return Effect.fail( | ||
| 122 | new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | return Effect.fail( | ||
| 126 | new RemoteEnvironmentAuthInvalidJsonError({ | ||
| 127 | message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`, | ||
| 128 | cause, | ||
| 129 | }), | ||
| 130 | ); | ||
| 131 | } | ||
| 132 | return Effect.fail( | ||
| 133 | new RemoteEnvironmentAuthFetchError({ | ||
| 134 | message: `Failed to fetch remote environment endpoint ${requestUrl} (${String(cause)}).`, | ||
| 135 | cause, | ||
| 136 | }), | ||
| 137 | ); | ||
| 138 | }; | ||
| 139 | |||
| 140 | export const executeEnvironmentHttpRequest = <A, E, R>( | ||
| 141 | requestUrl: string, | ||
| 142 | timeoutMs: number, | ||
| 143 | request: Effect.Effect<A, E, R>, | ||
| 144 | ): Effect.Effect<A, RemoteEnvironmentRequestError, R> => | ||
| 145 | request.pipe( | ||
| 146 | Effect.timeoutOption(Duration.millis(timeoutMs)), | ||
| 147 | Effect.flatMap( | ||
| 148 | Option.match({ | ||
| 149 | onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)), | ||
| 150 | onSome: Effect.succeed, | ||
| 151 | }), | ||
| 152 | ), | ||
| 153 | Effect.catch((cause) => failRemoteRequest(requestUrl, cause)), | ||
| 154 | ); | ||
packages/client-runtime/src/rpc/index.ts created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | export * from "./client.ts"; | ||
| 2 | export * from "./http.ts"; | ||
| 3 | export * from "./protocol.ts"; | ||
| 4 | export * from "./session.ts"; | ||
packages/client-runtime/src/rpc/protocol.ts created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | import { WsRpcGroup } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { RpcClient } from "effect/unstable/rpc"; | ||
| 4 | |||
| 5 | export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); | ||
| 6 | type RpcClientFactory = typeof makeWsRpcProtocolClient; | ||
| 7 | export type WsRpcProtocolClient = | ||
| 8 | RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never; | ||
packages/client-runtime/src/rpc/session.test.ts created+276| ... | @@ -0,0 +1,276 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | EnvironmentId, | ||
| 4 | ServerConfig, | ||
| 5 | type ServerConfig as ServerConfigType, | ||
| 6 | WS_METHODS, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { describe, expect, it } from "@effect/vitest"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Fiber from "effect/Fiber"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import * as TestClock from "effect/testing/TestClock"; | ||
| 14 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | ConnectionTransientError, | ||
| 18 | PrimaryConnectionTarget, | ||
| 19 | type PreparedConnection, | ||
| 20 | } from "../connection/model.ts"; | ||
| 21 | import { RpcSessionFactory, rpcSessionFactoryLayer } from "./session.ts"; | ||
| 22 | |||
| 23 | type SocketEventType = "open" | "message" | "close" | "error"; | ||
| 24 | type SocketEvent = { | ||
| 25 | readonly code?: number; | ||
| 26 | readonly data?: unknown; | ||
| 27 | readonly reason?: string; | ||
| 28 | readonly type: SocketEventType; | ||
| 29 | }; | ||
| 30 | type SocketListener = (event: SocketEvent) => void; | ||
| 31 | |||
| 32 | class TestWebSocket { | ||
| 33 | static readonly CONNECTING = 0; | ||
| 34 | static readonly OPEN = 1; | ||
| 35 | static readonly CLOSING = 2; | ||
| 36 | static readonly CLOSED = 3; | ||
| 37 | |||
| 38 | readyState = TestWebSocket.CONNECTING; | ||
| 39 | readonly sent: string[] = []; | ||
| 40 | readonly url: string; | ||
| 41 | private readonly listeners = new Map<SocketEventType, Set<SocketListener>>(); | ||
| 42 | |||
| 43 | constructor(url: string) { | ||
| 44 | this.url = url; | ||
| 45 | } | ||
| 46 | |||
| 47 | addEventListener(type: SocketEventType, listener: SocketListener) { | ||
| 48 | const listeners = this.listeners.get(type) ?? new Set<SocketListener>(); | ||
| 49 | listeners.add(listener); | ||
| 50 | this.listeners.set(type, listeners); | ||
| 51 | } | ||
| 52 | |||
| 53 | removeEventListener(type: SocketEventType, listener: SocketListener) { | ||
| 54 | this.listeners.get(type)?.delete(listener); | ||
| 55 | } | ||
| 56 | |||
| 57 | send(data: string) { | ||
| 58 | this.sent.push(data); | ||
| 59 | } | ||
| 60 | |||
| 61 | close(code = 1000, reason = "") { | ||
| 62 | if (this.readyState === TestWebSocket.CLOSED) { | ||
| 63 | return; | ||
| 64 | } | ||
| 65 | this.readyState = TestWebSocket.CLOSED; | ||
| 66 | this.emit("close", { code, reason, type: "close" }); | ||
| 67 | } | ||
| 68 | |||
| 69 | open() { | ||
| 70 | this.readyState = TestWebSocket.OPEN; | ||
| 71 | this.emit("open", { type: "open" }); | ||
| 72 | } | ||
| 73 | |||
| 74 | serverMessage(data: string) { | ||
| 75 | this.emit("message", { data, type: "message" }); | ||
| 76 | } | ||
| 77 | |||
| 78 | private emit(type: SocketEventType, event: SocketEvent) { | ||
| 79 | for (const listener of this.listeners.get(type) ?? []) { | ||
| 80 | listener(event); | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | const TARGET = new PrimaryConnectionTarget({ | ||
| 86 | environmentId: EnvironmentId.make("environment-1"), | ||
| 87 | label: "Test environment", | ||
| 88 | httpBaseUrl: "https://environment.example.test", | ||
| 89 | wsBaseUrl: "wss://environment.example.test", | ||
| 90 | }); | ||
| 91 | |||
| 92 | const PREPARED: PreparedConnection = { | ||
| 93 | environmentId: TARGET.environmentId, | ||
| 94 | label: TARGET.label, | ||
| 95 | httpBaseUrl: TARGET.httpBaseUrl, | ||
| 96 | socketUrl: "wss://environment.example.test/ws?wsTicket=test", | ||
| 97 | httpAuthorization: null, | ||
| 98 | target: TARGET, | ||
| 99 | }; | ||
| 100 | |||
| 101 | const SERVER_CONFIG: ServerConfigType = { | ||
| 102 | environment: { | ||
| 103 | environmentId: TARGET.environmentId, | ||
| 104 | label: TARGET.label, | ||
| 105 | platform: { | ||
| 106 | os: "darwin", | ||
| 107 | arch: "arm64", | ||
| 108 | }, | ||
| 109 | serverVersion: "0.0.0-test", | ||
| 110 | capabilities: { | ||
| 111 | repositoryIdentity: true, | ||
| 112 | }, | ||
| 113 | }, | ||
| 114 | auth: { | ||
| 115 | policy: "loopback-browser", | ||
| 116 | bootstrapMethods: ["one-time-token"], | ||
| 117 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | ||
| 118 | sessionCookieName: "t3_session", | ||
| 119 | }, | ||
| 120 | cwd: "/tmp/workspace", | ||
| 121 | keybindingsConfigPath: "/tmp/workspace/keybindings.json", | ||
| 122 | keybindings: [], | ||
| 123 | issues: [], | ||
| 124 | providers: [], | ||
| 125 | availableEditors: [], | ||
| 126 | observability: { | ||
| 127 | logsDirectoryPath: "/tmp/logs", | ||
| 128 | localTracingEnabled: false, | ||
| 129 | otlpTracesEnabled: false, | ||
| 130 | otlpMetricsEnabled: false, | ||
| 131 | }, | ||
| 132 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 133 | }; | ||
| 134 | |||
| 135 | const RpcRequest = Schema.TaggedStruct("Request", { | ||
| 136 | id: Schema.String, | ||
| 137 | payload: Schema.Unknown, | ||
| 138 | tag: Schema.String, | ||
| 139 | }); | ||
| 140 | const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); | ||
| 141 | const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); | ||
| 142 | const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); | ||
| 143 | const encodeServerConfig = Schema.encodeSync(ServerConfig); | ||
| 144 | |||
| 145 | const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { | ||
| 146 | const sockets: TestWebSocket[] = []; | ||
| 147 | const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { | ||
| 148 | const socket = new TestWebSocket(url); | ||
| 149 | sockets.push(socket); | ||
| 150 | return socket as unknown as globalThis.WebSocket; | ||
| 151 | }); | ||
| 152 | const layer = rpcSessionFactoryLayer.pipe(Layer.provide(constructorLayer)); | ||
| 153 | const factory = yield* RpcSessionFactory.pipe(Effect.provide(layer)); | ||
| 154 | return { factory, sockets }; | ||
| 155 | }); | ||
| 156 | |||
| 157 | const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( | ||
| 158 | sockets: ReadonlyArray<TestWebSocket>, | ||
| 159 | ) { | ||
| 160 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 161 | const socket = sockets[0]; | ||
| 162 | if (socket) { | ||
| 163 | return socket; | ||
| 164 | } | ||
| 165 | yield* Effect.yieldNow; | ||
| 166 | } | ||
| 167 | return yield* Effect.die(new Error("Expected the RPC protocol to create a websocket.")); | ||
| 168 | }); | ||
| 169 | |||
| 170 | const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( | ||
| 171 | socket: TestWebSocket, | ||
| 172 | ) { | ||
| 173 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 174 | const request = socket.sent[0]; | ||
| 175 | if (request) { | ||
| 176 | return decodeRpcRequest(decodeJson(request)); | ||
| 177 | } | ||
| 178 | yield* Effect.yieldNow; | ||
| 179 | } | ||
| 180 | return yield* Effect.die(new Error("Expected the RPC protocol to send a request.")); | ||
| 181 | }); | ||
| 182 | |||
| 183 | const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( | ||
| 184 | socket: TestWebSocket, | ||
| 185 | ) { | ||
| 186 | const request = yield* awaitRequest(socket); | ||
| 187 | expect(request).toMatchObject({ | ||
| 188 | _tag: "Request", | ||
| 189 | tag: WS_METHODS.serverGetConfig, | ||
| 190 | payload: {}, | ||
| 191 | }); | ||
| 192 | socket.serverMessage( | ||
| 193 | encodeJson({ | ||
| 194 | _tag: "Exit", | ||
| 195 | requestId: request.id, | ||
| 196 | exit: { | ||
| 197 | _tag: "Success", | ||
| 198 | value: encodeServerConfig(SERVER_CONFIG), | ||
| 199 | }, | ||
| 200 | }), | ||
| 201 | ); | ||
| 202 | }); | ||
| 203 | |||
| 204 | describe("RpcSessionFactory", () => { | ||
| 205 | it.effect("owns one scoped websocket attempt and exposes readiness and closure", () => | ||
| 206 | Effect.gen(function* () { | ||
| 207 | const { factory, sockets } = yield* makeFactory(); | ||
| 208 | const session = yield* factory.connect(PREPARED); | ||
| 209 | const readyFiber = yield* Effect.forkChild(session.ready); | ||
| 210 | const socket = yield* awaitSocket(sockets); | ||
| 211 | |||
| 212 | expect(socket.url).toBe(PREPARED.socketUrl); | ||
| 213 | socket.open(); | ||
| 214 | yield* completeInitialConfig(socket); | ||
| 215 | yield* Fiber.join(readyFiber); | ||
| 216 | |||
| 217 | const config = yield* session.initialConfig; | ||
| 218 | expect(config).toEqual(SERVER_CONFIG); | ||
| 219 | expect(socket.sent).toHaveLength(1); | ||
| 220 | |||
| 221 | socket.close(1012, "service restart"); | ||
| 222 | const error = yield* Effect.flip(session.closed); | ||
| 223 | |||
| 224 | expect(error).toBeInstanceOf(ConnectionTransientError); | ||
| 225 | expect(error).toMatchObject({ | ||
| 226 | reason: "transport", | ||
| 227 | message: "Test environment disconnected.", | ||
| 228 | }); | ||
| 229 | yield* Effect.yieldNow; | ||
| 230 | expect(sockets).toHaveLength(1); | ||
| 231 | }), | ||
| 232 | ); | ||
| 233 | |||
| 234 | it.effect("closes the websocket when the session scope is released", () => | ||
| 235 | Effect.gen(function* () { | ||
| 236 | const { factory, sockets } = yield* makeFactory(); | ||
| 237 | |||
| 238 | yield* Effect.scoped( | ||
| 239 | Effect.gen(function* () { | ||
| 240 | const session = yield* factory.connect(PREPARED); | ||
| 241 | const readyFiber = yield* Effect.forkChild(session.ready); | ||
| 242 | const socket = yield* awaitSocket(sockets); | ||
| 243 | socket.open(); | ||
| 244 | yield* completeInitialConfig(socket); | ||
| 245 | yield* Fiber.join(readyFiber); | ||
| 246 | }), | ||
| 247 | ); | ||
| 248 | |||
| 249 | expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("fails readiness when the websocket never opens", () => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const { factory, sockets } = yield* makeFactory(); | ||
| 256 | |||
| 257 | const error = yield* Effect.scoped( | ||
| 258 | Effect.gen(function* () { | ||
| 259 | const session = yield* factory.connect(PREPARED); | ||
| 260 | const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); | ||
| 261 | yield* awaitSocket(sockets); | ||
| 262 | |||
| 263 | yield* TestClock.adjust("15 seconds"); | ||
| 264 | return yield* Fiber.join(readyFiber); | ||
| 265 | }), | ||
| 266 | ); | ||
| 267 | |||
| 268 | expect(error).toBeInstanceOf(ConnectionTransientError); | ||
| 269 | expect(error).toMatchObject({ | ||
| 270 | reason: "transport", | ||
| 271 | message: "Test environment could not establish a WebSocket connection.", | ||
| 272 | }); | ||
| 273 | expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); | ||
| 274 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 275 | ); | ||
| 276 | }); | ||
packages/client-runtime/src/rpc/session.ts created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Deferred from "effect/Deferred"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Schedule from "effect/Schedule"; | ||
| 7 | import type * as Scope from "effect/Scope"; | ||
| 8 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | ||
| 9 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 10 | |||
| 11 | import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts"; | ||
| 12 | import type { | ||
| 13 | ConnectionAttemptError, | ||
| 14 | ConnectionTransientError, | ||
| 15 | PreparedConnection, | ||
| 16 | } from "../connection/model.ts"; | ||
| 17 | import { | ||
| 18 | ConnectionBlockedError, | ||
| 19 | ConnectionTransientError as ConnectionTransientErrorClass, | ||
| 20 | } from "../connection/model.ts"; | ||
| 21 | |||
| 22 | const SOCKET_OPEN_TIMEOUT = "15 seconds"; | ||
| 23 | |||
| 24 | export interface RpcSession { | ||
| 25 | readonly client: WsRpcProtocolClient; | ||
| 26 | readonly initialConfig: Effect.Effect<ServerConfig, ConnectionAttemptError>; | ||
| 27 | readonly ready: Effect.Effect<void, ConnectionAttemptError>; | ||
| 28 | readonly probe: Effect.Effect<void, ConnectionAttemptError>; | ||
| 29 | readonly closed: Effect.Effect<never, ConnectionTransientError>; | ||
| 30 | } | ||
| 31 | |||
| 32 | export class RpcSessionFactory extends Context.Service< | ||
| 33 | RpcSessionFactory, | ||
| 34 | { | ||
| 35 | readonly connect: ( | ||
| 36 | connection: PreparedConnection, | ||
| 37 | ) => Effect.Effect<RpcSession, ConnectionAttemptError, Scope.Scope>; | ||
| 38 | } | ||
| 39 | >()("@t3tools/client-runtime/rpc/session/RpcSessionFactory") {} | ||
| 40 | |||
| 41 | type InitialConfigError = Effect.Error< | ||
| 42 | ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverGetConfig]> | ||
| 43 | >; | ||
| 44 | |||
| 45 | function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptError { | ||
| 46 | switch (error._tag) { | ||
| 47 | case "EnvironmentAuthorizationError": | ||
| 48 | return new ConnectionBlockedError({ | ||
| 49 | reason: "permission", | ||
| 50 | message: error.message, | ||
| 51 | }); | ||
| 52 | case "KeybindingsConfigParseError": | ||
| 53 | case "ServerSettingsError": | ||
| 54 | return new ConnectionTransientErrorClass({ | ||
| 55 | reason: "remote-unavailable", | ||
| 56 | message: error.message, | ||
| 57 | }); | ||
| 58 | case "RpcClientError": | ||
| 59 | return new ConnectionTransientErrorClass({ | ||
| 60 | reason: "transport", | ||
| 61 | message: error.message, | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | export const rpcSessionFactoryLayer = Layer.effect( | ||
| 67 | RpcSessionFactory, | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const webSocketConstructor = yield* Socket.WebSocketConstructor; | ||
| 70 | |||
| 71 | const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { | ||
| 72 | yield* Effect.annotateCurrentSpan({ | ||
| 73 | "connection.environment.id": connection.environmentId, | ||
| 74 | }); | ||
| 75 | |||
| 76 | const connected = yield* Deferred.make<void>(); | ||
| 77 | const disconnected = yield* Deferred.make<never, ConnectionTransientError>(); | ||
| 78 | const hooks = RpcClient.ConnectionHooks.of({ | ||
| 79 | onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), | ||
| 80 | onDisconnect: Deferred.isDone(connected).pipe( | ||
| 81 | Effect.flatMap((wasConnected) => | ||
| 82 | Deferred.fail( | ||
| 83 | disconnected, | ||
| 84 | new ConnectionTransientErrorClass({ | ||
| 85 | reason: "transport", | ||
| 86 | message: wasConnected | ||
| 87 | ? `${connection.label} disconnected.` | ||
| 88 | : `${connection.label} could not establish a WebSocket connection.`, | ||
| 89 | }), | ||
| 90 | ), | ||
| 91 | ), | ||
| 92 | Effect.asVoid, | ||
| 93 | ), | ||
| 94 | }); | ||
| 95 | const socketLayer = Socket.layerWebSocket(connection.socketUrl, { | ||
| 96 | openTimeout: SOCKET_OPEN_TIMEOUT, | ||
| 97 | }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); | ||
| 98 | const protocolLayer = Layer.effect( | ||
| 99 | RpcClient.Protocol, | ||
| 100 | RpcClient.makeProtocolSocket({ | ||
| 101 | retryTransientErrors: false, | ||
| 102 | retryPolicy: Schedule.recurs(0), | ||
| 103 | }), | ||
| 104 | ).pipe( | ||
| 105 | Layer.provide( | ||
| 106 | Layer.mergeAll( | ||
| 107 | socketLayer, | ||
| 108 | RpcSerialization.layerJson, | ||
| 109 | Layer.succeed(RpcClient.ConnectionHooks, hooks), | ||
| 110 | ), | ||
| 111 | ), | ||
| 112 | ); | ||
| 113 | const protocolContext = yield* Layer.build(protocolLayer).pipe( | ||
| 114 | Effect.withSpan("environment.websocket.connect"), | ||
| 115 | ); | ||
| 116 | const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); | ||
| 117 | const initialConfig = yield* Effect.cached( | ||
| 118 | client[WS_METHODS.serverGetConfig]({}).pipe( | ||
| 119 | Effect.mapError(mapInitialConfigError), | ||
| 120 | Effect.withSpan("environment.initialSync"), | ||
| 121 | ), | ||
| 122 | ); | ||
| 123 | const probe = client[WS_METHODS.serverGetConfig]({}).pipe( | ||
| 124 | Effect.mapError(mapInitialConfigError), | ||
| 125 | Effect.asVoid, | ||
| 126 | Effect.withSpan("clientRuntime.connection.rpcSession.probe"), | ||
| 127 | ); | ||
| 128 | |||
| 129 | return { | ||
| 130 | client, | ||
| 131 | initialConfig, | ||
| 132 | ready: Deferred.await(connected).pipe( | ||
| 133 | Effect.andThen(initialConfig), | ||
| 134 | Effect.asVoid, | ||
| 135 | Effect.raceFirst(Deferred.await(disconnected)), | ||
| 136 | ), | ||
| 137 | probe, | ||
| 138 | closed: Deferred.await(disconnected), | ||
| 139 | } satisfies RpcSession; | ||
| 140 | }); | ||
| 141 | |||
| 142 | return RpcSessionFactory.of({ connect }); | ||
| 143 | }), | ||
| 144 | ); | ||
packages/client-runtime/src/scoped.ts deleted-69| ... | @@ -1,69 +0,0 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | ProjectId, | ||
| 4 | ThreadId, | ||
| 5 | type EnvironmentId as EnvironmentIdType, | ||
| 6 | type ProjectId as ProjectIdType, | ||
| 7 | type ScopedProjectRef, | ||
| 8 | type ScopedThreadRef, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | export function scopeProjectRef( | ||
| 12 | environmentId: EnvironmentIdType, | ||
| 13 | projectId: ProjectIdType, | ||
| 14 | ): ScopedProjectRef { | ||
| 15 | return { environmentId, projectId }; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function scopeThreadRef( | ||
| 19 | environmentId: EnvironmentIdType, | ||
| 20 | threadId: ThreadId, | ||
| 21 | ): ScopedThreadRef { | ||
| 22 | return { environmentId, threadId }; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { | ||
| 26 | const localId = "projectId" in ref ? ref.projectId : ref.threadId; | ||
| 27 | return `${ref.environmentId}:${localId}`; | ||
| 28 | } | ||
| 29 | |||
| 30 | export function scopedProjectKey(ref: ScopedProjectRef): string { | ||
| 31 | return scopedRefKey(ref); | ||
| 32 | } | ||
| 33 | |||
| 34 | export function scopedThreadKey(ref: ScopedThreadRef): string { | ||
| 35 | return scopedRefKey(ref); | ||
| 36 | } | ||
| 37 | |||
| 38 | function parseScopedKey(key: string): { environmentId: EnvironmentIdType; localId: string } | null { | ||
| 39 | const separatorIndex = key.indexOf(":"); | ||
| 40 | if (separatorIndex <= 0 || separatorIndex >= key.length - 1) { | ||
| 41 | return null; | ||
| 42 | } | ||
| 43 | return { | ||
| 44 | environmentId: EnvironmentId.make(key.slice(0, separatorIndex)), | ||
| 45 | localId: key.slice(separatorIndex + 1), | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function parseScopedProjectKey(key: string): ScopedProjectRef | null { | ||
| 50 | const parsed = parseScopedKey(key); | ||
| 51 | if (!parsed) { | ||
| 52 | return null; | ||
| 53 | } | ||
| 54 | return { | ||
| 55 | environmentId: parsed.environmentId, | ||
| 56 | projectId: ProjectId.make(parsed.localId), | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 60 | export function parseScopedThreadKey(key: string): ScopedThreadRef | null { | ||
| 61 | const parsed = parseScopedKey(key); | ||
| 62 | if (!parsed) { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | return { | ||
| 66 | environmentId: parsed.environmentId, | ||
| 67 | threadId: ThreadId.make(parsed.localId), | ||
| 68 | }; | ||
| 69 | } | ||
packages/client-runtime/src/shellSnapshotReducer.test.ts deleted-163| ... | @@ -1,163 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; | ||
| 7 | |||
| 8 | const baseSnapshot: OrchestrationShellSnapshot = { | ||
| 9 | snapshotSequence: 0, | ||
| 10 | projects: [], | ||
| 11 | threads: [], | ||
| 12 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 13 | }; | ||
| 14 | |||
| 15 | const stubProject = { | ||
| 16 | id: ProjectId.make("project-1"), | ||
| 17 | title: "Test Project", | ||
| 18 | workspaceRoot: "/workspace/test", | ||
| 19 | repositoryIdentity: null, | ||
| 20 | defaultModelSelection: null, | ||
| 21 | scripts: [], | ||
| 22 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 23 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 24 | } as const; | ||
| 25 | |||
| 26 | const stubThread = { | ||
| 27 | id: ThreadId.make("thread-1"), | ||
| 28 | projectId: ProjectId.make("project-1"), | ||
| 29 | title: "Test Thread", | ||
| 30 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 31 | runtimeMode: "full-access" as const, | ||
| 32 | interactionMode: "default" as const, | ||
| 33 | branch: null, | ||
| 34 | worktreePath: null, | ||
| 35 | latestTurn: null, | ||
| 36 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 37 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 38 | archivedAt: null, | ||
| 39 | latestUserMessageAt: null, | ||
| 40 | hasPendingApprovals: false, | ||
| 41 | hasPendingUserInput: false, | ||
| 42 | hasActionableProposedPlan: false, | ||
| 43 | session: null, | ||
| 44 | } as const; | ||
| 45 | |||
| 46 | describe("applyShellStreamEvent", () => { | ||
| 47 | describe("project-upserted", () => { | ||
| 48 | it("adds a new project", () => { | ||
| 49 | const event: OrchestrationShellStreamEvent = { | ||
| 50 | kind: "project-upserted", | ||
| 51 | sequence: 1, | ||
| 52 | project: stubProject, | ||
| 53 | }; | ||
| 54 | |||
| 55 | const next = applyShellStreamEvent(baseSnapshot, event); | ||
| 56 | |||
| 57 | expect(next.projects).toHaveLength(1); | ||
| 58 | expect(next.projects[0]?.id).toBe("project-1"); | ||
| 59 | expect(next.snapshotSequence).toBe(1); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("updates an existing project", () => { | ||
| 63 | const snapshotWithProject: OrchestrationShellSnapshot = { | ||
| 64 | ...baseSnapshot, | ||
| 65 | projects: [stubProject], | ||
| 66 | }; | ||
| 67 | |||
| 68 | const updatedProject = { ...stubProject, title: "Updated Title" }; | ||
| 69 | const event: OrchestrationShellStreamEvent = { | ||
| 70 | kind: "project-upserted", | ||
| 71 | sequence: 2, | ||
| 72 | project: updatedProject, | ||
| 73 | }; | ||
| 74 | |||
| 75 | const next = applyShellStreamEvent(snapshotWithProject, event); | ||
| 76 | |||
| 77 | expect(next.projects).toHaveLength(1); | ||
| 78 | expect(next.projects[0]?.title).toBe("Updated Title"); | ||
| 79 | expect(next.snapshotSequence).toBe(2); | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | describe("project-removed", () => { | ||
| 84 | it("removes a project by id", () => { | ||
| 85 | const snapshotWithProject: OrchestrationShellSnapshot = { | ||
| 86 | ...baseSnapshot, | ||
| 87 | projects: [stubProject], | ||
| 88 | }; | ||
| 89 | |||
| 90 | const event: OrchestrationShellStreamEvent = { | ||
| 91 | kind: "project-removed", | ||
| 92 | sequence: 3, | ||
| 93 | projectId: ProjectId.make("project-1"), | ||
| 94 | }; | ||
| 95 | |||
| 96 | const next = applyShellStreamEvent(snapshotWithProject, event); | ||
| 97 | |||
| 98 | expect(next.projects).toHaveLength(0); | ||
| 99 | expect(next.snapshotSequence).toBe(3); | ||
| 100 | }); | ||
| 101 | }); | ||
| 102 | |||
| 103 | describe("thread-upserted", () => { | ||
| 104 | it("adds a new thread", () => { | ||
| 105 | const event: OrchestrationShellStreamEvent = { | ||
| 106 | kind: "thread-upserted", | ||
| 107 | sequence: 4, | ||
| 108 | thread: stubThread, | ||
| 109 | }; | ||
| 110 | |||
| 111 | const next = applyShellStreamEvent(baseSnapshot, event); | ||
| 112 | |||
| 113 | expect(next.threads).toHaveLength(1); | ||
| 114 | expect(next.threads[0]?.id).toBe("thread-1"); | ||
| 115 | expect(next.snapshotSequence).toBe(4); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("updates an existing thread", () => { | ||
| 119 | const snapshotWithThread: OrchestrationShellSnapshot = { | ||
| 120 | ...baseSnapshot, | ||
| 121 | threads: [stubThread], | ||
| 122 | }; | ||
| 123 | |||
| 124 | const updatedThread = { ...stubThread, title: "Updated Thread" }; | ||
| 125 | const event: OrchestrationShellStreamEvent = { | ||
| 126 | kind: "thread-upserted", | ||
| 127 | sequence: 5, | ||
| 128 | thread: updatedThread, | ||
| 129 | }; | ||
| 130 | |||
| 131 | const next = applyShellStreamEvent(snapshotWithThread, event); | ||
| 132 | |||
| 133 | expect(next.threads).toHaveLength(1); | ||
| 134 | expect(next.threads[0]?.title).toBe("Updated Thread"); | ||
| 135 | }); | ||
| 136 | }); | ||
| 137 | |||
| 138 | describe("thread-removed", () => { | ||
| 139 | it("removes a thread by id", () => { | ||
| 140 | const snapshotWithThread: OrchestrationShellSnapshot = { | ||
| 141 | ...baseSnapshot, | ||
| 142 | threads: [stubThread], | ||
| 143 | }; | ||
| 144 | |||
| 145 | const event: OrchestrationShellStreamEvent = { | ||
| 146 | kind: "thread-removed", | ||
| 147 | sequence: 6, | ||
| 148 | threadId: ThreadId.make("thread-1"), | ||
| 149 | }; | ||
| 150 | |||
| 151 | const next = applyShellStreamEvent(snapshotWithThread, event); | ||
| 152 | |||
| 153 | expect(next.threads).toHaveLength(0); | ||
| 154 | expect(next.snapshotSequence).toBe(6); | ||
| 155 | }); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it("returns original snapshot for unrecognized event kinds", () => { | ||
| 159 | const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any; | ||
| 160 | const next = applyShellStreamEvent(baseSnapshot, unknownEvent); | ||
| 161 | expect(next).toBe(baseSnapshot); | ||
| 162 | }); | ||
| 163 | }); | ||
packages/client-runtime/src/shellSnapshotReducer.ts deleted-44| ... | @@ -1,44 +0,0 @@ | ||
| 1 | import * as Arr from "effect/Array"; | ||
| 2 | import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | /** | ||
| 5 | * Apply a single shell stream event to an existing snapshot, returning a new | ||
| 6 | * snapshot with the event's changes applied. This is a pure reducer that both | ||
| 7 | * web and mobile can use to keep their local shell snapshot in sync. | ||
| 8 | * | ||
| 9 | * Returns the original snapshot reference unchanged if the event is not | ||
| 10 | * recognized (forward-compatible). | ||
| 11 | */ | ||
| 12 | export function applyShellStreamEvent( | ||
| 13 | snapshot: OrchestrationShellSnapshot, | ||
| 14 | event: OrchestrationShellStreamEvent, | ||
| 15 | ): OrchestrationShellSnapshot { | ||
| 16 | switch (event.kind) { | ||
| 17 | case "project-upserted": { | ||
| 18 | const projects = snapshot.projects.some((p) => p.id === event.project.id) | ||
| 19 | ? Arr.map(snapshot.projects, (p) => (p.id === event.project.id ? event.project : p)) | ||
| 20 | : Arr.append(snapshot.projects, event.project); | ||
| 21 | return { ...snapshot, projects, snapshotSequence: event.sequence }; | ||
| 22 | } | ||
| 23 | case "project-removed": | ||
| 24 | return { | ||
| 25 | ...snapshot, | ||
| 26 | projects: Arr.filter(snapshot.projects, (p) => p.id !== event.projectId), | ||
| 27 | snapshotSequence: event.sequence, | ||
| 28 | }; | ||
| 29 | case "thread-upserted": { | ||
| 30 | const threads = snapshot.threads.some((t) => t.id === event.thread.id) | ||
| 31 | ? Arr.map(snapshot.threads, (t) => (t.id === event.thread.id ? event.thread : t)) | ||
| 32 | : Arr.append(snapshot.threads, event.thread); | ||
| 33 | return { ...snapshot, threads, snapshotSequence: event.sequence }; | ||
| 34 | } | ||
| 35 | case "thread-removed": | ||
| 36 | return { | ||
| 37 | ...snapshot, | ||
| 38 | threads: Arr.filter(snapshot.threads, (t) => t.id !== event.threadId), | ||
| 39 | snapshotSequence: event.sequence, | ||
| 40 | }; | ||
| 41 | default: | ||
| 42 | return snapshot; | ||
| 43 | } | ||
| 44 | } | ||
packages/client-runtime/src/shellSnapshotState.test.ts deleted-128| ... | @@ -1,128 +0,0 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import { afterEach, describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | EnvironmentId, | ||
| 6 | ProjectId, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ThreadId, | ||
| 9 | type OrchestrationShellSnapshot, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { createShellSnapshotManager } from "./shellSnapshotState.ts"; | ||
| 13 | |||
| 14 | let atomRegistry = AtomRegistry.make(); | ||
| 15 | |||
| 16 | function resetAtomRegistry() { | ||
| 17 | atomRegistry.dispose(); | ||
| 18 | atomRegistry = AtomRegistry.make(); | ||
| 19 | } | ||
| 20 | |||
| 21 | const BASE_SNAPSHOT: OrchestrationShellSnapshot = { | ||
| 22 | snapshotSequence: 1, | ||
| 23 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 24 | projects: [ | ||
| 25 | { | ||
| 26 | id: ProjectId.make("project-1"), | ||
| 27 | title: "Project", | ||
| 28 | workspaceRoot: "/repo", | ||
| 29 | repositoryIdentity: null, | ||
| 30 | defaultModelSelection: null, | ||
| 31 | scripts: [], | ||
| 32 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 33 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 34 | }, | ||
| 35 | ], | ||
| 36 | threads: [ | ||
| 37 | { | ||
| 38 | id: ThreadId.make("thread-1"), | ||
| 39 | projectId: ProjectId.make("project-1"), | ||
| 40 | title: "Thread", | ||
| 41 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 42 | runtimeMode: "full-access", | ||
| 43 | interactionMode: "default", | ||
| 44 | branch: null, | ||
| 45 | worktreePath: null, | ||
| 46 | latestTurn: null, | ||
| 47 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 48 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 49 | archivedAt: null, | ||
| 50 | session: null, | ||
| 51 | latestUserMessageAt: null, | ||
| 52 | hasPendingApprovals: false, | ||
| 53 | hasPendingUserInput: false, | ||
| 54 | hasActionableProposedPlan: false, | ||
| 55 | }, | ||
| 56 | ], | ||
| 57 | }; | ||
| 58 | |||
| 59 | const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; | ||
| 60 | |||
| 61 | describe("createShellSnapshotManager", () => { | ||
| 62 | afterEach(() => { | ||
| 63 | resetAtomRegistry(); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("starts pending when marked pending", () => { | ||
| 67 | const manager = createShellSnapshotManager({ | ||
| 68 | getRegistry: () => atomRegistry, | ||
| 69 | }); | ||
| 70 | |||
| 71 | manager.markPending(TARGET); | ||
| 72 | |||
| 73 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 74 | data: null, | ||
| 75 | error: null, | ||
| 76 | isPending: true, | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("stores snapshots", () => { | ||
| 81 | const manager = createShellSnapshotManager({ | ||
| 82 | getRegistry: () => atomRegistry, | ||
| 83 | }); | ||
| 84 | |||
| 85 | manager.syncSnapshot(TARGET, BASE_SNAPSHOT); | ||
| 86 | |||
| 87 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 88 | data: BASE_SNAPSHOT, | ||
| 89 | error: null, | ||
| 90 | isPending: false, | ||
| 91 | }); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("applies incremental shell events", () => { | ||
| 95 | const manager = createShellSnapshotManager({ | ||
| 96 | getRegistry: () => atomRegistry, | ||
| 97 | }); | ||
| 98 | const existingThread = BASE_SNAPSHOT.threads[0]!; | ||
| 99 | |||
| 100 | manager.syncSnapshot(TARGET, BASE_SNAPSHOT); | ||
| 101 | manager.applyEvent(TARGET, { | ||
| 102 | kind: "thread-upserted", | ||
| 103 | sequence: 2, | ||
| 104 | thread: { | ||
| 105 | ...existingThread, | ||
| 106 | title: "Renamed thread", | ||
| 107 | }, | ||
| 108 | }); | ||
| 109 | |||
| 110 | expect(manager.getSnapshot(TARGET).data?.threads[0]?.title).toBe("Renamed thread"); | ||
| 111 | expect(manager.getSnapshot(TARGET).data?.snapshotSequence).toBe(2); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("invalidates per environment", () => { | ||
| 115 | const manager = createShellSnapshotManager({ | ||
| 116 | getRegistry: () => atomRegistry, | ||
| 117 | }); | ||
| 118 | |||
| 119 | manager.syncSnapshot(TARGET, BASE_SNAPSHOT); | ||
| 120 | manager.invalidate(TARGET); | ||
| 121 | |||
| 122 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 123 | data: null, | ||
| 124 | error: null, | ||
| 125 | isPending: false, | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | }); | ||
packages/client-runtime/src/shellSnapshotState.ts deleted-140| ... | @@ -1,140 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationShellSnapshot, | ||
| 4 | OrchestrationShellStreamEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 7 | |||
| 8 | import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; | ||
| 9 | |||
| 10 | export interface ShellSnapshotState { | ||
| 11 | readonly data: OrchestrationShellSnapshot | null; | ||
| 12 | readonly error: string | null; | ||
| 13 | readonly isPending: boolean; | ||
| 14 | } | ||
| 15 | |||
| 16 | export interface ShellSnapshotTarget { | ||
| 17 | readonly environmentId: EnvironmentId | null; | ||
| 18 | } | ||
| 19 | |||
| 20 | export const EMPTY_SHELL_SNAPSHOT_STATE = Object.freeze<ShellSnapshotState>({ | ||
| 21 | data: null, | ||
| 22 | error: null, | ||
| 23 | isPending: false, | ||
| 24 | }); | ||
| 25 | |||
| 26 | const INITIAL_SHELL_SNAPSHOT_STATE = Object.freeze<ShellSnapshotState>({ | ||
| 27 | data: null, | ||
| 28 | error: null, | ||
| 29 | isPending: true, | ||
| 30 | }); | ||
| 31 | |||
| 32 | const knownShellSnapshotKeys = new Set<string>(); | ||
| 33 | |||
| 34 | export const shellSnapshotStateAtom = Atom.family((key: string) => { | ||
| 35 | knownShellSnapshotKeys.add(key); | ||
| 36 | return Atom.make(INITIAL_SHELL_SNAPSHOT_STATE).pipe( | ||
| 37 | Atom.keepAlive, | ||
| 38 | Atom.withLabel(`shell-snapshot:${key}`), | ||
| 39 | ); | ||
| 40 | }); | ||
| 41 | |||
| 42 | export const EMPTY_SHELL_SNAPSHOT_ATOM = Atom.make(EMPTY_SHELL_SNAPSHOT_STATE).pipe( | ||
| 43 | Atom.keepAlive, | ||
| 44 | Atom.withLabel("shell-snapshot:null"), | ||
| 45 | ); | ||
| 46 | |||
| 47 | export function getShellSnapshotTargetKey(target: ShellSnapshotTarget): string | null { | ||
| 48 | return target.environmentId; | ||
| 49 | } | ||
| 50 | |||
| 51 | export interface ShellSnapshotManagerConfig { | ||
| 52 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 53 | } | ||
| 54 | |||
| 55 | export function createShellSnapshotManager(config: ShellSnapshotManagerConfig) { | ||
| 56 | function getSnapshot(target: ShellSnapshotTarget): ShellSnapshotState { | ||
| 57 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 58 | if (targetKey === null) { | ||
| 59 | return EMPTY_SHELL_SNAPSHOT_STATE; | ||
| 60 | } | ||
| 61 | |||
| 62 | return config.getRegistry().get(shellSnapshotStateAtom(targetKey)); | ||
| 63 | } | ||
| 64 | |||
| 65 | function setState(targetKey: string, nextState: ShellSnapshotState): void { | ||
| 66 | config.getRegistry().set(shellSnapshotStateAtom(targetKey), nextState); | ||
| 67 | } | ||
| 68 | |||
| 69 | function markPending(target: ShellSnapshotTarget): void { | ||
| 70 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 71 | if (targetKey === null) { | ||
| 72 | return; | ||
| 73 | } | ||
| 74 | |||
| 75 | const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); | ||
| 76 | setState(targetKey, { | ||
| 77 | data: current.data, | ||
| 78 | error: null, | ||
| 79 | isPending: true, | ||
| 80 | }); | ||
| 81 | } | ||
| 82 | |||
| 83 | function syncSnapshot(target: ShellSnapshotTarget, snapshot: OrchestrationShellSnapshot): void { | ||
| 84 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 85 | if (targetKey === null) { | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | |||
| 89 | setState(targetKey, { | ||
| 90 | data: snapshot, | ||
| 91 | error: null, | ||
| 92 | isPending: false, | ||
| 93 | }); | ||
| 94 | } | ||
| 95 | |||
| 96 | function applyEvent(target: ShellSnapshotTarget, event: OrchestrationShellStreamEvent): void { | ||
| 97 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 98 | if (targetKey === null) { | ||
| 99 | return; | ||
| 100 | } | ||
| 101 | |||
| 102 | const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); | ||
| 103 | if (current.data === null) { | ||
| 104 | return; | ||
| 105 | } | ||
| 106 | |||
| 107 | setState(targetKey, { | ||
| 108 | data: applyShellStreamEvent(current.data, event), | ||
| 109 | error: null, | ||
| 110 | isPending: false, | ||
| 111 | }); | ||
| 112 | } | ||
| 113 | |||
| 114 | function invalidate(target?: ShellSnapshotTarget): void { | ||
| 115 | if (target) { | ||
| 116 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 117 | if (targetKey !== null) { | ||
| 118 | setState(targetKey, EMPTY_SHELL_SNAPSHOT_STATE); | ||
| 119 | } | ||
| 120 | return; | ||
| 121 | } | ||
| 122 | |||
| 123 | for (const key of knownShellSnapshotKeys) { | ||
| 124 | setState(key, EMPTY_SHELL_SNAPSHOT_STATE); | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | function reset(): void { | ||
| 129 | invalidate(); | ||
| 130 | } | ||
| 131 | |||
| 132 | return { | ||
| 133 | markPending, | ||
| 134 | syncSnapshot, | ||
| 135 | applyEvent, | ||
| 136 | getSnapshot, | ||
| 137 | invalidate, | ||
| 138 | reset, | ||
| 139 | }; | ||
| 140 | } | ||
packages/client-runtime/src/shellTypes.ts deleted-38| ... | @@ -1,38 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationProjectShell, | ||
| 4 | OrchestrationShellSnapshot, | ||
| 5 | OrchestrationThreadShell, | ||
| 6 | ThreadId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | export interface EnvironmentScopedProjectShell extends OrchestrationProjectShell { | ||
| 10 | readonly environmentId: EnvironmentId; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface EnvironmentScopedThreadShell extends OrchestrationThreadShell { | ||
| 14 | readonly environmentId: EnvironmentId; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function scopeProjectShell( | ||
| 18 | environmentId: EnvironmentId, | ||
| 19 | project: OrchestrationProjectShell, | ||
| 20 | ): EnvironmentScopedProjectShell { | ||
| 21 | return { ...project, environmentId }; | ||
| 22 | } | ||
| 23 | |||
| 24 | export function scopeThreadShell( | ||
| 25 | environmentId: EnvironmentId, | ||
| 26 | thread: OrchestrationThreadShell, | ||
| 27 | ): EnvironmentScopedThreadShell { | ||
| 28 | return { ...thread, environmentId }; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function selectScopedThreadShell( | ||
| 32 | snapshot: OrchestrationShellSnapshot | null, | ||
| 33 | environmentId: EnvironmentId, | ||
| 34 | threadId: ThreadId, | ||
| 35 | ): EnvironmentScopedThreadShell | null { | ||
| 36 | const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; | ||
| 37 | return thread ? scopeThreadShell(environmentId, thread) : null; | ||
| 38 | } | ||
packages/client-runtime/src/sourceControlDiscoveryState.test.ts deleted-309| ... | @@ -1,309 +0,0 @@ | ||
| 1 | import { assert, beforeEach, it } from "vite-plus/test"; | ||
| 2 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | ||
| 8 | createSourceControlDiscoveryManager, | ||
| 9 | } from "./sourceControlDiscoveryState.ts"; | ||
| 10 | |||
| 11 | const EMPTY_RESULT: SourceControlDiscoveryResult = { | ||
| 12 | versionControlSystems: [], | ||
| 13 | sourceControlProviders: [], | ||
| 14 | }; | ||
| 15 | |||
| 16 | const GITHUB_RESULT: SourceControlDiscoveryResult = { | ||
| 17 | versionControlSystems: [ | ||
| 18 | { | ||
| 19 | kind: "git", | ||
| 20 | label: "Git", | ||
| 21 | implemented: true, | ||
| 22 | status: "available", | ||
| 23 | version: Option.some("2.51.0"), | ||
| 24 | installHint: "Install Git.", | ||
| 25 | detail: Option.none(), | ||
| 26 | }, | ||
| 27 | ], | ||
| 28 | sourceControlProviders: [ | ||
| 29 | { | ||
| 30 | kind: "github", | ||
| 31 | label: "GitHub", | ||
| 32 | status: "available", | ||
| 33 | version: Option.some("2.85.0"), | ||
| 34 | installHint: "Install GitHub CLI.", | ||
| 35 | detail: Option.none(), | ||
| 36 | auth: { | ||
| 37 | status: "authenticated", | ||
| 38 | account: Option.some("octo"), | ||
| 39 | host: Option.some("github.com"), | ||
| 40 | detail: Option.none(), | ||
| 41 | }, | ||
| 42 | }, | ||
| 43 | ], | ||
| 44 | }; | ||
| 45 | |||
| 46 | function unresolvedDiscovery() { | ||
| 47 | throw new Error("Discovery resolver was not initialized."); | ||
| 48 | } | ||
| 49 | |||
| 50 | let registry = AtomRegistry.make(); | ||
| 51 | |||
| 52 | const noop = () => undefined; | ||
| 53 | |||
| 54 | beforeEach(() => { | ||
| 55 | registry.dispose(); | ||
| 56 | registry = AtomRegistry.make(); | ||
| 57 | }); | ||
| 58 | |||
| 59 | function flushAsyncWork(): Promise<void> { | ||
| 60 | return Promise.resolve().then(() => undefined); | ||
| 61 | } | ||
| 62 | |||
| 63 | it("stores refreshed discovery data in an atom snapshot", async () => { | ||
| 64 | const manager = createSourceControlDiscoveryManager({ | ||
| 65 | getRegistry: () => registry, | ||
| 66 | getClient: () => ({ | ||
| 67 | discoverSourceControl: async () => EMPTY_RESULT, | ||
| 68 | }), | ||
| 69 | }); | ||
| 70 | |||
| 71 | assert.deepStrictEqual(manager.getSnapshot({ key: null }), EMPTY_SOURCE_CONTROL_DISCOVERY_STATE); | ||
| 72 | |||
| 73 | const result = await manager.refresh({ key: "primary" }); | ||
| 74 | |||
| 75 | assert.strictEqual(result, EMPTY_RESULT); | ||
| 76 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 77 | data: EMPTY_RESULT, | ||
| 78 | error: null, | ||
| 79 | isPending: false, | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("deduplicates in-flight discovery refreshes by target key", async () => { | ||
| 84 | let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; | ||
| 85 | let calls = 0; | ||
| 86 | const manager = createSourceControlDiscoveryManager({ | ||
| 87 | getRegistry: () => registry, | ||
| 88 | getClient: () => ({ | ||
| 89 | discoverSourceControl: () => { | ||
| 90 | calls += 1; | ||
| 91 | return new Promise<SourceControlDiscoveryResult>((resolve) => { | ||
| 92 | resolveDiscovery = resolve; | ||
| 93 | }); | ||
| 94 | }, | ||
| 95 | }), | ||
| 96 | }); | ||
| 97 | |||
| 98 | const first = manager.refresh({ key: "primary" }); | ||
| 99 | const second = manager.refresh({ key: "primary" }); | ||
| 100 | |||
| 101 | assert.strictEqual(first, second); | ||
| 102 | assert.strictEqual(calls, 1); | ||
| 103 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 104 | data: null, | ||
| 105 | error: null, | ||
| 106 | isPending: true, | ||
| 107 | }); | ||
| 108 | |||
| 109 | resolveDiscovery(EMPTY_RESULT); | ||
| 110 | await first; | ||
| 111 | |||
| 112 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 113 | data: EMPTY_RESULT, | ||
| 114 | error: null, | ||
| 115 | isPending: false, | ||
| 116 | }); | ||
| 117 | }); | ||
| 118 | |||
| 119 | it("keeps the previous snapshot when refresh fails", async () => { | ||
| 120 | let shouldFail = false; | ||
| 121 | const manager = createSourceControlDiscoveryManager({ | ||
| 122 | getRegistry: () => registry, | ||
| 123 | getClient: () => ({ | ||
| 124 | discoverSourceControl: async () => { | ||
| 125 | if (shouldFail) { | ||
| 126 | throw new Error("probe failed"); | ||
| 127 | } | ||
| 128 | return EMPTY_RESULT; | ||
| 129 | }, | ||
| 130 | }), | ||
| 131 | }); | ||
| 132 | |||
| 133 | await manager.refresh({ key: "primary" }); | ||
| 134 | shouldFail = true; | ||
| 135 | |||
| 136 | const result = await manager.refresh({ key: "primary" }); | ||
| 137 | |||
| 138 | assert.strictEqual(result, EMPTY_RESULT); | ||
| 139 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 140 | data: EMPTY_RESULT, | ||
| 141 | error: "probe failed", | ||
| 142 | isPending: false, | ||
| 143 | }); | ||
| 144 | }); | ||
| 145 | |||
| 146 | it("invalidates a discovery target back to the initial snapshot", async () => { | ||
| 147 | const manager = createSourceControlDiscoveryManager({ | ||
| 148 | getRegistry: () => registry, | ||
| 149 | getClient: () => ({ | ||
| 150 | discoverSourceControl: async () => GITHUB_RESULT, | ||
| 151 | }), | ||
| 152 | }); | ||
| 153 | |||
| 154 | await manager.refresh({ key: "primary" }); | ||
| 155 | manager.invalidate({ key: "primary" }); | ||
| 156 | |||
| 157 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 158 | data: null, | ||
| 159 | error: null, | ||
| 160 | isPending: true, | ||
| 161 | }); | ||
| 162 | }); | ||
| 163 | |||
| 164 | it("ignores an in-flight refresh after the target is invalidated", async () => { | ||
| 165 | let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; | ||
| 166 | const manager = createSourceControlDiscoveryManager({ | ||
| 167 | getRegistry: () => registry, | ||
| 168 | getClient: () => ({ | ||
| 169 | discoverSourceControl: () => | ||
| 170 | new Promise<SourceControlDiscoveryResult>((resolve) => { | ||
| 171 | resolveDiscovery = resolve; | ||
| 172 | }), | ||
| 173 | }), | ||
| 174 | }); | ||
| 175 | |||
| 176 | const refresh = manager.refresh({ key: "primary" }); | ||
| 177 | manager.invalidate({ key: "primary" }); | ||
| 178 | resolveDiscovery(GITHUB_RESULT); | ||
| 179 | |||
| 180 | const result = await refresh; | ||
| 181 | |||
| 182 | assert.strictEqual(result, GITHUB_RESULT); | ||
| 183 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 184 | data: null, | ||
| 185 | error: null, | ||
| 186 | isPending: true, | ||
| 187 | }); | ||
| 188 | }); | ||
| 189 | |||
| 190 | it("watches a discovery target with ref-counted client-change subscriptions", async () => { | ||
| 191 | let listener: () => void = noop; | ||
| 192 | let subscribeCalls = 0; | ||
| 193 | let unsubscribeCalls = 0; | ||
| 194 | let discoveryCalls = 0; | ||
| 195 | const client = { | ||
| 196 | discoverSourceControl: async () => { | ||
| 197 | discoveryCalls += 1; | ||
| 198 | return EMPTY_RESULT; | ||
| 199 | }, | ||
| 200 | }; | ||
| 201 | const manager = createSourceControlDiscoveryManager({ | ||
| 202 | getRegistry: () => registry, | ||
| 203 | getClient: () => client, | ||
| 204 | subscribeClientChanges: (nextListener) => { | ||
| 205 | subscribeCalls += 1; | ||
| 206 | listener = nextListener; | ||
| 207 | return () => { | ||
| 208 | unsubscribeCalls += 1; | ||
| 209 | }; | ||
| 210 | }, | ||
| 211 | }); | ||
| 212 | |||
| 213 | const firstUnwatch = manager.watch({ key: "primary" }); | ||
| 214 | const secondUnwatch = manager.watch({ key: "primary" }); | ||
| 215 | await flushAsyncWork(); | ||
| 216 | |||
| 217 | assert.strictEqual(subscribeCalls, 1); | ||
| 218 | assert.strictEqual(discoveryCalls, 1); | ||
| 219 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 220 | data: EMPTY_RESULT, | ||
| 221 | error: null, | ||
| 222 | isPending: false, | ||
| 223 | }); | ||
| 224 | |||
| 225 | listener(); | ||
| 226 | await flushAsyncWork(); | ||
| 227 | assert.strictEqual(discoveryCalls, 1); | ||
| 228 | |||
| 229 | firstUnwatch(); | ||
| 230 | assert.strictEqual(unsubscribeCalls, 0); | ||
| 231 | |||
| 232 | secondUnwatch(); | ||
| 233 | assert.strictEqual(unsubscribeCalls, 1); | ||
| 234 | }); | ||
| 235 | |||
| 236 | it("reuses fresh watched discovery results on remount", async () => { | ||
| 237 | let discoveryCalls = 0; | ||
| 238 | const client = { | ||
| 239 | discoverSourceControl: async () => { | ||
| 240 | discoveryCalls += 1; | ||
| 241 | return EMPTY_RESULT; | ||
| 242 | }, | ||
| 243 | }; | ||
| 244 | const manager = createSourceControlDiscoveryManager({ | ||
| 245 | getRegistry: () => registry, | ||
| 246 | getClient: () => client, | ||
| 247 | staleTimeMs: 60_000, | ||
| 248 | }); | ||
| 249 | |||
| 250 | const firstUnwatch = manager.watch({ key: "primary" }); | ||
| 251 | await flushAsyncWork(); | ||
| 252 | firstUnwatch(); | ||
| 253 | |||
| 254 | const secondUnwatch = manager.watch({ key: "primary" }); | ||
| 255 | await flushAsyncWork(); | ||
| 256 | secondUnwatch(); | ||
| 257 | |||
| 258 | assert.strictEqual(discoveryCalls, 1); | ||
| 259 | }); | ||
| 260 | |||
| 261 | it("refreshes a watched discovery target when the resolved client is replaced", async () => { | ||
| 262 | let listener: () => void = noop; | ||
| 263 | let activeResult = EMPTY_RESULT; | ||
| 264 | let discoveryCalls = 0; | ||
| 265 | const firstClient = { | ||
| 266 | discoverSourceControl: async () => { | ||
| 267 | discoveryCalls += 1; | ||
| 268 | return activeResult; | ||
| 269 | }, | ||
| 270 | }; | ||
| 271 | const secondClient = { | ||
| 272 | discoverSourceControl: async () => { | ||
| 273 | discoveryCalls += 1; | ||
| 274 | return activeResult; | ||
| 275 | }, | ||
| 276 | }; | ||
| 277 | let activeClient = firstClient; | ||
| 278 | const manager = createSourceControlDiscoveryManager({ | ||
| 279 | getRegistry: () => registry, | ||
| 280 | getClient: () => activeClient, | ||
| 281 | subscribeClientChanges: (nextListener) => { | ||
| 282 | listener = nextListener; | ||
| 283 | return () => undefined; | ||
| 284 | }, | ||
| 285 | }); | ||
| 286 | |||
| 287 | const unwatch = manager.watch({ key: "primary" }); | ||
| 288 | await flushAsyncWork(); | ||
| 289 | |||
| 290 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 291 | data: EMPTY_RESULT, | ||
| 292 | error: null, | ||
| 293 | isPending: false, | ||
| 294 | }); | ||
| 295 | |||
| 296 | activeClient = secondClient; | ||
| 297 | activeResult = GITHUB_RESULT; | ||
| 298 | listener(); | ||
| 299 | await flushAsyncWork(); | ||
| 300 | |||
| 301 | assert.strictEqual(discoveryCalls, 2); | ||
| 302 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 303 | data: GITHUB_RESULT, | ||
| 304 | error: null, | ||
| 305 | isPending: false, | ||
| 306 | }); | ||
| 307 | |||
| 308 | unwatch(); | ||
| 309 | }); | ||
packages/client-runtime/src/sourceControlDiscoveryState.ts deleted-401| ... | @@ -1,401 +0,0 @@ | ||
| 1 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | /* --- Types ---------------------------------------------------------- */ | ||
| 6 | |||
| 7 | export interface SourceControlDiscoveryState { | ||
| 8 | readonly data: SourceControlDiscoveryResult | null; | ||
| 9 | readonly error: string | null; | ||
| 10 | readonly isPending: boolean; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface SourceControlDiscoveryTarget<TKey extends string = string> { | ||
| 14 | readonly key: TKey | null; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface SourceControlDiscoveryClient { | ||
| 18 | readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; | ||
| 19 | } | ||
| 20 | |||
| 21 | interface WatchedEntry { | ||
| 22 | refCount: number; | ||
| 23 | teardown: () => void; | ||
| 24 | } | ||
| 25 | |||
| 26 | /* --- Constants ------------------------------------------------------ */ | ||
| 27 | |||
| 28 | export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({ | ||
| 29 | data: null, | ||
| 30 | error: null, | ||
| 31 | isPending: false, | ||
| 32 | }); | ||
| 33 | |||
| 34 | const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({ | ||
| 35 | data: null, | ||
| 36 | error: null, | ||
| 37 | isPending: true, | ||
| 38 | }); | ||
| 39 | |||
| 40 | /* --- Atoms ---------------------------------------------------------- */ | ||
| 41 | |||
| 42 | const knownSourceControlDiscoveryKeys = new Set<string>(); | ||
| 43 | |||
| 44 | export const sourceControlDiscoveryStateAtom = Atom.family((key: string) => { | ||
| 45 | knownSourceControlDiscoveryKeys.add(key); | ||
| 46 | return Atom.make(INITIAL_SOURCE_CONTROL_DISCOVERY_STATE).pipe( | ||
| 47 | Atom.keepAlive, | ||
| 48 | Atom.withLabel(`source-control-discovery:${key}`), | ||
| 49 | ); | ||
| 50 | }); | ||
| 51 | |||
| 52 | export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make( | ||
| 53 | EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | ||
| 54 | ).pipe(Atom.keepAlive, Atom.withLabel("source-control-discovery:null")); | ||
| 55 | |||
| 56 | /* --- Helpers -------------------------------------------------------- */ | ||
| 57 | |||
| 58 | export function getSourceControlDiscoveryTargetKey<TKey extends string>( | ||
| 59 | target: SourceControlDiscoveryTarget<TKey>, | ||
| 60 | ): TKey | null { | ||
| 61 | const key = target.key; | ||
| 62 | return key && key.length > 0 ? key : null; | ||
| 63 | } | ||
| 64 | |||
| 65 | /* --- Refresh manager ------------------------------------------------ */ | ||
| 66 | |||
| 67 | export interface SourceControlDiscoveryManagerConfig<TKey extends string = string> { | ||
| 68 | /** | ||
| 69 | * Get the atom registry used to read/write source-control discovery snapshots. | ||
| 70 | */ | ||
| 71 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 72 | /** | ||
| 73 | * Resolve the runtime client for a discovery target key. | ||
| 74 | * | ||
| 75 | * Web currently uses a single `"primary"` target, but keeping this keyed | ||
| 76 | * lets mobile or future multi-environment clients provide separate discovery | ||
| 77 | * clients without changing the state primitive. | ||
| 78 | */ | ||
| 79 | readonly getClient: (key: TKey) => SourceControlDiscoveryClient | null; | ||
| 80 | /** | ||
| 81 | * Optional: subscribe to environment/client availability changes. | ||
| 82 | * | ||
| 83 | * When provided, `watch` refreshes as clients appear or are replaced | ||
| 84 | * instead of relying on React hooks to manually kick discovery. | ||
| 85 | */ | ||
| 86 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 87 | readonly staleTimeMs?: number; | ||
| 88 | readonly idleTtlMs?: number; | ||
| 89 | } | ||
| 90 | |||
| 91 | const NOOP: () => void = () => undefined; | ||
| 92 | const DEFAULT_STALE_TIME_MS = 30_000; | ||
| 93 | const DEFAULT_IDLE_TTL_MS = 5 * 60_000; | ||
| 94 | |||
| 95 | export function createSourceControlDiscoveryManager<TKey extends string = string>( | ||
| 96 | config: SourceControlDiscoveryManagerConfig<TKey>, | ||
| 97 | ) { | ||
| 98 | const refreshInFlight = new Map< | ||
| 99 | string, | ||
| 100 | { | ||
| 101 | readonly client: SourceControlDiscoveryClient; | ||
| 102 | readonly promise: Promise<SourceControlDiscoveryResult | null>; | ||
| 103 | } | ||
| 104 | >(); | ||
| 105 | const refreshVersions = new Map<string, number>(); | ||
| 106 | const watched = new Map<string, WatchedEntry>(); | ||
| 107 | const refreshTargets = new Map<string, SourceControlDiscoveryTarget<TKey>>(); | ||
| 108 | const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; | ||
| 109 | const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; | ||
| 110 | |||
| 111 | const watchedRefreshAtom = Atom.family((targetKey: string) => | ||
| 112 | Atom.make(() => | ||
| 113 | Effect.promise(() => { | ||
| 114 | const target = refreshTargets.get(targetKey); | ||
| 115 | return target ? refresh(target) : Promise.resolve(null); | ||
| 116 | }), | ||
| 117 | ).pipe( | ||
| 118 | Atom.swr({ | ||
| 119 | staleTime: staleTimeMs, | ||
| 120 | revalidateOnMount: true, | ||
| 121 | }), | ||
| 122 | Atom.setIdleTTL(idleTtlMs), | ||
| 123 | Atom.withLabel(`source-control-discovery:watched-refresh:${targetKey}`), | ||
| 124 | ), | ||
| 125 | ); | ||
| 126 | |||
| 127 | function getRefreshVersion(targetKey: string): number { | ||
| 128 | return refreshVersions.get(targetKey) ?? 0; | ||
| 129 | } | ||
| 130 | |||
| 131 | function bumpRefreshVersion(targetKey: string): void { | ||
| 132 | refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); | ||
| 133 | } | ||
| 134 | |||
| 135 | /* -- Atom helpers -------------------------------------------------- */ | ||
| 136 | |||
| 137 | function setState(targetKey: string, nextState: SourceControlDiscoveryState): void { | ||
| 138 | config.getRegistry().set(sourceControlDiscoveryStateAtom(targetKey), nextState); | ||
| 139 | } | ||
| 140 | |||
| 141 | function markPending(targetKey: string): void { | ||
| 142 | const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | ||
| 143 | const next: SourceControlDiscoveryState = | ||
| 144 | current.data === null | ||
| 145 | ? INITIAL_SOURCE_CONTROL_DISCOVERY_STATE | ||
| 146 | : { | ||
| 147 | data: current.data, | ||
| 148 | error: null, | ||
| 149 | isPending: true, | ||
| 150 | }; | ||
| 151 | |||
| 152 | if ( | ||
| 153 | current.data === next.data && | ||
| 154 | current.error === next.error && | ||
| 155 | current.isPending === next.isPending | ||
| 156 | ) { | ||
| 157 | return; | ||
| 158 | } | ||
| 159 | |||
| 160 | setState(targetKey, next); | ||
| 161 | } | ||
| 162 | |||
| 163 | function setData(targetKey: string, data: SourceControlDiscoveryResult): void { | ||
| 164 | setState(targetKey, { | ||
| 165 | data, | ||
| 166 | error: null, | ||
| 167 | isPending: false, | ||
| 168 | }); | ||
| 169 | } | ||
| 170 | |||
| 171 | function setError(targetKey: string, error: unknown): void { | ||
| 172 | const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | ||
| 173 | setState(targetKey, { | ||
| 174 | data: current.data, | ||
| 175 | error: error instanceof Error ? error.message : "Failed to discover source control tools.", | ||
| 176 | isPending: false, | ||
| 177 | }); | ||
| 178 | } | ||
| 179 | |||
| 180 | /* -- Public API ---------------------------------------------------- */ | ||
| 181 | |||
| 182 | /** | ||
| 183 | * Trigger a one-shot source-control discovery RPC for a target. | ||
| 184 | * | ||
| 185 | * Calls are deduplicated while a refresh for the same target key is in | ||
| 186 | * flight. On failure, the previous successful snapshot is kept in `data` | ||
| 187 | * and the error message is stored separately so UI can keep rendering stale | ||
| 188 | * discovery results while showing the failure. | ||
| 189 | * | ||
| 190 | * @param target The logical runtime target to refresh. | ||
| 191 | * @param client Optional pre-resolved client, useful in tests. | ||
| 192 | */ | ||
| 193 | function refresh( | ||
| 194 | target: SourceControlDiscoveryTarget<TKey>, | ||
| 195 | client?: SourceControlDiscoveryClient, | ||
| 196 | ): Promise<SourceControlDiscoveryResult | null> { | ||
| 197 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 198 | if (targetKey === null) { | ||
| 199 | return Promise.resolve(null); | ||
| 200 | } | ||
| 201 | refreshTargets.set(targetKey, target); | ||
| 202 | |||
| 203 | const resolvedClient = client ?? config.getClient(targetKey); | ||
| 204 | if (!resolvedClient) { | ||
| 205 | const error = new Error("Source control discovery client is unavailable."); | ||
| 206 | setError(targetKey, error); | ||
| 207 | return Promise.resolve(getSnapshot(target).data); | ||
| 208 | } | ||
| 209 | |||
| 210 | const existing = refreshInFlight.get(targetKey); | ||
| 211 | if (existing) { | ||
| 212 | if (!client || existing.client === resolvedClient) { | ||
| 213 | return existing.promise; | ||
| 214 | } | ||
| 215 | |||
| 216 | return existing.promise.then(() => refresh(target, resolvedClient)); | ||
| 217 | } | ||
| 218 | |||
| 219 | markPending(targetKey); | ||
| 220 | const refreshVersion = getRefreshVersion(targetKey); | ||
| 221 | const promise = resolvedClient.discoverSourceControl().then( | ||
| 222 | (result) => { | ||
| 223 | if (getRefreshVersion(targetKey) === refreshVersion) { | ||
| 224 | setData(targetKey, result); | ||
| 225 | } | ||
| 226 | return result; | ||
| 227 | }, | ||
| 228 | (error: unknown) => { | ||
| 229 | if (getRefreshVersion(targetKey) === refreshVersion) { | ||
| 230 | setError(targetKey, error); | ||
| 231 | } | ||
| 232 | return getSnapshot(target).data; | ||
| 233 | }, | ||
| 234 | ); | ||
| 235 | let tracked: Promise<SourceControlDiscoveryResult | null>; | ||
| 236 | tracked = promise.finally(() => { | ||
| 237 | if (refreshInFlight.get(targetKey)?.promise === tracked) { | ||
| 238 | refreshInFlight.delete(targetKey); | ||
| 239 | } | ||
| 240 | }); | ||
| 241 | refreshInFlight.set(targetKey, { | ||
| 242 | client: resolvedClient, | ||
| 243 | promise: tracked, | ||
| 244 | }); | ||
| 245 | return tracked; | ||
| 246 | } | ||
| 247 | |||
| 248 | /** | ||
| 249 | * Reset discovery state for one target and ignore any currently in-flight | ||
| 250 | * refresh for that target. If no target is provided, every known target is | ||
| 251 | * invalidated. | ||
| 252 | */ | ||
| 253 | function invalidate(target?: SourceControlDiscoveryTarget<TKey>): void { | ||
| 254 | if (!target) { | ||
| 255 | reset(); | ||
| 256 | return; | ||
| 257 | } | ||
| 258 | |||
| 259 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 260 | if (targetKey === null) { | ||
| 261 | return; | ||
| 262 | } | ||
| 263 | |||
| 264 | bumpRefreshVersion(targetKey); | ||
| 265 | refreshInFlight.delete(targetKey); | ||
| 266 | setState(targetKey, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); | ||
| 267 | } | ||
| 268 | |||
| 269 | /** | ||
| 270 | * Read the current atom snapshot for `target`. | ||
| 271 | * | ||
| 272 | * Invalid targets return the inert empty state rather than creating a new | ||
| 273 | * family atom entry. | ||
| 274 | */ | ||
| 275 | function getSnapshot(target: SourceControlDiscoveryTarget<TKey>): SourceControlDiscoveryState { | ||
| 276 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 277 | if (targetKey === null) { | ||
| 278 | return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE; | ||
| 279 | } | ||
| 280 | |||
| 281 | return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | ||
| 282 | } | ||
| 283 | |||
| 284 | /** | ||
| 285 | * Keep discovery warm for `target`. | ||
| 286 | * | ||
| 287 | * Multiple callers sharing a target key are ref-counted. With | ||
| 288 | * `subscribeClientChanges`, the manager refreshes whenever a client first | ||
| 289 | * appears or is replaced after reconnect. | ||
| 290 | */ | ||
| 291 | function watch( | ||
| 292 | target: SourceControlDiscoveryTarget<TKey>, | ||
| 293 | client?: SourceControlDiscoveryClient, | ||
| 294 | ): () => void { | ||
| 295 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 296 | if (targetKey === null) { | ||
| 297 | return NOOP; | ||
| 298 | } | ||
| 299 | refreshTargets.set(targetKey, target); | ||
| 300 | |||
| 301 | const existing = watched.get(targetKey); | ||
| 302 | if (existing) { | ||
| 303 | existing.refCount += 1; | ||
| 304 | return () => unwatch(targetKey); | ||
| 305 | } | ||
| 306 | |||
| 307 | let teardown: () => void; | ||
| 308 | |||
| 309 | if (client) { | ||
| 310 | void refresh(target, client); | ||
| 311 | teardown = NOOP; | ||
| 312 | } else if (config.subscribeClientChanges) { | ||
| 313 | let currentClient: SourceControlDiscoveryClient | null = null; | ||
| 314 | |||
| 315 | const sync = () => { | ||
| 316 | const resolved = config.getClient(targetKey); | ||
| 317 | if (!resolved) { | ||
| 318 | currentClient = null; | ||
| 319 | markPending(targetKey); | ||
| 320 | return; | ||
| 321 | } | ||
| 322 | |||
| 323 | if (currentClient === resolved) { | ||
| 324 | return; | ||
| 325 | } | ||
| 326 | |||
| 327 | const isClientReplacement = currentClient !== null; | ||
| 328 | currentClient = resolved; | ||
| 329 | refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); | ||
| 330 | }; | ||
| 331 | |||
| 332 | const unsubChanges = config.subscribeClientChanges(sync); | ||
| 333 | sync(); | ||
| 334 | teardown = unsubChanges; | ||
| 335 | } else { | ||
| 336 | if (!config.getClient(targetKey)) { | ||
| 337 | return NOOP; | ||
| 338 | } | ||
| 339 | refreshWatchedTarget(targetKey, target); | ||
| 340 | teardown = NOOP; | ||
| 341 | } | ||
| 342 | |||
| 343 | watched.set(targetKey, { refCount: 1, teardown }); | ||
| 344 | return () => unwatch(targetKey); | ||
| 345 | } | ||
| 346 | |||
| 347 | function unwatch(targetKey: string): void { | ||
| 348 | const entry = watched.get(targetKey); | ||
| 349 | if (!entry) { | ||
| 350 | return; | ||
| 351 | } | ||
| 352 | |||
| 353 | entry.refCount -= 1; | ||
| 354 | if (entry.refCount > 0) { | ||
| 355 | return; | ||
| 356 | } | ||
| 357 | |||
| 358 | entry.teardown(); | ||
| 359 | watched.delete(targetKey); | ||
| 360 | } | ||
| 361 | |||
| 362 | function refreshWatchedTarget( | ||
| 363 | targetKey: string, | ||
| 364 | target: SourceControlDiscoveryTarget<TKey>, | ||
| 365 | client?: SourceControlDiscoveryClient, | ||
| 366 | ): void { | ||
| 367 | refreshTargets.set(targetKey, target); | ||
| 368 | if (client) { | ||
| 369 | void refresh(target, client); | ||
| 370 | return; | ||
| 371 | } | ||
| 372 | |||
| 373 | config.getRegistry().get(watchedRefreshAtom(targetKey)); | ||
| 374 | } | ||
| 375 | |||
| 376 | /** | ||
| 377 | * Clear in-flight refresh tracking and reset every known discovery atom. | ||
| 378 | * Primarily used by tests and runtime teardown. | ||
| 379 | */ | ||
| 380 | function reset(): void { | ||
| 381 | const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]); | ||
| 382 | for (const entry of watched.values()) { | ||
| 383 | entry.teardown(); | ||
| 384 | } | ||
| 385 | watched.clear(); | ||
| 386 | refreshTargets.clear(); | ||
| 387 | refreshInFlight.clear(); | ||
| 388 | for (const key of keys) { | ||
| 389 | bumpRefreshVersion(key); | ||
| 390 | setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | return { | ||
| 395 | watch, | ||
| 396 | refresh, | ||
| 397 | getSnapshot, | ||
| 398 | invalidate, | ||
| 399 | reset, | ||
| 400 | }; | ||
| 401 | } | ||
packages/client-runtime/src/state/archivedThreads.test.ts created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | makeArchivedThreadsEnvironmentKey, | ||
| 6 | parseArchivedThreadsEnvironmentKey, | ||
| 7 | } from "./archivedThreads.ts"; | ||
| 8 | |||
| 9 | it("round-trips environment keys in sorted order", () => { | ||
| 10 | const envA = EnvironmentId.make("env-a"); | ||
| 11 | const envB = EnvironmentId.make("env-b"); | ||
| 12 | const key = makeArchivedThreadsEnvironmentKey([envB, envA]); | ||
| 13 | |||
| 14 | expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); | ||
| 15 | }); | ||
packages/client-runtime/src/state/archivedThreads.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import { pipe } from "effect/Function"; | ||
| 4 | import * as Order from "effect/Order"; | ||
| 5 | |||
| 6 | export interface ArchivedSnapshotEntry { | ||
| 7 | readonly environmentId: EnvironmentId; | ||
| 8 | readonly snapshot: OrchestrationShellSnapshot; | ||
| 9 | } | ||
| 10 | |||
| 11 | const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; | ||
| 12 | const environmentIdOrder = Order.String as Order.Order<EnvironmentId>; | ||
| 13 | |||
| 14 | export function makeArchivedThreadsEnvironmentKey( | ||
| 15 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 16 | ): string { | ||
| 17 | return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) => | ||
| 18 | sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), | ||
| 19 | ); | ||
| 20 | } | ||
| 21 | |||
| 22 | export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<EnvironmentId> { | ||
| 23 | if (key.length === 0) { | ||
| 24 | return []; | ||
| 25 | } | ||
| 26 | return pipe( | ||
| 27 | key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), | ||
| 28 | Arr.map((environmentId) => EnvironmentId.make(environmentId)), | ||
| 29 | ); | ||
| 30 | } | ||
packages/client-runtime/src/state/assets.test.ts created+82| ... | @@ -0,0 +1,82 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { Atom } from "effect/unstable/reactivity"; | ||
| 5 | |||
| 6 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 7 | import { createAssetEnvironmentAtoms } from "./assets.ts"; | ||
| 8 | |||
| 9 | describe("createAssetEnvironmentAtoms", () => { | ||
| 10 | it("keys asset URL queries by environment and resource", () => { | ||
| 11 | const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< | ||
| 12 | EnvironmentRegistry, | ||
| 13 | never | ||
| 14 | >; | ||
| 15 | const assets = createAssetEnvironmentAtoms(runtime); | ||
| 16 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 17 | const originalTarget = { | ||
| 18 | environmentId, | ||
| 19 | input: { | ||
| 20 | resource: { | ||
| 21 | _tag: "project-favicon" as const, | ||
| 22 | cwd: "/repo/original", | ||
| 23 | }, | ||
| 24 | }, | ||
| 25 | }; | ||
| 26 | |||
| 27 | expect(assets.createUrl(originalTarget)).toBe( | ||
| 28 | assets.createUrl({ | ||
| 29 | environmentId, | ||
| 30 | input: { | ||
| 31 | resource: { | ||
| 32 | _tag: "project-favicon", | ||
| 33 | cwd: "/repo/original", | ||
| 34 | }, | ||
| 35 | }, | ||
| 36 | }), | ||
| 37 | ); | ||
| 38 | expect( | ||
| 39 | assets.createUrl({ | ||
| 40 | environmentId, | ||
| 41 | input: { | ||
| 42 | resource: { | ||
| 43 | _tag: "project-favicon", | ||
| 44 | cwd: "/repo/next", | ||
| 45 | }, | ||
| 46 | }, | ||
| 47 | }), | ||
| 48 | ).not.toBe(assets.createUrl(originalTarget)); | ||
| 49 | expect( | ||
| 50 | assets.createUrl({ | ||
| 51 | environmentId: EnvironmentId.make("environment-2"), | ||
| 52 | input: originalTarget.input, | ||
| 53 | }), | ||
| 54 | ).not.toBe(assets.createUrl(originalTarget)); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("keys collections while preserving independent resource queries", () => { | ||
| 58 | const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< | ||
| 59 | EnvironmentRegistry, | ||
| 60 | never | ||
| 61 | >; | ||
| 62 | const assets = createAssetEnvironmentAtoms(runtime); | ||
| 63 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 64 | const resources = [ | ||
| 65 | { _tag: "attachment" as const, attachmentId: "attachment-1" }, | ||
| 66 | { _tag: "attachment" as const, attachmentId: "attachment-2" }, | ||
| 67 | ]; | ||
| 68 | |||
| 69 | expect(assets.createUrls({ environmentId, resources })).toBe( | ||
| 70 | assets.createUrls({ | ||
| 71 | environmentId, | ||
| 72 | resources: resources.map((resource) => ({ ...resource })), | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | expect( | ||
| 76 | assets.createUrls({ | ||
| 77 | environmentId, | ||
| 78 | resources: [...resources].toReversed(), | ||
| 79 | }), | ||
| 80 | ).not.toBe(assets.createUrls({ environmentId, resources })); | ||
| 81 | }); | ||
| 82 | }); | ||
packages/client-runtime/src/state/assets.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import { EnvironmentId, type AssetResource, WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 5 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | ||
| 6 | |||
| 7 | const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; | ||
| 8 | const ASSET_URL_STALE_TIME_MS = 5 * 60_000; | ||
| 9 | const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; | ||
| 10 | |||
| 11 | export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { | ||
| 12 | try { | ||
| 13 | return new URL(relativeUrl, httpBaseUrl).toString(); | ||
| 14 | } catch { | ||
| 15 | return null; | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | export function createAssetEnvironmentAtoms<R, E>( | ||
| 20 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 21 | ) { | ||
| 22 | const createUrl = createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 23 | label: "environment-data:assets:create-url", | ||
| 24 | tag: WS_METHODS.assetsCreateUrl, | ||
| 25 | staleTimeMs: ASSET_URL_STALE_TIME_MS, | ||
| 26 | idleTtlMs: ASSET_URL_IDLE_TTL_MS, | ||
| 27 | refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, | ||
| 28 | }); | ||
| 29 | const createUrlsFamily = Atom.family((key: string) => { | ||
| 30 | const [rawEnvironmentId, resources] = JSON.parse(key) as [string, ReadonlyArray<AssetResource>]; | ||
| 31 | const environmentId = EnvironmentId.make(rawEnvironmentId); | ||
| 32 | return Atom.make((get) => | ||
| 33 | resources.map((resource) => | ||
| 34 | get( | ||
| 35 | createUrl({ | ||
| 36 | environmentId, | ||
| 37 | input: { resource }, | ||
| 38 | }), | ||
| 39 | ), | ||
| 40 | ), | ||
| 41 | ).pipe( | ||
| 42 | Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS), | ||
| 43 | Atom.withLabel(`environment-data:assets:create-urls:${key}`), | ||
| 44 | ); | ||
| 45 | }); | ||
| 46 | |||
| 47 | return { | ||
| 48 | createUrl, | ||
| 49 | createUrls: (target: { | ||
| 50 | readonly environmentId: EnvironmentId; | ||
| 51 | readonly resources: ReadonlyArray<AssetResource>; | ||
| 52 | }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), | ||
| 53 | }; | ||
| 54 | } | ||
packages/client-runtime/src/state/auth.test.ts created+79| ... | @@ -0,0 +1,79 @@ | ||
| 1 | import { AuthSessionId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | |||
| 5 | import { applyAuthAccessStreamEvent, EMPTY_AUTH_ACCESS_SNAPSHOT } from "./auth.ts"; | ||
| 6 | |||
| 7 | describe("applyAuthAccessStreamEvent", () => { | ||
| 8 | it("accumulates rapid pairing-link and client updates into one snapshot", () => { | ||
| 9 | const pairingLink = { | ||
| 10 | id: "pairing-link", | ||
| 11 | credential: "credential", | ||
| 12 | scopes: ["orchestration:read"], | ||
| 13 | subject: "subject", | ||
| 14 | label: "Phone", | ||
| 15 | createdAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), | ||
| 16 | expiresAt: DateTime.makeUnsafe("2036-04-07T00:05:00.000Z"), | ||
| 17 | } as const; | ||
| 18 | const clientSession = { | ||
| 19 | sessionId: AuthSessionId.make("session-client"), | ||
| 20 | subject: "subject", | ||
| 21 | scopes: ["orchestration:read"], | ||
| 22 | method: "browser-session-cookie", | ||
| 23 | client: { | ||
| 24 | label: "Phone", | ||
| 25 | deviceType: "mobile", | ||
| 26 | }, | ||
| 27 | issuedAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), | ||
| 28 | expiresAt: DateTime.makeUnsafe("2036-05-07T00:00:00.000Z"), | ||
| 29 | lastConnectedAt: null, | ||
| 30 | connected: true, | ||
| 31 | current: false, | ||
| 32 | } as const; | ||
| 33 | |||
| 34 | const withPairingLink = applyAuthAccessStreamEvent(EMPTY_AUTH_ACCESS_SNAPSHOT, { | ||
| 35 | version: 1, | ||
| 36 | revision: 1, | ||
| 37 | type: "pairingLinkUpserted", | ||
| 38 | payload: pairingLink, | ||
| 39 | }); | ||
| 40 | const withClient = applyAuthAccessStreamEvent(withPairingLink, { | ||
| 41 | version: 1, | ||
| 42 | revision: 2, | ||
| 43 | type: "clientUpserted", | ||
| 44 | payload: clientSession, | ||
| 45 | }); | ||
| 46 | |||
| 47 | expect(withClient).toEqual({ | ||
| 48 | pairingLinks: [pairingLink], | ||
| 49 | clientSessions: [clientSession], | ||
| 50 | }); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("applies removals without disturbing unrelated access state", () => { | ||
| 54 | const snapshot = applyAuthAccessStreamEvent( | ||
| 55 | { | ||
| 56 | pairingLinks: [ | ||
| 57 | { | ||
| 58 | id: "pairing-link", | ||
| 59 | credential: "credential", | ||
| 60 | scopes: ["orchestration:read"], | ||
| 61 | subject: "subject", | ||
| 62 | label: "Phone", | ||
| 63 | createdAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), | ||
| 64 | expiresAt: DateTime.makeUnsafe("2036-04-07T00:05:00.000Z"), | ||
| 65 | }, | ||
| 66 | ], | ||
| 67 | clientSessions: [], | ||
| 68 | }, | ||
| 69 | { | ||
| 70 | version: 1, | ||
| 71 | revision: 2, | ||
| 72 | type: "pairingLinkRemoved", | ||
| 73 | payload: { id: "pairing-link" }, | ||
| 74 | }, | ||
| 75 | ); | ||
| 76 | |||
| 77 | expect(snapshot).toEqual(EMPTY_AUTH_ACCESS_SNAPSHOT); | ||
| 78 | }); | ||
| 79 | }); | ||
packages/client-runtime/src/state/auth.ts created+90| ... | @@ -0,0 +1,90 @@ | ||
| 1 | import type { | ||
| 2 | AuthAccessSnapshot, | ||
| 3 | AuthAccessStreamEvent, | ||
| 4 | AuthAccessStreamSnapshotEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import { Atom } from "effect/unstable/reactivity"; | ||
| 9 | |||
| 10 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 11 | import { subscribe } from "../rpc/client.ts"; | ||
| 12 | import { createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; | ||
| 13 | |||
| 14 | export const EMPTY_AUTH_ACCESS_SNAPSHOT: AuthAccessSnapshot = { | ||
| 15 | pairingLinks: [], | ||
| 16 | clientSessions: [], | ||
| 17 | }; | ||
| 18 | |||
| 19 | function upsertByKey<A>( | ||
| 20 | values: ReadonlyArray<A>, | ||
| 21 | next: A, | ||
| 22 | key: (value: A) => string, | ||
| 23 | ): ReadonlyArray<A> { | ||
| 24 | const nextKey = key(next); | ||
| 25 | return [...values.filter((value) => key(value) !== nextKey), next]; | ||
| 26 | } | ||
| 27 | |||
| 28 | export function applyAuthAccessStreamEvent( | ||
| 29 | current: AuthAccessSnapshot, | ||
| 30 | event: AuthAccessStreamEvent, | ||
| 31 | ): AuthAccessSnapshot { | ||
| 32 | switch (event.type) { | ||
| 33 | case "snapshot": | ||
| 34 | return event.payload; | ||
| 35 | case "pairingLinkUpserted": | ||
| 36 | return { | ||
| 37 | ...current, | ||
| 38 | pairingLinks: upsertByKey(current.pairingLinks, event.payload, (value) => value.id), | ||
| 39 | }; | ||
| 40 | case "pairingLinkRemoved": | ||
| 41 | return { | ||
| 42 | ...current, | ||
| 43 | pairingLinks: current.pairingLinks.filter((value) => value.id !== event.payload.id), | ||
| 44 | }; | ||
| 45 | case "clientUpserted": | ||
| 46 | return { | ||
| 47 | ...current, | ||
| 48 | clientSessions: upsertByKey( | ||
| 49 | current.clientSessions, | ||
| 50 | event.payload, | ||
| 51 | (value) => value.sessionId, | ||
| 52 | ), | ||
| 53 | }; | ||
| 54 | case "clientRemoved": | ||
| 55 | return { | ||
| 56 | ...current, | ||
| 57 | clientSessions: current.clientSessions.filter( | ||
| 58 | (value) => value.sessionId !== event.payload.sessionId, | ||
| 59 | ), | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | export function projectAuthAccessSnapshot( | ||
| 65 | current: AuthAccessSnapshot, | ||
| 66 | event: AuthAccessStreamEvent, | ||
| 67 | ): readonly [AuthAccessSnapshot, ReadonlyArray<AuthAccessStreamEvent>] { | ||
| 68 | const snapshot = applyAuthAccessStreamEvent(current, event); | ||
| 69 | const projected: AuthAccessStreamSnapshotEvent = { | ||
| 70 | version: 1, | ||
| 71 | revision: event.revision, | ||
| 72 | type: "snapshot", | ||
| 73 | payload: snapshot, | ||
| 74 | }; | ||
| 75 | return [snapshot, [projected]]; | ||
| 76 | } | ||
| 77 | |||
| 78 | export function createAuthEnvironmentAtoms<R, E>( | ||
| 79 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 80 | ) { | ||
| 81 | return { | ||
| 82 | accessChanges: createEnvironmentSubscriptionAtomFamily(runtime, { | ||
| 83 | label: "environment-data:server:auth-access-changes", | ||
| 84 | subscribe: (_input: null) => | ||
| 85 | subscribe(WS_METHODS.subscribeAuthAccess, {}).pipe( | ||
| 86 | Stream.mapAccum(() => EMPTY_AUTH_ACCESS_SNAPSHOT, projectAuthAccessSnapshot), | ||
| 87 | ), | ||
| 88 | }), | ||
| 89 | }; | ||
| 90 | } | ||
packages/client-runtime/src/state/checkpointDiff.ts created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationGetFullThreadDiffResult, | ||
| 4 | OrchestrationGetTurnDiffResult, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | export type CheckpointDiffResult = | ||
| 9 | | OrchestrationGetTurnDiffResult | ||
| 10 | | OrchestrationGetFullThreadDiffResult; | ||
| 11 | |||
| 12 | export interface CheckpointDiffState { | ||
| 13 | readonly data: CheckpointDiffResult | null; | ||
| 14 | readonly error: string | null; | ||
| 15 | readonly isPending: boolean; | ||
| 16 | } | ||
| 17 | |||
| 18 | export interface CheckpointDiffTarget { | ||
| 19 | readonly environmentId: EnvironmentId | null; | ||
| 20 | readonly threadId: ThreadId | null; | ||
| 21 | readonly fromTurnCount: number | null; | ||
| 22 | readonly toTurnCount: number | null; | ||
| 23 | readonly ignoreWhitespace: boolean; | ||
| 24 | readonly cacheScope?: string | null; | ||
| 25 | } | ||
packages/client-runtime/src/state/composerPathSearch.ts created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface ComposerPathSearchEntry { | ||
| 4 | readonly path: string; | ||
| 5 | readonly kind: "file" | "directory"; | ||
| 6 | readonly parentPath?: string; | ||
| 7 | } | ||
| 8 | |||
| 9 | export interface ComposerPathSearchState { | ||
| 10 | readonly entries: ReadonlyArray<ComposerPathSearchEntry>; | ||
| 11 | readonly isPending: boolean; | ||
| 12 | readonly error: string | null; | ||
| 13 | } | ||
| 14 | |||
| 15 | export interface ComposerPathSearchTarget { | ||
| 16 | readonly environmentId: EnvironmentId | null; | ||
| 17 | readonly cwd: string | null; | ||
| 18 | readonly query: string | null; | ||
| 19 | } | ||
packages/client-runtime/src/state/connections.ts created+120| ... | @@ -0,0 +1,120 @@ | ||
| 1 | import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Stream from "effect/Stream"; | ||
| 5 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 7 | |||
| 8 | import { EnvironmentRegistry, type EnvironmentRegistryService } from "../connection/registry.ts"; | ||
| 9 | import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; | ||
| 10 | import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; | ||
| 11 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | ||
| 12 | import { | ||
| 13 | createAtomCommandScheduler, | ||
| 14 | createRuntimeCommand, | ||
| 15 | followStreamInEnvironment, | ||
| 16 | } from "./runtime.ts"; | ||
| 17 | |||
| 18 | export interface EnvironmentCatalogState { | ||
| 19 | readonly isReady: boolean; | ||
| 20 | readonly entries: ReadonlyMap<EnvironmentIdType, ConnectionCatalogEntry>; | ||
| 21 | } | ||
| 22 | |||
| 23 | export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ | ||
| 24 | isReady: false, | ||
| 25 | entries: new Map(), | ||
| 26 | }); | ||
| 27 | |||
| 28 | export function createEnvironmentCatalogAtoms<R, E>( | ||
| 29 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 30 | ) { | ||
| 31 | const commandScheduler = createAtomCommandScheduler(); | ||
| 32 | const serial = { mode: "serial" as const, key: () => "environment-catalog" }; | ||
| 33 | const catalogAtom = runtime.atom( | ||
| 34 | Stream.unwrap( | ||
| 35 | EnvironmentRegistry.pipe( | ||
| 36 | Effect.map((registry) => | ||
| 37 | SubscriptionRef.changes(registry.entries).pipe( | ||
| 38 | Stream.map((entries) => ({ | ||
| 39 | isReady: true, | ||
| 40 | entries, | ||
| 41 | })), | ||
| 42 | ), | ||
| 43 | ), | ||
| 44 | ), | ||
| 45 | ), | ||
| 46 | { initialValue: EMPTY_ENVIRONMENT_CATALOG_STATE }, | ||
| 47 | ); | ||
| 48 | |||
| 49 | const catalogValueAtom = Atom.make((get) => | ||
| 50 | Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE), | ||
| 51 | ).pipe(Atom.withLabel("environment-catalog-value")); | ||
| 52 | |||
| 53 | const networkStatusAtom = runtime.atom( | ||
| 54 | Stream.unwrap( | ||
| 55 | EnvironmentRegistry.pipe( | ||
| 56 | Effect.map((registry) => SubscriptionRef.changes(registry.networkStatus)), | ||
| 57 | ), | ||
| 58 | ), | ||
| 59 | { initialValue: "unknown" as const }, | ||
| 60 | ); | ||
| 61 | |||
| 62 | const networkStatusValueAtom = Atom.make((get) => | ||
| 63 | Option.getOrElse(AsyncResult.value(get(networkStatusAtom)), () => "unknown" as const), | ||
| 64 | ).pipe(Atom.withLabel("environment-network-status-value")); | ||
| 65 | |||
| 66 | const stateAtom = Atom.family((environmentId: EnvironmentIdType) => | ||
| 67 | runtime.atom( | ||
| 68 | followStreamInEnvironment( | ||
| 69 | environmentId, | ||
| 70 | Stream.unwrap( | ||
| 71 | EnvironmentSupervisor.pipe( | ||
| 72 | Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), | ||
| 73 | ), | ||
| 74 | ), | ||
| 75 | ), | ||
| 76 | { initialValue: AVAILABLE_CONNECTION_STATE }, | ||
| 77 | ), | ||
| 78 | ); | ||
| 79 | |||
| 80 | const register = createRuntimeCommand(runtime, { | ||
| 81 | label: "environment-catalog:register", | ||
| 82 | scheduler: commandScheduler, | ||
| 83 | concurrency: serial, | ||
| 84 | execute: (target: Parameters<EnvironmentRegistryService["register"]>[0]) => | ||
| 85 | EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.register(target))), | ||
| 86 | }); | ||
| 87 | const remove = createRuntimeCommand(runtime, { | ||
| 88 | label: "environment-catalog:remove", | ||
| 89 | scheduler: commandScheduler, | ||
| 90 | concurrency: serial, | ||
| 91 | execute: (environmentId: EnvironmentIdType) => | ||
| 92 | EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.remove(environmentId))), | ||
| 93 | }); | ||
| 94 | const removeRelayEnvironments = createRuntimeCommand(runtime, { | ||
| 95 | label: "environment-catalog:remove-relay-environments", | ||
| 96 | scheduler: commandScheduler, | ||
| 97 | concurrency: serial, | ||
| 98 | execute: (_input: void) => | ||
| 99 | EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.removeRelayEnvironments())), | ||
| 100 | }); | ||
| 101 | const retryNow = createRuntimeCommand(runtime, { | ||
| 102 | label: "environment-catalog:retry-now", | ||
| 103 | scheduler: commandScheduler, | ||
| 104 | concurrency: serial, | ||
| 105 | execute: (environmentId: EnvironmentIdType) => | ||
| 106 | EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.retryNow(environmentId))), | ||
| 107 | }); | ||
| 108 | |||
| 109 | return { | ||
| 110 | catalogAtom, | ||
| 111 | catalogValueAtom, | ||
| 112 | networkStatusAtom, | ||
| 113 | networkStatusValueAtom, | ||
| 114 | stateAtom, | ||
| 115 | register, | ||
| 116 | remove, | ||
| 117 | removeRelayEnvironments, | ||
| 118 | retryNow, | ||
| 119 | }; | ||
| 120 | } | ||
packages/client-runtime/src/state/entities.test.ts created+310| ... | @@ -0,0 +1,310 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | ProjectId, | ||
| 4 | ProviderInstanceId, | ||
| 5 | ThreadId, | ||
| 6 | type OrchestrationShellSnapshot, | ||
| 7 | type OrchestrationThread, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { describe, expect, it } from "@effect/vitest"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 12 | |||
| 13 | import { PrimaryConnectionTarget } from "../connection/model.ts"; | ||
| 14 | import type { EnvironmentShellState } from "./shell.ts"; | ||
| 15 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; | ||
| 16 | import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; | ||
| 17 | import { createEnvironmentSnapshotAtom } from "./snapshots.ts"; | ||
| 18 | import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts"; | ||
| 19 | import { mergeEnvironmentThread } from "./threadDetail.ts"; | ||
| 20 | import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; | ||
| 21 | |||
| 22 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | ||
| 23 | const PROJECT_ID = ProjectId.make("project-1"); | ||
| 24 | const OTHER_PROJECT_ID = ProjectId.make("project-2"); | ||
| 25 | const THREAD_ID = ThreadId.make("thread-1"); | ||
| 26 | const OTHER_THREAD_ID = ThreadId.make("thread-2"); | ||
| 27 | |||
| 28 | const THREAD_SHELL = { | ||
| 29 | id: THREAD_ID, | ||
| 30 | projectId: PROJECT_ID, | ||
| 31 | title: "Thread", | ||
| 32 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 33 | runtimeMode: "full-access", | ||
| 34 | interactionMode: "default", | ||
| 35 | branch: null, | ||
| 36 | worktreePath: null, | ||
| 37 | latestTurn: null, | ||
| 38 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 39 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 40 | archivedAt: null, | ||
| 41 | session: null, | ||
| 42 | latestUserMessageAt: null, | ||
| 43 | hasPendingApprovals: false, | ||
| 44 | hasPendingUserInput: false, | ||
| 45 | hasActionableProposedPlan: false, | ||
| 46 | } as const; | ||
| 47 | |||
| 48 | const SNAPSHOT: OrchestrationShellSnapshot = { | ||
| 49 | snapshotSequence: 1, | ||
| 50 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 51 | projects: [ | ||
| 52 | { | ||
| 53 | id: PROJECT_ID, | ||
| 54 | title: "Project", | ||
| 55 | workspaceRoot: "/repo", | ||
| 56 | repositoryIdentity: null, | ||
| 57 | defaultModelSelection: null, | ||
| 58 | scripts: [], | ||
| 59 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 60 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 61 | }, | ||
| 62 | { | ||
| 63 | id: OTHER_PROJECT_ID, | ||
| 64 | title: "Other project", | ||
| 65 | workspaceRoot: "/other-repo", | ||
| 66 | repositoryIdentity: null, | ||
| 67 | defaultModelSelection: null, | ||
| 68 | scripts: [], | ||
| 69 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 70 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 71 | }, | ||
| 72 | ], | ||
| 73 | threads: [ | ||
| 74 | THREAD_SHELL, | ||
| 75 | { | ||
| 76 | ...THREAD_SHELL, | ||
| 77 | id: OTHER_THREAD_ID, | ||
| 78 | projectId: OTHER_PROJECT_ID, | ||
| 79 | title: "Other thread", | ||
| 80 | }, | ||
| 81 | ], | ||
| 82 | }; | ||
| 83 | |||
| 84 | function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState { | ||
| 85 | return { | ||
| 86 | snapshot: Option.some(snapshot), | ||
| 87 | status: "live", | ||
| 88 | error: Option.none(), | ||
| 89 | }; | ||
| 90 | } | ||
| 91 | |||
| 92 | function makeHarness() { | ||
| 93 | const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => | ||
| 94 | Atom.make(AsyncResult.success(shellState(SNAPSHOT))), | ||
| 95 | ); | ||
| 96 | const threadStateAtoms = Atom.family((_key: string) => | ||
| 97 | Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)), | ||
| 98 | ); | ||
| 99 | const catalogValueAtom = Atom.make({ | ||
| 100 | isReady: true, | ||
| 101 | entries: new Map([ | ||
| 102 | [ | ||
| 103 | ENVIRONMENT_ID, | ||
| 104 | { | ||
| 105 | target: new PrimaryConnectionTarget({ | ||
| 106 | environmentId: ENVIRONMENT_ID, | ||
| 107 | label: "Environment", | ||
| 108 | httpBaseUrl: "https://example.test", | ||
| 109 | wsBaseUrl: "wss://example.test", | ||
| 110 | }), | ||
| 111 | profile: Option.none(), | ||
| 112 | }, | ||
| 113 | ], | ||
| 114 | ]), | ||
| 115 | }); | ||
| 116 | const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms); | ||
| 117 | const projects = createEnvironmentProjectAtoms({ | ||
| 118 | catalogValueAtom, | ||
| 119 | snapshotAtom, | ||
| 120 | }); | ||
| 121 | const threadShells = createEnvironmentThreadShellAtoms({ | ||
| 122 | catalogValueAtom, | ||
| 123 | snapshotAtom, | ||
| 124 | }); | ||
| 125 | const threadDetails = createEnvironmentThreadDetailAtoms((environmentId, threadId) => | ||
| 126 | threadStateAtoms(`${environmentId}\u0000${threadId}`), | ||
| 127 | ); | ||
| 128 | |||
| 129 | return { | ||
| 130 | registry: AtomRegistry.make(), | ||
| 131 | shellStateAtom: shellStateAtoms(ENVIRONMENT_ID), | ||
| 132 | threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`), | ||
| 133 | projects, | ||
| 134 | threadShells, | ||
| 135 | threadDetails, | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | |||
| 139 | describe("environment entity projections", () => { | ||
| 140 | it("composes detail collections with authoritative shell workspace metadata", () => { | ||
| 141 | const messages: OrchestrationThread["messages"] = []; | ||
| 142 | const detail = { | ||
| 143 | ...THREAD_SHELL, | ||
| 144 | environmentId: ENVIRONMENT_ID, | ||
| 145 | title: "Cached thread", | ||
| 146 | branch: "stale-branch", | ||
| 147 | worktreePath: "/repo/stale-worktree", | ||
| 148 | deletedAt: null, | ||
| 149 | messages, | ||
| 150 | proposedPlans: [], | ||
| 151 | activities: [], | ||
| 152 | checkpoints: [], | ||
| 153 | } satisfies OrchestrationThread & { readonly environmentId: EnvironmentId }; | ||
| 154 | const shell = { | ||
| 155 | ...THREAD_SHELL, | ||
| 156 | environmentId: ENVIRONMENT_ID, | ||
| 157 | title: "Current thread", | ||
| 158 | branch: "current-branch", | ||
| 159 | worktreePath: "/repo/current-worktree", | ||
| 160 | }; | ||
| 161 | |||
| 162 | const merged = mergeEnvironmentThread(detail, shell); | ||
| 163 | |||
| 164 | expect(merged).toMatchObject({ | ||
| 165 | title: "Current thread", | ||
| 166 | branch: "current-branch", | ||
| 167 | worktreePath: "/repo/current-worktree", | ||
| 168 | }); | ||
| 169 | expect(merged?.messages).toBe(messages); | ||
| 170 | }); | ||
| 171 | |||
| 172 | it("preserves untouched project and thread identities across unrelated shell updates", () => { | ||
| 173 | const harness = makeHarness(); | ||
| 174 | const projectRefsAtom = harness.projects.environmentProjectRefsAtom(ENVIRONMENT_ID); | ||
| 175 | const threadRefsAtom = harness.threadShells.environmentThreadRefsAtom(ENVIRONMENT_ID); | ||
| 176 | const projectsAtom = harness.projects.projectsAtom; | ||
| 177 | const projectAtom = harness.projects.projectAtom({ | ||
| 178 | environmentId: ENVIRONMENT_ID, | ||
| 179 | projectId: PROJECT_ID, | ||
| 180 | }); | ||
| 181 | const threadAtom = harness.threadShells.threadShellAtom({ | ||
| 182 | environmentId: ENVIRONMENT_ID, | ||
| 183 | threadId: THREAD_ID, | ||
| 184 | }); | ||
| 185 | const projectRefs = harness.registry.get(projectRefsAtom); | ||
| 186 | const threadRefs = harness.registry.get(threadRefsAtom); | ||
| 187 | const projects = harness.registry.get(projectsAtom); | ||
| 188 | const project = harness.registry.get(projectAtom); | ||
| 189 | const thread = harness.registry.get(threadAtom); | ||
| 190 | |||
| 191 | harness.registry.set( | ||
| 192 | harness.shellStateAtom, | ||
| 193 | AsyncResult.success( | ||
| 194 | shellState({ | ||
| 195 | ...SNAPSHOT, | ||
| 196 | snapshotSequence: 2, | ||
| 197 | threads: SNAPSHOT.threads.map((candidate) => | ||
| 198 | candidate.id === OTHER_THREAD_ID | ||
| 199 | ? { ...candidate, title: "Renamed other thread" } | ||
| 200 | : candidate, | ||
| 201 | ), | ||
| 202 | }), | ||
| 203 | ), | ||
| 204 | ); | ||
| 205 | |||
| 206 | expect(harness.registry.get(projectRefsAtom)).toBe(projectRefs); | ||
| 207 | expect(harness.registry.get(threadRefsAtom)).toBe(threadRefs); | ||
| 208 | expect(harness.registry.get(projectsAtom)).toBe(projects); | ||
| 209 | expect(harness.registry.get(projectAtom)).toBe(project); | ||
| 210 | expect(harness.registry.get(threadAtom)).toBe(thread); | ||
| 211 | }); | ||
| 212 | |||
| 213 | it("preserves project-scoped thread collections across unrelated project updates", () => { | ||
| 214 | const harness = makeHarness(); | ||
| 215 | const projectRef = { | ||
| 216 | environmentId: ENVIRONMENT_ID, | ||
| 217 | projectId: PROJECT_ID, | ||
| 218 | }; | ||
| 219 | const refsByProjectAtom = | ||
| 220 | harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID); | ||
| 221 | const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]); | ||
| 222 | const refs = harness.registry.get(refsByProjectAtom).get(PROJECT_ID); | ||
| 223 | const threads = harness.registry.get(threadsAtom); | ||
| 224 | |||
| 225 | expect(threads).toHaveLength(1); | ||
| 226 | expect(threads[0]?.id).toBe(THREAD_ID); | ||
| 227 | |||
| 228 | harness.registry.set( | ||
| 229 | harness.shellStateAtom, | ||
| 230 | AsyncResult.success( | ||
| 231 | shellState({ | ||
| 232 | ...SNAPSHOT, | ||
| 233 | snapshotSequence: 2, | ||
| 234 | threads: SNAPSHOT.threads.map((thread) => | ||
| 235 | thread.id === OTHER_THREAD_ID ? { ...thread, title: "Updated elsewhere" } : thread, | ||
| 236 | ), | ||
| 237 | }), | ||
| 238 | ), | ||
| 239 | ); | ||
| 240 | |||
| 241 | expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs); | ||
| 242 | expect(harness.registry.get(threadsAtom)).toBe(threads); | ||
| 243 | }); | ||
| 244 | |||
| 245 | it("updates only the requested thread detail and preserves untouched field identities", () => { | ||
| 246 | const harness = makeHarness(); | ||
| 247 | const threadRef = { | ||
| 248 | environmentId: ENVIRONMENT_ID, | ||
| 249 | threadId: THREAD_ID, | ||
| 250 | }; | ||
| 251 | const otherThreadRef = { | ||
| 252 | environmentId: ENVIRONMENT_ID, | ||
| 253 | threadId: OTHER_THREAD_ID, | ||
| 254 | }; | ||
| 255 | const threadDetailAtom = harness.threadDetails.detailAtom(threadRef); | ||
| 256 | const messagesAtom = harness.threadDetails.messagesAtom(threadRef); | ||
| 257 | const activitiesAtom = harness.threadDetails.activitiesAtom(threadRef); | ||
| 258 | const statusAtom = harness.threadDetails.statusAtom(threadRef); | ||
| 259 | const otherThreadDetailAtom = harness.threadDetails.detailAtom(otherThreadRef); | ||
| 260 | const otherValue = harness.registry.get(otherThreadDetailAtom); | ||
| 261 | const detail = { | ||
| 262 | ...THREAD_SHELL, | ||
| 263 | deletedAt: null, | ||
| 264 | messages: [], | ||
| 265 | proposedPlans: [], | ||
| 266 | activities: [], | ||
| 267 | checkpoints: [], | ||
| 268 | } satisfies OrchestrationThread; | ||
| 269 | |||
| 270 | harness.registry.set( | ||
| 271 | harness.threadStateAtom(THREAD_ID), | ||
| 272 | AsyncResult.success<EnvironmentThreadState>({ | ||
| 273 | data: Option.some(detail), | ||
| 274 | status: "live", | ||
| 275 | error: Option.none(), | ||
| 276 | }), | ||
| 277 | ); | ||
| 278 | |||
| 279 | const scopedDetail = harness.registry.get(threadDetailAtom); | ||
| 280 | const messages = harness.registry.get(messagesAtom); | ||
| 281 | const activities = harness.registry.get(activitiesAtom); | ||
| 282 | |||
| 283 | expect(scopedDetail).toEqual({ ...detail, environmentId: ENVIRONMENT_ID }); | ||
| 284 | expect(harness.registry.get(statusAtom)).toBe("live"); | ||
| 285 | expect(harness.registry.get(otherThreadDetailAtom)).toBe(otherValue); | ||
| 286 | |||
| 287 | harness.registry.set( | ||
| 288 | harness.threadStateAtom(THREAD_ID), | ||
| 289 | AsyncResult.success<EnvironmentThreadState>({ | ||
| 290 | data: Option.some({ | ||
| 291 | ...detail, | ||
| 292 | session: { | ||
| 293 | threadId: THREAD_ID, | ||
| 294 | status: "ready", | ||
| 295 | providerName: "codex", | ||
| 296 | runtimeMode: "full-access", | ||
| 297 | activeTurnId: null, | ||
| 298 | lastError: null, | ||
| 299 | updatedAt: "2026-06-01T00:01:00.000Z", | ||
| 300 | }, | ||
| 301 | }), | ||
| 302 | status: "live", | ||
| 303 | error: Option.none(), | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | |||
| 307 | expect(harness.registry.get(messagesAtom)).toBe(messages); | ||
| 308 | expect(harness.registry.get(activitiesAtom)).toBe(activities); | ||
| 309 | }); | ||
| 310 | }); | ||
packages/client-runtime/src/state/entities.ts created+81| ... | @@ -0,0 +1,81 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | ProjectId, | ||
| 4 | ThreadId, | ||
| 5 | type ScopedProjectRef, | ||
| 6 | type ScopedThreadRef, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | export function projectKey(ref: ScopedProjectRef): string { | ||
| 10 | return `${ref.environmentId}\u0000${ref.projectId}`; | ||
| 11 | } | ||
| 12 | |||
| 13 | export function threadKey(ref: ScopedThreadRef): string { | ||
| 14 | return `${ref.environmentId}\u0000${ref.threadId}`; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function projectRefCollectionKey(refs: ReadonlyArray<ScopedProjectRef>): string { | ||
| 18 | return JSON.stringify(refs.map((ref) => [ref.environmentId, ref.projectId])); | ||
| 19 | } | ||
| 20 | |||
| 21 | export function parseProjectKey(key: string): ScopedProjectRef { | ||
| 22 | const separator = key.indexOf("\u0000"); | ||
| 23 | if (separator < 0) { | ||
| 24 | throw new Error("Invalid scoped project atom key."); | ||
| 25 | } | ||
| 26 | return { | ||
| 27 | environmentId: EnvironmentId.make(key.slice(0, separator)), | ||
| 28 | projectId: ProjectId.make(key.slice(separator + 1)), | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | export function parseProjectRefCollectionKey(key: string): ReadonlyArray<ScopedProjectRef> { | ||
| 33 | const entries = JSON.parse(key) as ReadonlyArray<readonly [string, string]>; | ||
| 34 | return entries.map(([environmentId, projectId]) => ({ | ||
| 35 | environmentId: EnvironmentId.make(environmentId), | ||
| 36 | projectId: ProjectId.make(projectId), | ||
| 37 | })); | ||
| 38 | } | ||
| 39 | |||
| 40 | export function parseThreadKey(key: string): ScopedThreadRef { | ||
| 41 | const separator = key.indexOf("\u0000"); | ||
| 42 | if (separator < 0) { | ||
| 43 | throw new Error("Invalid scoped thread atom key."); | ||
| 44 | } | ||
| 45 | return { | ||
| 46 | environmentId: EnvironmentId.make(key.slice(0, separator)), | ||
| 47 | threadId: ThreadId.make(key.slice(separator + 1)), | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 51 | export function projectRefsEqual( | ||
| 52 | left: ReadonlyArray<ScopedProjectRef>, | ||
| 53 | right: ReadonlyArray<ScopedProjectRef>, | ||
| 54 | ): boolean { | ||
| 55 | return ( | ||
| 56 | left.length === right.length && | ||
| 57 | left.every( | ||
| 58 | (ref, index) => | ||
| 59 | ref.environmentId === right[index]?.environmentId && | ||
| 60 | ref.projectId === right[index]?.projectId, | ||
| 61 | ) | ||
| 62 | ); | ||
| 63 | } | ||
| 64 | |||
| 65 | export function threadRefsEqual( | ||
| 66 | left: ReadonlyArray<ScopedThreadRef>, | ||
| 67 | right: ReadonlyArray<ScopedThreadRef>, | ||
| 68 | ): boolean { | ||
| 69 | return ( | ||
| 70 | left.length === right.length && | ||
| 71 | left.every( | ||
| 72 | (ref, index) => | ||
| 73 | ref.environmentId === right[index]?.environmentId && | ||
| 74 | ref.threadId === right[index]?.threadId, | ||
| 75 | ) | ||
| 76 | ); | ||
| 77 | } | ||
| 78 | |||
| 79 | export function arrayElementsEqual<A>(left: ReadonlyArray<A>, right: ReadonlyArray<A>): boolean { | ||
| 80 | return left.length === right.length && left.every((value, index) => value === right[index]); | ||
| 81 | } | ||
packages/client-runtime/src/state/filesystem.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | ||
| 5 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 6 | |||
| 7 | export function createFilesystemEnvironmentAtoms<R, E>( | ||
| 8 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 9 | ) { | ||
| 10 | return { | ||
| 11 | browse: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 12 | label: "environment-data:filesystem:browse", | ||
| 13 | tag: WS_METHODS.filesystemBrowse, | ||
| 14 | }), | ||
| 15 | }; | ||
| 16 | } | ||
packages/client-runtime/src/state/git.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | ||
| 5 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 6 | import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; | ||
| 7 | |||
| 8 | export function createGitEnvironmentAtoms<R, E>( | ||
| 9 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 10 | ) { | ||
| 11 | return { | ||
| 12 | pullRequestResolution: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 13 | label: "environment-data:git:resolve-pull-request", | ||
| 14 | tag: WS_METHODS.gitResolvePullRequest, | ||
| 15 | }), | ||
| 16 | preparePullRequestThread: createEnvironmentRpcCommand(runtime, { | ||
| 17 | label: "environment-data:git:prepare-pull-request-thread", | ||
| 18 | tag: WS_METHODS.gitPreparePullRequestThread, | ||
| 19 | scheduler: vcsCommandScheduler, | ||
| 20 | concurrency: vcsCommandConcurrency, | ||
| 21 | }), | ||
| 22 | }; | ||
| 23 | } | ||
packages/client-runtime/src/state/gitActions.ts created+440| ... | @@ -0,0 +1,440 @@ | ||
| 1 | import type { | ||
| 2 | GitRunStackedActionInput, | ||
| 3 | GitRunStackedActionResult, | ||
| 4 | GitStackedAction, | ||
| 5 | VcsStatusResult, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; | ||
| 8 | |||
| 9 | export type GitActionIconName = "commit" | "push" | "pr"; | ||
| 10 | |||
| 11 | export type GitDialogAction = "commit" | "push" | "create_pr"; | ||
| 12 | |||
| 13 | export interface GitActionMenuItem { | ||
| 14 | id: "commit" | "push" | "pr"; | ||
| 15 | label: string; | ||
| 16 | disabled: boolean; | ||
| 17 | icon: GitActionIconName; | ||
| 18 | kind: "open_dialog" | "open_pr"; | ||
| 19 | dialogAction?: GitDialogAction; | ||
| 20 | } | ||
| 21 | |||
| 22 | export interface GitQuickAction { | ||
| 23 | label: string; | ||
| 24 | disabled: boolean; | ||
| 25 | kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; | ||
| 26 | action?: GitStackedAction; | ||
| 27 | hint?: string; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface DefaultBranchActionDialogCopy { | ||
| 31 | title: string; | ||
| 32 | description: string; | ||
| 33 | continueLabel: string; | ||
| 34 | } | ||
| 35 | |||
| 36 | export type DefaultBranchConfirmableAction = | ||
| 37 | | "push" | ||
| 38 | | "create_pr" | ||
| 39 | | "commit_push" | ||
| 40 | | "commit_push_pr"; | ||
| 41 | |||
| 42 | export type GitActionRequestInput = Pick< | ||
| 43 | GitRunStackedActionInput, | ||
| 44 | "action" | "commitMessage" | "featureBranch" | "filePaths" | ||
| 45 | >; | ||
| 46 | |||
| 47 | export function buildGitActionProgressStages(input: { | ||
| 48 | action: GitStackedAction; | ||
| 49 | hasCustomCommitMessage: boolean; | ||
| 50 | hasWorkingTreeChanges: boolean; | ||
| 51 | pushTarget?: string; | ||
| 52 | featureBranch?: boolean; | ||
| 53 | shouldPushBeforePr?: boolean; | ||
| 54 | }): string[] { | ||
| 55 | const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; | ||
| 56 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; | ||
| 57 | const prStages = [ | ||
| 58 | "Preparing PR...", | ||
| 59 | "Generating PR content...", | ||
| 60 | "Creating GitHub pull request...", | ||
| 61 | ]; | ||
| 62 | |||
| 63 | if (input.action === "push") { | ||
| 64 | return [pushStage]; | ||
| 65 | } | ||
| 66 | if (input.action === "create_pr") { | ||
| 67 | return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; | ||
| 68 | } | ||
| 69 | |||
| 70 | const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; | ||
| 71 | const commitStages = !shouldIncludeCommitStages | ||
| 72 | ? [] | ||
| 73 | : input.hasCustomCommitMessage | ||
| 74 | ? ["Committing..."] | ||
| 75 | : ["Generating commit message...", "Committing..."]; | ||
| 76 | if (input.action === "commit") { | ||
| 77 | return [...branchStages, ...commitStages]; | ||
| 78 | } | ||
| 79 | if (input.action === "commit_push") { | ||
| 80 | return [...branchStages, ...commitStages, pushStage]; | ||
| 81 | } | ||
| 82 | return [...branchStages, ...commitStages, pushStage, ...prStages]; | ||
| 83 | } | ||
| 84 | |||
| 85 | export function buildMenuItems( | ||
| 86 | gitStatus: VcsStatusResult | null, | ||
| 87 | isBusy: boolean, | ||
| 88 | hasOriginRemote = true, | ||
| 89 | ): GitActionMenuItem[] { | ||
| 90 | if (!gitStatus) return []; | ||
| 91 | |||
| 92 | const hasBranch = gitStatus.refName !== null; | ||
| 93 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 94 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 95 | const isBehind = gitStatus.behindCount > 0; | ||
| 96 | const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; | ||
| 97 | const canCommit = !isBusy && hasChanges; | ||
| 98 | const canPush = | ||
| 99 | !isBusy && | ||
| 100 | hasBranch && | ||
| 101 | !hasChanges && | ||
| 102 | !isBehind && | ||
| 103 | gitStatus.aheadCount > 0 && | ||
| 104 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 105 | const canCreatePr = | ||
| 106 | !isBusy && | ||
| 107 | hasBranch && | ||
| 108 | !hasChanges && | ||
| 109 | !hasOpenPr && | ||
| 110 | gitStatus.aheadCount > 0 && | ||
| 111 | !isBehind && | ||
| 112 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 113 | const canOpenPr = !isBusy && hasOpenPr; | ||
| 114 | |||
| 115 | return [ | ||
| 116 | { | ||
| 117 | id: "commit", | ||
| 118 | label: "Commit", | ||
| 119 | disabled: !canCommit, | ||
| 120 | icon: "commit", | ||
| 121 | kind: "open_dialog", | ||
| 122 | dialogAction: "commit", | ||
| 123 | }, | ||
| 124 | { | ||
| 125 | id: "push", | ||
| 126 | label: "Push", | ||
| 127 | disabled: !canPush, | ||
| 128 | icon: "push", | ||
| 129 | kind: "open_dialog", | ||
| 130 | dialogAction: "push", | ||
| 131 | }, | ||
| 132 | hasOpenPr | ||
| 133 | ? { | ||
| 134 | id: "pr", | ||
| 135 | label: "View PR", | ||
| 136 | disabled: !canOpenPr, | ||
| 137 | icon: "pr", | ||
| 138 | kind: "open_pr", | ||
| 139 | } | ||
| 140 | : { | ||
| 141 | id: "pr", | ||
| 142 | label: "Create PR", | ||
| 143 | disabled: !canCreatePr, | ||
| 144 | icon: "pr", | ||
| 145 | kind: "open_dialog", | ||
| 146 | dialogAction: "create_pr", | ||
| 147 | }, | ||
| 148 | ]; | ||
| 149 | } | ||
| 150 | |||
| 151 | export function resolveQuickAction( | ||
| 152 | gitStatus: VcsStatusResult | null, | ||
| 153 | isBusy: boolean, | ||
| 154 | isDefaultBranch = false, | ||
| 155 | hasOriginRemote = true, | ||
| 156 | ): GitQuickAction { | ||
| 157 | if (isBusy) { | ||
| 158 | return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; | ||
| 159 | } | ||
| 160 | |||
| 161 | if (!gitStatus) { | ||
| 162 | return { | ||
| 163 | label: "Commit", | ||
| 164 | disabled: true, | ||
| 165 | kind: "show_hint", | ||
| 166 | hint: "Git status is unavailable.", | ||
| 167 | }; | ||
| 168 | } | ||
| 169 | |||
| 170 | const hasBranch = gitStatus.refName !== null; | ||
| 171 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 172 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 173 | const isAhead = gitStatus.aheadCount > 0; | ||
| 174 | const isBehind = gitStatus.behindCount > 0; | ||
| 175 | const isDiverged = isAhead && isBehind; | ||
| 176 | |||
| 177 | if (!hasBranch) { | ||
| 178 | return { | ||
| 179 | label: "Commit", | ||
| 180 | disabled: true, | ||
| 181 | kind: "show_hint", | ||
| 182 | hint: "Create and checkout a branch before pushing or opening a PR.", | ||
| 183 | }; | ||
| 184 | } | ||
| 185 | |||
| 186 | if (hasChanges) { | ||
| 187 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 188 | return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; | ||
| 189 | } | ||
| 190 | if (hasOpenPr || isDefaultBranch) { | ||
| 191 | return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; | ||
| 192 | } | ||
| 193 | return { | ||
| 194 | label: "Commit, push & PR", | ||
| 195 | disabled: false, | ||
| 196 | kind: "run_action", | ||
| 197 | action: "commit_push_pr", | ||
| 198 | }; | ||
| 199 | } | ||
| 200 | |||
| 201 | if (!gitStatus.hasUpstream) { | ||
| 202 | if (!hasOriginRemote) { | ||
| 203 | if (hasOpenPr && !isAhead) { | ||
| 204 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 205 | } | ||
| 206 | return { | ||
| 207 | label: "Push", | ||
| 208 | disabled: true, | ||
| 209 | kind: "show_hint", | ||
| 210 | hint: 'Add an "origin" remote before pushing or creating a PR.', | ||
| 211 | }; | ||
| 212 | } | ||
| 213 | if (!isAhead) { | ||
| 214 | if (hasOpenPr) { | ||
| 215 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 216 | } | ||
| 217 | return { | ||
| 218 | label: "Push", | ||
| 219 | disabled: true, | ||
| 220 | kind: "show_hint", | ||
| 221 | hint: "No local commits to push.", | ||
| 222 | }; | ||
| 223 | } | ||
| 224 | if (hasOpenPr || isDefaultBranch) { | ||
| 225 | return { | ||
| 226 | label: "Push", | ||
| 227 | disabled: false, | ||
| 228 | kind: "run_action", | ||
| 229 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 230 | }; | ||
| 231 | } | ||
| 232 | return { | ||
| 233 | label: "Push & create PR", | ||
| 234 | disabled: false, | ||
| 235 | kind: "run_action", | ||
| 236 | action: "create_pr", | ||
| 237 | }; | ||
| 238 | } | ||
| 239 | |||
| 240 | if (isDiverged) { | ||
| 241 | return { | ||
| 242 | label: "Sync branch", | ||
| 243 | disabled: true, | ||
| 244 | kind: "show_hint", | ||
| 245 | hint: "Branch has diverged from upstream. Rebase/merge first.", | ||
| 246 | }; | ||
| 247 | } | ||
| 248 | |||
| 249 | if (isBehind) { | ||
| 250 | return { | ||
| 251 | label: "Pull", | ||
| 252 | disabled: false, | ||
| 253 | kind: "run_pull", | ||
| 254 | }; | ||
| 255 | } | ||
| 256 | |||
| 257 | if (isAhead) { | ||
| 258 | if (hasOpenPr || isDefaultBranch) { | ||
| 259 | return { | ||
| 260 | label: "Push", | ||
| 261 | disabled: false, | ||
| 262 | kind: "run_action", | ||
| 263 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 264 | }; | ||
| 265 | } | ||
| 266 | return { | ||
| 267 | label: "Push & create PR", | ||
| 268 | disabled: false, | ||
| 269 | kind: "run_action", | ||
| 270 | action: "create_pr", | ||
| 271 | }; | ||
| 272 | } | ||
| 273 | |||
| 274 | if (hasOpenPr && gitStatus.hasUpstream) { | ||
| 275 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 276 | } | ||
| 277 | |||
| 278 | return { | ||
| 279 | label: "Commit", | ||
| 280 | disabled: true, | ||
| 281 | kind: "show_hint", | ||
| 282 | hint: "Branch is up to date. No action needed.", | ||
| 283 | }; | ||
| 284 | } | ||
| 285 | |||
| 286 | export function getGitActionDisabledReason(input: { | ||
| 287 | item: GitActionMenuItem; | ||
| 288 | gitStatus: VcsStatusResult | null; | ||
| 289 | isBusy: boolean; | ||
| 290 | hasOriginRemote: boolean; | ||
| 291 | }): string | null { | ||
| 292 | const { item, gitStatus, isBusy, hasOriginRemote } = input; | ||
| 293 | if (!item.disabled) return null; | ||
| 294 | if (isBusy) return "Git action in progress."; | ||
| 295 | if (!gitStatus) return "Git status is unavailable."; | ||
| 296 | |||
| 297 | const hasBranch = gitStatus.refName !== null; | ||
| 298 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 299 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 300 | const isAhead = gitStatus.aheadCount > 0; | ||
| 301 | const isBehind = gitStatus.behindCount > 0; | ||
| 302 | |||
| 303 | if (item.id === "commit") { | ||
| 304 | if (!hasChanges) { | ||
| 305 | return "Worktree is clean. Make changes before committing."; | ||
| 306 | } | ||
| 307 | return "Commit is currently unavailable."; | ||
| 308 | } | ||
| 309 | |||
| 310 | if (item.id === "push") { | ||
| 311 | if (!hasBranch) { | ||
| 312 | return "Detached HEAD: checkout a branch before pushing."; | ||
| 313 | } | ||
| 314 | if (hasChanges) { | ||
| 315 | return "Commit or stash local changes before pushing."; | ||
| 316 | } | ||
| 317 | if (isBehind) { | ||
| 318 | return "Branch is behind upstream. Pull/rebase before pushing."; | ||
| 319 | } | ||
| 320 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 321 | return 'Add an "origin" remote before pushing.'; | ||
| 322 | } | ||
| 323 | if (!isAhead) { | ||
| 324 | return "No local commits to push."; | ||
| 325 | } | ||
| 326 | return "Push is currently unavailable."; | ||
| 327 | } | ||
| 328 | |||
| 329 | if (hasOpenPr) { | ||
| 330 | return "View PR is currently unavailable."; | ||
| 331 | } | ||
| 332 | if (!hasBranch) { | ||
| 333 | return "Detached HEAD: checkout a branch before creating a PR."; | ||
| 334 | } | ||
| 335 | if (hasChanges) { | ||
| 336 | return "Commit local changes before creating a PR."; | ||
| 337 | } | ||
| 338 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 339 | return 'Add an "origin" remote before creating a PR.'; | ||
| 340 | } | ||
| 341 | if (!isAhead) { | ||
| 342 | return "No local commits to include in a PR."; | ||
| 343 | } | ||
| 344 | if (isBehind) { | ||
| 345 | return "Branch is behind upstream. Pull/rebase before creating a PR."; | ||
| 346 | } | ||
| 347 | return "Create PR is currently unavailable."; | ||
| 348 | } | ||
| 349 | |||
| 350 | export function requiresDefaultBranchConfirmation( | ||
| 351 | action: GitStackedAction, | ||
| 352 | isDefaultBranch: boolean, | ||
| 353 | ): boolean { | ||
| 354 | if (!isDefaultBranch) return false; | ||
| 355 | return ( | ||
| 356 | action === "push" || | ||
| 357 | action === "create_pr" || | ||
| 358 | action === "commit_push" || | ||
| 359 | action === "commit_push_pr" | ||
| 360 | ); | ||
| 361 | } | ||
| 362 | |||
| 363 | export function resolveDefaultBranchActionDialogCopy(input: { | ||
| 364 | action: DefaultBranchConfirmableAction; | ||
| 365 | branchName: string; | ||
| 366 | includesCommit: boolean; | ||
| 367 | }): DefaultBranchActionDialogCopy { | ||
| 368 | const branchLabel = input.branchName; | ||
| 369 | const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; | ||
| 370 | |||
| 371 | if (input.action === "push" || input.action === "commit_push") { | ||
| 372 | if (input.includesCommit) { | ||
| 373 | return { | ||
| 374 | title: "Commit & push to default branch?", | ||
| 375 | description: `This action will commit and push changes${suffix}`, | ||
| 376 | continueLabel: `Commit & push to ${branchLabel}`, | ||
| 377 | }; | ||
| 378 | } | ||
| 379 | return { | ||
| 380 | title: "Push to default branch?", | ||
| 381 | description: `This action will push local commits${suffix}`, | ||
| 382 | continueLabel: `Push to ${branchLabel}`, | ||
| 383 | }; | ||
| 384 | } | ||
| 385 | |||
| 386 | if (input.includesCommit) { | ||
| 387 | return { | ||
| 388 | title: "Commit, push & create PR from default branch?", | ||
| 389 | description: `This action will commit, push, and create a PR${suffix}`, | ||
| 390 | continueLabel: "Commit, push & create PR", | ||
| 391 | }; | ||
| 392 | } | ||
| 393 | return { | ||
| 394 | title: "Push & create PR from default branch?", | ||
| 395 | description: `This action will push local commits and create a PR${suffix}`, | ||
| 396 | continueLabel: "Push & create PR", | ||
| 397 | }; | ||
| 398 | } | ||
| 399 | |||
| 400 | export function resolveThreadBranchUpdate( | ||
| 401 | result: GitRunStackedActionResult, | ||
| 402 | ): { branch: string } | null { | ||
| 403 | if (result.branch.status !== "created" || !result.branch.name) { | ||
| 404 | return null; | ||
| 405 | } | ||
| 406 | |||
| 407 | return { | ||
| 408 | branch: result.branch.name, | ||
| 409 | }; | ||
| 410 | } | ||
| 411 | |||
| 412 | export function resolveLiveThreadBranchUpdate(input: { | ||
| 413 | threadBranch: string | null; | ||
| 414 | gitStatus: VcsStatusResult | null; | ||
| 415 | }): { branch: string | null } | null { | ||
| 416 | if (!input.gitStatus) { | ||
| 417 | return null; | ||
| 418 | } | ||
| 419 | |||
| 420 | if (input.gitStatus.refName === null && input.threadBranch !== null) { | ||
| 421 | return null; | ||
| 422 | } | ||
| 423 | |||
| 424 | if (input.threadBranch === input.gitStatus.refName) { | ||
| 425 | return null; | ||
| 426 | } | ||
| 427 | |||
| 428 | if ( | ||
| 429 | input.threadBranch !== null && | ||
| 430 | input.gitStatus.refName !== null && | ||
| 431 | !isTemporaryWorktreeBranch(input.threadBranch) && | ||
| 432 | isTemporaryWorktreeBranch(input.gitStatus.refName) | ||
| 433 | ) { | ||
| 434 | return null; | ||
| 435 | } | ||
| 436 | |||
| 437 | return { | ||
| 438 | branch: input.gitStatus.refName, | ||
| 439 | }; | ||
| 440 | } | ||
packages/client-runtime/src/state/models.ts created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationMessage, | ||
| 4 | OrchestrationProjectShell, | ||
| 5 | OrchestrationShellSnapshot, | ||
| 6 | OrchestrationThread, | ||
| 7 | OrchestrationThreadShell, | ||
| 8 | ThreadId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | export interface EnvironmentProject extends OrchestrationProjectShell { | ||
| 12 | readonly environmentId: EnvironmentId; | ||
| 13 | } | ||
| 14 | |||
| 15 | export interface EnvironmentThreadShell extends OrchestrationThreadShell { | ||
| 16 | readonly environmentId: EnvironmentId; | ||
| 17 | } | ||
| 18 | |||
| 19 | export type EnvironmentMessage = OrchestrationMessage; | ||
| 20 | |||
| 21 | export interface EnvironmentThread extends OrchestrationThread { | ||
| 22 | readonly environmentId: EnvironmentId; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function scopeProject( | ||
| 26 | environmentId: EnvironmentId, | ||
| 27 | project: OrchestrationProjectShell, | ||
| 28 | ): EnvironmentProject { | ||
| 29 | return { ...project, environmentId }; | ||
| 30 | } | ||
| 31 | |||
| 32 | export function scopeThreadShell( | ||
| 33 | environmentId: EnvironmentId, | ||
| 34 | thread: OrchestrationThreadShell, | ||
| 35 | ): EnvironmentThreadShell { | ||
| 36 | return { ...thread, environmentId }; | ||
| 37 | } | ||
| 38 | |||
| 39 | export function scopeThread( | ||
| 40 | environmentId: EnvironmentId, | ||
| 41 | thread: OrchestrationThread, | ||
| 42 | ): EnvironmentThread { | ||
| 43 | return { ...thread, environmentId }; | ||
| 44 | } | ||
| 45 | |||
| 46 | export function selectEnvironmentThreadShell( | ||
| 47 | snapshot: OrchestrationShellSnapshot | null, | ||
| 48 | environmentId: EnvironmentId, | ||
| 49 | threadId: ThreadId, | ||
| 50 | ): EnvironmentThreadShell | null { | ||
| 51 | const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; | ||
| 52 | return thread ? scopeThreadShell(environmentId, thread) : null; | ||
| 53 | } | ||
packages/client-runtime/src/state/orchestration.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | ||
| 5 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 6 | |||
| 7 | export function createOrchestrationEnvironmentAtoms<R, E>( | ||
| 8 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 9 | ) { | ||
| 10 | return { | ||
| 11 | turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 12 | label: "environment-data:orchestration:turn-diff", | ||
| 13 | tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | ||
| 14 | }), | ||
| 15 | fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 16 | label: "environment-data:orchestration:full-thread-diff", | ||
| 17 | tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, | ||
| 18 | }), | ||
| 19 | archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 20 | label: "environment-data:orchestration:archived-shell-snapshot", | ||
| 21 | tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, | ||
| 22 | }), | ||
| 23 | }; | ||
| 24 | } | ||
packages/client-runtime/src/state/presentation.ts created+69| ... | @@ -0,0 +1,69 @@ | ||
| 1 | import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts"; | ||
| 6 | import { | ||
| 7 | presentEnvironmentConnection, | ||
| 8 | type EnvironmentPresentation, | ||
| 9 | } from "../connection/presentation.ts"; | ||
| 10 | import type { EnvironmentCatalogState } from "./connections.ts"; | ||
| 11 | |||
| 12 | function mapsEqual<K, V>(left: ReadonlyMap<K, V>, right: ReadonlyMap<K, V>): boolean { | ||
| 13 | if (left.size !== right.size) { | ||
| 14 | return false; | ||
| 15 | } | ||
| 16 | for (const [key, value] of left) { | ||
| 17 | if (right.get(key) !== value) { | ||
| 18 | return false; | ||
| 19 | } | ||
| 20 | } | ||
| 21 | return true; | ||
| 22 | } | ||
| 23 | |||
| 24 | export function createEnvironmentPresentationAtoms<E>(input: { | ||
| 25 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; | ||
| 26 | readonly stateAtom: ( | ||
| 27 | environmentId: EnvironmentId, | ||
| 28 | ) => Atom.Atom<AsyncResult.AsyncResult<SupervisorConnectionState, E>>; | ||
| 29 | readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>; | ||
| 30 | }) { | ||
| 31 | const presentationAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 32 | Atom.make((get) => { | ||
| 33 | const entry = get(input.catalogValueAtom).entries.get(environmentId); | ||
| 34 | if (entry === undefined) { | ||
| 35 | return null; | ||
| 36 | } | ||
| 37 | const state = Option.getOrElse( | ||
| 38 | AsyncResult.value(get(input.stateAtom(environmentId))), | ||
| 39 | () => AVAILABLE_CONNECTION_STATE, | ||
| 40 | ); | ||
| 41 | return { | ||
| 42 | entry, | ||
| 43 | connection: presentEnvironmentConnection(state), | ||
| 44 | serverConfig: get(input.configValueAtom(environmentId)), | ||
| 45 | } satisfies EnvironmentPresentation; | ||
| 46 | }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)), | ||
| 47 | ); | ||
| 48 | |||
| 49 | let previous: ReadonlyMap<EnvironmentId, EnvironmentPresentation> = new Map(); | ||
| 50 | const presentationsAtom = Atom.make((get) => { | ||
| 51 | const next = new Map<EnvironmentId, EnvironmentPresentation>(); | ||
| 52 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | ||
| 53 | const presentation = get(presentationAtom(environmentId)); | ||
| 54 | if (presentation !== null) { | ||
| 55 | next.set(environmentId, presentation); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | if (mapsEqual(previous, next)) { | ||
| 59 | return previous; | ||
| 60 | } | ||
| 61 | previous = next; | ||
| 62 | return previous; | ||
| 63 | }).pipe(Atom.withLabel("environment-presentations")); | ||
| 64 | |||
| 65 | return { | ||
| 66 | presentationAtom, | ||
| 67 | presentationsAtom, | ||
| 68 | }; | ||
| 69 | } | ||
packages/client-runtime/src/state/preview.ts created+103| ... | @@ -0,0 +1,103 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 5 | import { | ||
| 6 | createAtomCommandScheduler, | ||
| 7 | createEnvironmentRpcCommand, | ||
| 8 | createEnvironmentRpcQueryAtomFamily, | ||
| 9 | createEnvironmentRpcSubscriptionAtomFamily, | ||
| 10 | } from "./runtime.ts"; | ||
| 11 | |||
| 12 | export function createPreviewEnvironmentAtoms<R, E>( | ||
| 13 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 14 | ) { | ||
| 15 | const lifecycleScheduler = createAtomCommandScheduler(); | ||
| 16 | const statusScheduler = createAtomCommandScheduler(); | ||
| 17 | const automationScheduler = createAtomCommandScheduler(); | ||
| 18 | const lifecycleConcurrency = { | ||
| 19 | mode: "serial" as const, | ||
| 20 | key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => | ||
| 21 | JSON.stringify([environmentId, input.threadId]), | ||
| 22 | }; | ||
| 23 | return { | ||
| 24 | list: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 25 | label: "environment-data:preview:list", | ||
| 26 | tag: WS_METHODS.previewList, | ||
| 27 | staleTimeMs: 5_000, | ||
| 28 | }), | ||
| 29 | events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | ||
| 30 | label: "environment-data:preview:events", | ||
| 31 | tag: WS_METHODS.subscribePreviewEvents, | ||
| 32 | }), | ||
| 33 | discoveredServers: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | ||
| 34 | label: "environment-data:preview:discovered-servers", | ||
| 35 | tag: WS_METHODS.subscribeDiscoveredLocalServers, | ||
| 36 | }), | ||
| 37 | automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | ||
| 38 | label: "environment-data:preview:automation-requests", | ||
| 39 | tag: WS_METHODS.previewAutomationConnect, | ||
| 40 | }), | ||
| 41 | open: createEnvironmentRpcCommand(runtime, { | ||
| 42 | label: "environment-data:preview:open", | ||
| 43 | tag: WS_METHODS.previewOpen, | ||
| 44 | scheduler: lifecycleScheduler, | ||
| 45 | concurrency: lifecycleConcurrency, | ||
| 46 | }), | ||
| 47 | navigate: createEnvironmentRpcCommand(runtime, { | ||
| 48 | label: "environment-data:preview:navigate", | ||
| 49 | tag: WS_METHODS.previewNavigate, | ||
| 50 | scheduler: lifecycleScheduler, | ||
| 51 | concurrency: lifecycleConcurrency, | ||
| 52 | }), | ||
| 53 | refresh: createEnvironmentRpcCommand(runtime, { | ||
| 54 | label: "environment-data:preview:refresh", | ||
| 55 | tag: WS_METHODS.previewRefresh, | ||
| 56 | scheduler: lifecycleScheduler, | ||
| 57 | concurrency: lifecycleConcurrency, | ||
| 58 | }), | ||
| 59 | close: createEnvironmentRpcCommand(runtime, { | ||
| 60 | label: "environment-data:preview:close", | ||
| 61 | tag: WS_METHODS.previewClose, | ||
| 62 | scheduler: lifecycleScheduler, | ||
| 63 | concurrency: lifecycleConcurrency, | ||
| 64 | }), | ||
| 65 | reportStatus: createEnvironmentRpcCommand(runtime, { | ||
| 66 | label: "environment-data:preview:report-status", | ||
| 67 | tag: WS_METHODS.previewReportStatus, | ||
| 68 | scheduler: statusScheduler, | ||
| 69 | concurrency: { | ||
| 70 | mode: "latest", | ||
| 71 | key: ({ environmentId, input }) => | ||
| 72 | JSON.stringify([environmentId, input.threadId, input.tabId]), | ||
| 73 | }, | ||
| 74 | }), | ||
| 75 | respondToAutomation: createEnvironmentRpcCommand(runtime, { | ||
| 76 | label: "environment-data:preview:automation-respond", | ||
| 77 | tag: WS_METHODS.previewAutomationRespond, | ||
| 78 | scheduler: automationScheduler, | ||
| 79 | concurrency: { | ||
| 80 | mode: "singleFlight", | ||
| 81 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.requestId]), | ||
| 82 | }, | ||
| 83 | }), | ||
| 84 | reportAutomationOwner: createEnvironmentRpcCommand(runtime, { | ||
| 85 | label: "environment-data:preview:automation-report-owner", | ||
| 86 | tag: WS_METHODS.previewAutomationReportOwner, | ||
| 87 | scheduler: automationScheduler, | ||
| 88 | concurrency: { | ||
| 89 | mode: "serial", | ||
| 90 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), | ||
| 91 | }, | ||
| 92 | }), | ||
| 93 | clearAutomationOwner: createEnvironmentRpcCommand(runtime, { | ||
| 94 | label: "environment-data:preview:automation-clear-owner", | ||
| 95 | tag: WS_METHODS.previewAutomationClearOwner, | ||
| 96 | scheduler: automationScheduler, | ||
| 97 | concurrency: { | ||
| 98 | mode: "serial", | ||
| 99 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), | ||
| 100 | }, | ||
| 101 | }), | ||
| 102 | }; | ||
| 103 | } | ||
packages/client-runtime/src/state/projectCommands.ts created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | createAtomCommandScheduler, | ||
| 7 | createEnvironmentCommand, | ||
| 8 | createEnvironmentRpcCommand, | ||
| 9 | createEnvironmentRpcQueryAtomFamily, | ||
| 10 | } from "./runtime.ts"; | ||
| 11 | import { | ||
| 12 | type CreateProjectInput, | ||
| 13 | type DeleteProjectInput, | ||
| 14 | type UpdateProjectInput, | ||
| 15 | createProject, | ||
| 16 | deleteProject, | ||
| 17 | updateProject, | ||
| 18 | } from "../operations/commands.ts"; | ||
| 19 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 20 | |||
| 21 | export type { | ||
| 22 | CreateProjectInput, | ||
| 23 | DeleteProjectInput, | ||
| 24 | UpdateProjectInput, | ||
| 25 | } from "../operations/commands.ts"; | ||
| 26 | |||
| 27 | export interface OptimisticProjectFile { | ||
| 28 | readonly data: ProjectReadFileResult; | ||
| 29 | readonly confirmedAgainst: object | null | undefined; | ||
| 30 | } | ||
| 31 | |||
| 32 | export interface OptimisticProjectFileTarget { | ||
| 33 | readonly environmentId: EnvironmentId; | ||
| 34 | readonly cwd: string; | ||
| 35 | readonly relativePath: string; | ||
| 36 | } | ||
| 37 | |||
| 38 | function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { | ||
| 39 | return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); | ||
| 40 | } | ||
| 41 | |||
| 42 | export function createProjectEnvironmentAtoms<R, E>( | ||
| 43 | runtime: Atom.AtomRuntime<EnvironmentRegistry | Crypto.Crypto | R, E>, | ||
| 44 | ) { | ||
| 45 | const projectScheduler = createAtomCommandScheduler(); | ||
| 46 | const fileScheduler = createAtomCommandScheduler(); | ||
| 47 | const optimisticFileFamily = Atom.family((key: string) => | ||
| 48 | Atom.make<OptimisticProjectFile | null>(null).pipe( | ||
| 49 | Atom.withLabel(`environment-data:projects:optimistic-file:${key}`), | ||
| 50 | ), | ||
| 51 | ); | ||
| 52 | const projectConcurrency = { | ||
| 53 | mode: "serial" as const, | ||
| 54 | key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) => | ||
| 55 | JSON.stringify([environmentId, input.projectId]), | ||
| 56 | }; | ||
| 57 | return { | ||
| 58 | searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 59 | label: "environment-data:projects:search-entries", | ||
| 60 | tag: WS_METHODS.projectsSearchEntries, | ||
| 61 | staleTimeMs: 15_000, | ||
| 62 | }), | ||
| 63 | listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 64 | label: "environment-data:projects:list-entries", | ||
| 65 | tag: WS_METHODS.projectsListEntries, | ||
| 66 | staleTimeMs: 30_000, | ||
| 67 | idleTtlMs: 5 * 60_000, | ||
| 68 | }), | ||
| 69 | readFile: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 70 | label: "environment-data:projects:read-file", | ||
| 71 | tag: WS_METHODS.projectsReadFile, | ||
| 72 | staleTimeMs: 30_000, | ||
| 73 | idleTtlMs: 5 * 60_000, | ||
| 74 | }), | ||
| 75 | optimisticFile: (target: OptimisticProjectFileTarget) => | ||
| 76 | optimisticFileFamily(optimisticProjectFileKey(target)), | ||
| 77 | create: createEnvironmentCommand(runtime, { | ||
| 78 | label: "environment-data:commands:project:create", | ||
| 79 | execute: (input: CreateProjectInput) => createProject(input), | ||
| 80 | scheduler: projectScheduler, | ||
| 81 | concurrency: projectConcurrency, | ||
| 82 | }), | ||
| 83 | update: createEnvironmentCommand(runtime, { | ||
| 84 | label: "environment-data:commands:project:update", | ||
| 85 | execute: (input: UpdateProjectInput) => updateProject(input), | ||
| 86 | scheduler: projectScheduler, | ||
| 87 | concurrency: projectConcurrency, | ||
| 88 | }), | ||
| 89 | delete: createEnvironmentCommand(runtime, { | ||
| 90 | label: "environment-data:commands:project:delete", | ||
| 91 | execute: (input: DeleteProjectInput) => deleteProject(input), | ||
| 92 | scheduler: projectScheduler, | ||
| 93 | concurrency: projectConcurrency, | ||
| 94 | }), | ||
| 95 | writeFile: createEnvironmentRpcCommand(runtime, { | ||
| 96 | label: "environment-data:projects:write-file", | ||
| 97 | tag: WS_METHODS.projectsWriteFile, | ||
| 98 | scheduler: fileScheduler, | ||
| 99 | concurrency: { | ||
| 100 | mode: "serial", | ||
| 101 | key: ({ environmentId, input }) => | ||
| 102 | JSON.stringify([environmentId, input.cwd, input.relativePath]), | ||
| 103 | }, | ||
| 104 | }), | ||
| 105 | }; | ||
| 106 | } | ||
packages/client-runtime/src/state/projectEntities.ts created+105| ... | @@ -0,0 +1,105 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationProjectShell, | ||
| 4 | OrchestrationShellSnapshot, | ||
| 5 | ProjectId, | ||
| 6 | ScopedProjectRef, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { Atom } from "effect/unstable/reactivity"; | ||
| 9 | |||
| 10 | import type { EnvironmentProject } from "./models.ts"; | ||
| 11 | import { scopeProject } from "./models.ts"; | ||
| 12 | import type { EnvironmentCatalogState } from "./connections.ts"; | ||
| 13 | import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; | ||
| 14 | |||
| 15 | const EMPTY_PROJECTS: ReadonlyArray<OrchestrationProjectShell> = Object.freeze([]); | ||
| 16 | const EMPTY_PROJECT_INDEX: ReadonlyMap<ProjectId, OrchestrationProjectShell> = new Map(); | ||
| 17 | |||
| 18 | export function createEnvironmentProjectAtoms(input: { | ||
| 19 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; | ||
| 20 | readonly snapshotAtom: ( | ||
| 21 | environmentId: EnvironmentId, | ||
| 22 | ) => Atom.Atom<OrchestrationShellSnapshot | null>; | ||
| 23 | }) { | ||
| 24 | const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 25 | Atom.make( | ||
| 26 | (get): ReadonlyArray<OrchestrationProjectShell> => | ||
| 27 | get(input.snapshotAtom(environmentId))?.projects ?? EMPTY_PROJECTS, | ||
| 28 | ).pipe(Atom.withLabel(`environment-projects:${environmentId}`)), | ||
| 29 | ); | ||
| 30 | |||
| 31 | const environmentProjectIndexAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 32 | Atom.make((get): ReadonlyMap<ProjectId, OrchestrationProjectShell> => { | ||
| 33 | const projects = get(environmentProjectsAtom(environmentId)); | ||
| 34 | if (projects.length === 0) { | ||
| 35 | return EMPTY_PROJECT_INDEX; | ||
| 36 | } | ||
| 37 | return new Map(projects.map((project) => [project.id, project] as const)); | ||
| 38 | }).pipe(Atom.withLabel(`environment-project-index:${environmentId}`)), | ||
| 39 | ); | ||
| 40 | |||
| 41 | const environmentProjectRefsAtom = Atom.family((environmentId: EnvironmentId) => { | ||
| 42 | let previous: ReadonlyArray<ScopedProjectRef> = []; | ||
| 43 | return Atom.make((get) => { | ||
| 44 | const next = get(environmentProjectsAtom(environmentId)).map((project) => ({ | ||
| 45 | environmentId, | ||
| 46 | projectId: project.id, | ||
| 47 | })); | ||
| 48 | if (projectRefsEqual(previous, next)) { | ||
| 49 | return previous; | ||
| 50 | } | ||
| 51 | previous = next; | ||
| 52 | return next; | ||
| 53 | }).pipe(Atom.withLabel(`environment-project-refs:${environmentId}`)); | ||
| 54 | }); | ||
| 55 | |||
| 56 | const projectAtomFamily = Atom.family((key: string) => { | ||
| 57 | const ref = parseProjectKey(key); | ||
| 58 | let previousSource: OrchestrationProjectShell | null = null; | ||
| 59 | let previousValue: EnvironmentProject | null = null; | ||
| 60 | return Atom.make((get) => { | ||
| 61 | const source = get(environmentProjectIndexAtom(ref.environmentId)).get(ref.projectId) ?? null; | ||
| 62 | if (source === previousSource) { | ||
| 63 | return previousValue; | ||
| 64 | } | ||
| 65 | previousSource = source; | ||
| 66 | previousValue = source === null ? null : scopeProject(ref.environmentId, source); | ||
| 67 | return previousValue; | ||
| 68 | }).pipe(Atom.withLabel(`environment-project:${key}`)); | ||
| 69 | }); | ||
| 70 | |||
| 71 | let previousProjectRefs: ReadonlyArray<ScopedProjectRef> = []; | ||
| 72 | const projectRefsAtom = Atom.make((get) => { | ||
| 73 | const refs: ScopedProjectRef[] = []; | ||
| 74 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | ||
| 75 | refs.push(...get(environmentProjectRefsAtom(environmentId))); | ||
| 76 | } | ||
| 77 | if (projectRefsEqual(previousProjectRefs, refs)) { | ||
| 78 | return previousProjectRefs; | ||
| 79 | } | ||
| 80 | previousProjectRefs = refs; | ||
| 81 | return refs; | ||
| 82 | }).pipe(Atom.withLabel("environment-project-refs")); | ||
| 83 | |||
| 84 | let previousProjects: ReadonlyArray<EnvironmentProject> = []; | ||
| 85 | const projectsAtom = Atom.make((get) => { | ||
| 86 | const next = get(projectRefsAtom).flatMap((ref) => { | ||
| 87 | const project = get(projectAtomFamily(projectKey(ref))); | ||
| 88 | return project === null ? [] : [project]; | ||
| 89 | }); | ||
| 90 | if (arrayElementsEqual(previousProjects, next)) { | ||
| 91 | return previousProjects; | ||
| 92 | } | ||
| 93 | previousProjects = next; | ||
| 94 | return previousProjects; | ||
| 95 | }).pipe(Atom.withLabel("environment-project-list")); | ||
| 96 | |||
| 97 | return { | ||
| 98 | environmentProjectsAtom, | ||
| 99 | environmentProjectIndexAtom, | ||
| 100 | environmentProjectRefsAtom, | ||
| 101 | projectRefsAtom, | ||
| 102 | projectsAtom, | ||
| 103 | projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)), | ||
| 104 | }; | ||
| 105 | } | ||
packages/client-runtime/src/state/projects.ts created+224| ... | @@ -0,0 +1,224 @@ | ||
| 1 | import { | ||
| 2 | isExplicitRelativePath, | ||
| 3 | isUncPath, | ||
| 4 | isWindowsAbsolutePath, | ||
| 5 | isWindowsDrivePath, | ||
| 6 | } from "@t3tools/shared/path"; | ||
| 7 | |||
| 8 | const isWindowsPlatform = (platform: string): boolean => { | ||
| 9 | return /^win(dows)?/i.test(platform); | ||
| 10 | }; | ||
| 11 | |||
| 12 | function isRootPath(value: string): boolean { | ||
| 13 | return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); | ||
| 14 | } | ||
| 15 | |||
| 16 | function getAbsolutePathKind(value: string): "unix" | "windows" | null { | ||
| 17 | if (isWindowsDrivePath(value) || isUncPath(value)) { | ||
| 18 | return "windows"; | ||
| 19 | } | ||
| 20 | if (value.startsWith("/")) { | ||
| 21 | return "unix"; | ||
| 22 | } | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | |||
| 26 | function trimTrailingPathSeparators(value: string): string { | ||
| 27 | if (value.length === 0 || isRootPath(value)) { | ||
| 28 | return value; | ||
| 29 | } | ||
| 30 | const trimmed = | ||
| 31 | getAbsolutePathKind(value) === "unix" | ||
| 32 | ? value.replace(/\/+$/g, "") | ||
| 33 | : value.replace(/[\\/]+$/g, ""); | ||
| 34 | if (trimmed.length === 0) { | ||
| 35 | return value; | ||
| 36 | } | ||
| 37 | return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; | ||
| 38 | } | ||
| 39 | |||
| 40 | function preferredPathSeparator(value: string): "/" | "\\" { | ||
| 41 | const absolutePathKind = getAbsolutePathKind(value); | ||
| 42 | if (absolutePathKind === "windows") return "\\"; | ||
| 43 | if (absolutePathKind === "unix") return "/"; | ||
| 44 | return value.includes("\\") ? "\\" : "/"; | ||
| 45 | } | ||
| 46 | |||
| 47 | export function hasTrailingPathSeparator(value: string): boolean { | ||
| 48 | return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value); | ||
| 49 | } | ||
| 50 | |||
| 51 | export { isExplicitRelativePath as isExplicitRelativeProjectPath }; | ||
| 52 | |||
| 53 | function splitPathSegments(value: string, separator: "/" | "\\"): string[] { | ||
| 54 | return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean); | ||
| 55 | } | ||
| 56 | |||
| 57 | function getLastPathSeparatorIndex(value: string): number { | ||
| 58 | if (getAbsolutePathKind(value) === "unix") { | ||
| 59 | return value.lastIndexOf("/"); | ||
| 60 | } | ||
| 61 | return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); | ||
| 62 | } | ||
| 63 | |||
| 64 | function splitAbsolutePath(value: string): { | ||
| 65 | root: string; | ||
| 66 | separator: "/" | "\\"; | ||
| 67 | segments: string[]; | ||
| 68 | } | null { | ||
| 69 | if (isWindowsDrivePath(value)) { | ||
| 70 | const root = `${value.slice(0, 2)}\\`; | ||
| 71 | const segments = splitPathSegments(value.slice(root.length), "\\"); | ||
| 72 | return { root, separator: "\\", segments }; | ||
| 73 | } | ||
| 74 | if (isUncPath(value)) { | ||
| 75 | const segments = splitPathSegments(value, "\\"); | ||
| 76 | const [server, share, ...rest] = segments; | ||
| 77 | if (!server || !share) return null; | ||
| 78 | return { | ||
| 79 | root: `\\\\${server}\\${share}\\`, | ||
| 80 | separator: "\\", | ||
| 81 | segments: rest, | ||
| 82 | }; | ||
| 83 | } | ||
| 84 | if (value.startsWith("/")) { | ||
| 85 | return { | ||
| 86 | root: "/", | ||
| 87 | separator: "/", | ||
| 88 | segments: splitPathSegments(value.slice(1), "/"), | ||
| 89 | }; | ||
| 90 | } | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | |||
| 94 | export function isFilesystemBrowseQuery(value: string, platform = ""): boolean { | ||
| 95 | const allowWindowsPaths = isWindowsPlatform(platform); | ||
| 96 | return ( | ||
| 97 | value.startsWith("./") || | ||
| 98 | value.startsWith("../") || | ||
| 99 | value.startsWith(".\\") || | ||
| 100 | value.startsWith("..\\") || | ||
| 101 | value.startsWith("/") || | ||
| 102 | value.startsWith("~/") || | ||
| 103 | (allowWindowsPaths && isWindowsAbsolutePath(value)) | ||
| 104 | ); | ||
| 105 | } | ||
| 106 | |||
| 107 | export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { | ||
| 108 | return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); | ||
| 109 | } | ||
| 110 | |||
| 111 | export function normalizeProjectPathForDispatch(value: string): string { | ||
| 112 | return trimTrailingPathSeparators(value.trim()); | ||
| 113 | } | ||
| 114 | |||
| 115 | export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { | ||
| 116 | const trimmedValue = value.trim(); | ||
| 117 | if (!isExplicitRelativePath(trimmedValue) || !cwd) { | ||
| 118 | return normalizeProjectPathForDispatch(trimmedValue); | ||
| 119 | } | ||
| 120 | |||
| 121 | const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); | ||
| 122 | if (!absoluteBase) { | ||
| 123 | return normalizeProjectPathForDispatch(trimmedValue); | ||
| 124 | } | ||
| 125 | |||
| 126 | const nextSegments = [...absoluteBase.segments]; | ||
| 127 | for (const segment of trimmedValue.split(/[\\/]+/)) { | ||
| 128 | if (segment.length === 0 || segment === ".") continue; | ||
| 129 | if (segment === "..") { | ||
| 130 | nextSegments.pop(); | ||
| 131 | continue; | ||
| 132 | } | ||
| 133 | nextSegments.push(segment); | ||
| 134 | } | ||
| 135 | |||
| 136 | const joinedPath = nextSegments.join(absoluteBase.separator); | ||
| 137 | return normalizeProjectPathForDispatch( | ||
| 138 | joinedPath.length === 0 ? absoluteBase.root : `${absoluteBase.root}${joinedPath}`, | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | |||
| 142 | export function normalizeProjectPathForComparison(value: string): string { | ||
| 143 | const normalized = normalizeProjectPathForDispatch(value); | ||
| 144 | if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { | ||
| 145 | return normalized.replaceAll("/", "\\").toLowerCase(); | ||
| 146 | } | ||
| 147 | return normalized; | ||
| 148 | } | ||
| 149 | |||
| 150 | export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>( | ||
| 151 | projects: ReadonlyArray<T>, | ||
| 152 | candidatePath: string, | ||
| 153 | ): T | undefined { | ||
| 154 | const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); | ||
| 155 | if (normalizedCandidate.length === 0) { | ||
| 156 | return undefined; | ||
| 157 | } | ||
| 158 | return projects.find((project) => { | ||
| 159 | const cwd = project.workspaceRoot ?? project.cwd; | ||
| 160 | return cwd ? normalizeProjectPathForComparison(cwd) === normalizedCandidate : false; | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | |||
| 164 | export function inferProjectTitleFromPath(value: string): string { | ||
| 165 | const normalized = normalizeProjectPathForDispatch(value); | ||
| 166 | const absolutePath = splitAbsolutePath(normalized); | ||
| 167 | if (absolutePath) { | ||
| 168 | return absolutePath.segments.findLast(Boolean) ?? normalized; | ||
| 169 | } | ||
| 170 | const segments = normalized.split(/[/\\]/); | ||
| 171 | return segments.findLast(Boolean) ?? normalized; | ||
| 172 | } | ||
| 173 | |||
| 174 | export function appendBrowsePathSegment(currentPath: string, segment: string): string { | ||
| 175 | const separator = preferredPathSeparator(currentPath); | ||
| 176 | return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; | ||
| 177 | } | ||
| 178 | |||
| 179 | export function getBrowseLeafPathSegment(currentPath: string): string { | ||
| 180 | const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); | ||
| 181 | return currentPath.slice(lastSeparatorIndex + 1); | ||
| 182 | } | ||
| 183 | |||
| 184 | export function getBrowseDirectoryPath(currentPath: string): string { | ||
| 185 | if (hasTrailingPathSeparator(currentPath)) { | ||
| 186 | return currentPath; | ||
| 187 | } | ||
| 188 | const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); | ||
| 189 | return lastSeparatorIndex < 0 ? currentPath : currentPath.slice(0, lastSeparatorIndex + 1); | ||
| 190 | } | ||
| 191 | |||
| 192 | export function ensureBrowseDirectoryPath(currentPath: string): string { | ||
| 193 | const trimmed = currentPath.trim(); | ||
| 194 | if (trimmed.length === 0 || hasTrailingPathSeparator(trimmed)) { | ||
| 195 | return trimmed; | ||
| 196 | } | ||
| 197 | return `${trimmed}${preferredPathSeparator(trimmed)}`; | ||
| 198 | } | ||
| 199 | |||
| 200 | export function getBrowseParentPath(currentPath: string): string | null { | ||
| 201 | const trimmed = trimTrailingPathSeparators(currentPath); | ||
| 202 | const absolutePath = splitAbsolutePath(trimmed); | ||
| 203 | if (absolutePath) { | ||
| 204 | if (absolutePath.segments.length === 0) return null; | ||
| 205 | if (absolutePath.segments.length === 1) return absolutePath.root; | ||
| 206 | const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); | ||
| 207 | return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; | ||
| 208 | } | ||
| 209 | |||
| 210 | const separator = preferredPathSeparator(currentPath); | ||
| 211 | const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed); | ||
| 212 | if (lastSeparatorIndex < 0) return null; | ||
| 213 | if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { | ||
| 214 | return `${trimmed.slice(0, 2)}${separator}`; | ||
| 215 | } | ||
| 216 | return trimmed.slice(0, lastSeparatorIndex + 1); | ||
| 217 | } | ||
| 218 | |||
| 219 | export function canNavigateUp(currentPath: string): boolean { | ||
| 220 | return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; | ||
| 221 | } | ||
| 222 | |||
| 223 | export * from "./projectCommands.ts"; | ||
| 224 | export * from "./projectEntities.ts"; | ||
packages/client-runtime/src/state/relayDiscovery.ts created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import * as Stream from "effect/Stream"; | ||
| 4 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, | ||
| 9 | RelayEnvironmentDiscovery, | ||
| 10 | } from "../relay/discovery.ts"; | ||
| 11 | import { createRuntimeCommand } from "./runtime.ts"; | ||
| 12 | |||
| 13 | export function createRelayEnvironmentDiscoveryAtoms<R, E>( | ||
| 14 | runtime: Atom.AtomRuntime<RelayEnvironmentDiscovery | R, E>, | ||
| 15 | ) { | ||
| 16 | const stateAtom = runtime.atom( | ||
| 17 | Stream.unwrap( | ||
| 18 | RelayEnvironmentDiscovery.pipe( | ||
| 19 | Effect.map((discovery) => SubscriptionRef.changes(discovery.state)), | ||
| 20 | ), | ||
| 21 | ), | ||
| 22 | { initialValue: EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, | ||
| 23 | ); | ||
| 24 | const stateValueAtom = Atom.make((get) => | ||
| 25 | Option.getOrElse( | ||
| 26 | AsyncResult.value(get(stateAtom)), | ||
| 27 | () => EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, | ||
| 28 | ), | ||
| 29 | ).pipe(Atom.withLabel("relay-environment-discovery-value")); | ||
| 30 | const refresh = createRuntimeCommand(runtime, { | ||
| 31 | label: "relay-environment-discovery:refresh", | ||
| 32 | concurrency: { mode: "singleFlight", key: () => "refresh" }, | ||
| 33 | execute: (_input: void) => | ||
| 34 | RelayEnvironmentDiscovery.pipe(Effect.flatMap((discovery) => discovery.refresh)), | ||
| 35 | }); | ||
| 36 | |||
| 37 | return { | ||
| 38 | stateAtom, | ||
| 39 | stateValueAtom, | ||
| 40 | refresh, | ||
| 41 | }; | ||
| 42 | } | ||
packages/client-runtime/src/state/review.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | ||
| 5 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 6 | |||
| 7 | export function createReviewEnvironmentAtoms<R, E>( | ||
| 8 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 9 | ) { | ||
| 10 | return { | ||
| 11 | diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 12 | label: "environment-data:review:diff-preview", | ||
| 13 | tag: WS_METHODS.reviewGetDiffPreview, | ||
| 14 | staleTimeMs: 5_000, | ||
| 15 | }), | ||
| 16 | }; | ||
| 17 | } | ||
packages/client-runtime/src/state/runtime.test.ts created+451| ... | @@ -0,0 +1,451 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Exit from "effect/Exit"; | ||
| 6 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as Latch from "effect/Latch"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | environmentRpcKey, | ||
| 14 | createAtomCommandScheduler, | ||
| 15 | createRuntimeCommand, | ||
| 16 | executeAtomCommand, | ||
| 17 | executeAtomQuery, | ||
| 18 | isAtomCommandInterrupted, | ||
| 19 | mapAtomCommandResult, | ||
| 20 | runAtomCommand, | ||
| 21 | settleAsyncResult, | ||
| 22 | settlePromise, | ||
| 23 | squashAtomCommandFailure, | ||
| 24 | } from "./runtime.ts"; | ||
| 25 | |||
| 26 | describe("settleAsyncResult", () => { | ||
| 27 | it("preserves successful values and typed failures", async () => { | ||
| 28 | const success = await settleAsyncResult(() => Promise.resolve(Exit.succeed("done"))); | ||
| 29 | expect(AsyncResult.isSuccess(success)).toBe(true); | ||
| 30 | if (AsyncResult.isSuccess(success)) { | ||
| 31 | expect(success.value).toBe("done"); | ||
| 32 | } | ||
| 33 | |||
| 34 | const expectedFailure = new Error("request failed"); | ||
| 35 | const failure = await settleAsyncResult(() => Promise.resolve(Exit.fail(expectedFailure))); | ||
| 36 | expect(AsyncResult.isFailure(failure)).toBe(true); | ||
| 37 | if (AsyncResult.isFailure(failure)) { | ||
| 38 | expect(Cause.hasDies(failure.cause)).toBe(false); | ||
| 39 | expect(Cause.squash(failure.cause)).toBe(expectedFailure); | ||
| 40 | } | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("encodes thrown and rejected promises as defects", async () => { | ||
| 44 | const thrownDefect = new Error("thrown defect"); | ||
| 45 | const thrown = await settleAsyncResult<void, never>(() => { | ||
| 46 | throw thrownDefect; | ||
| 47 | }); | ||
| 48 | expect(AsyncResult.isFailure(thrown)).toBe(true); | ||
| 49 | if (AsyncResult.isFailure(thrown)) { | ||
| 50 | expect(Cause.hasDies(thrown.cause)).toBe(true); | ||
| 51 | expect(Cause.squash(thrown.cause)).toBe(thrownDefect); | ||
| 52 | } | ||
| 53 | |||
| 54 | const rejectedDefect = new Error("rejected defect"); | ||
| 55 | const rejected = await settleAsyncResult<void, never>(() => Promise.reject(rejectedDefect)); | ||
| 56 | expect(AsyncResult.isFailure(rejected)).toBe(true); | ||
| 57 | if (AsyncResult.isFailure(rejected)) { | ||
| 58 | expect(Cause.hasDies(rejected.cause)).toBe(true); | ||
| 59 | expect(Cause.squash(rejected.cause)).toBe(rejectedDefect); | ||
| 60 | } | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | describe("atom command result helpers", () => { | ||
| 65 | it("maps successful command values", () => { | ||
| 66 | const result = mapAtomCommandResult(AsyncResult.success(2), (value) => value * 3); | ||
| 67 | |||
| 68 | expect(result._tag).toBe("Success"); | ||
| 69 | if (result._tag === "Success") { | ||
| 70 | expect(result.value).toBe(6); | ||
| 71 | } | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("preserves failures while mapping", () => { | ||
| 75 | const result = mapAtomCommandResult( | ||
| 76 | AsyncResult.failure<number, string>(Cause.fail("nope")), | ||
| 77 | (value) => value * 3, | ||
| 78 | ); | ||
| 79 | |||
| 80 | expect(result._tag).toBe("Failure"); | ||
| 81 | if (result._tag === "Failure") { | ||
| 82 | expect(Cause.squash(result.cause)).toBe("nope"); | ||
| 83 | } | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("distinguishes interruption from other failures", () => { | ||
| 87 | const interrupted = AsyncResult.failure(Cause.interrupt(1)); | ||
| 88 | const failed = AsyncResult.failure(Cause.fail("nope")); | ||
| 89 | |||
| 90 | expect(isAtomCommandInterrupted(interrupted)).toBe(true); | ||
| 91 | expect(isAtomCommandInterrupted(failed)).toBe(false); | ||
| 92 | expect(squashAtomCommandFailure(failed)).toBe("nope"); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("settles raw promise boundaries as successes or defects", async () => { | ||
| 96 | const success = await settlePromise(() => Promise.resolve("done")); | ||
| 97 | expect(success._tag).toBe("Success"); | ||
| 98 | |||
| 99 | const defect = new Error("raw promise rejected"); | ||
| 100 | const failure = await settlePromise(() => Promise.reject(defect)); | ||
| 101 | expect(failure._tag).toBe("Failure"); | ||
| 102 | if (failure._tag === "Failure") { | ||
| 103 | expect(Cause.hasDies(failure.cause)).toBe(true); | ||
| 104 | expect(Cause.squash(failure.cause)).toBe(defect); | ||
| 105 | } | ||
| 106 | }); | ||
| 107 | |||
| 108 | it("reports expected failures and defects through separate policies", async () => { | ||
| 109 | const warnings: string[] = []; | ||
| 110 | const errors: string[] = []; | ||
| 111 | const reporter = { | ||
| 112 | warn: (message: string) => { | ||
| 113 | warnings.push(message); | ||
| 114 | }, | ||
| 115 | error: (message: string) => { | ||
| 116 | errors.push(message); | ||
| 117 | }, | ||
| 118 | }; | ||
| 119 | |||
| 120 | await executeAtomCommand(() => Promise.resolve(Exit.fail("nope")), { label: "save" }, reporter); | ||
| 121 | await executeAtomCommand( | ||
| 122 | () => Promise.resolve(Exit.fail("ignored")), | ||
| 123 | { label: "quiet save", reportFailure: false }, | ||
| 124 | reporter, | ||
| 125 | ); | ||
| 126 | await executeAtomCommand( | ||
| 127 | () => Promise.reject(new Error("defect")), | ||
| 128 | { label: "quiet save", reportFailure: false }, | ||
| 129 | reporter, | ||
| 130 | ); | ||
| 131 | await executeAtomCommand( | ||
| 132 | () => Promise.resolve(Exit.interrupt(1)), | ||
| 133 | { label: "interrupted" }, | ||
| 134 | reporter, | ||
| 135 | ); | ||
| 136 | |||
| 137 | expect(warnings).toEqual(["[atom-command] save failed"]); | ||
| 138 | expect(errors).toEqual(["[atom-command] quiet save defected"]); | ||
| 139 | }); | ||
| 140 | }); | ||
| 141 | |||
| 142 | describe("environmentRpcKey", () => { | ||
| 143 | it("isolates subscription state by environment and cwd", () => { | ||
| 144 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 145 | const originalTarget = { | ||
| 146 | environmentId, | ||
| 147 | input: { cwd: "/repo/original" }, | ||
| 148 | }; | ||
| 149 | const nextTarget = { | ||
| 150 | environmentId, | ||
| 151 | input: { cwd: "/repo/next" }, | ||
| 152 | }; | ||
| 153 | |||
| 154 | expect(environmentRpcKey(originalTarget)).not.toBe(environmentRpcKey(nextTarget)); | ||
| 155 | expect(environmentRpcKey(originalTarget)).toBe(environmentRpcKey({ ...originalTarget })); | ||
| 156 | expect( | ||
| 157 | environmentRpcKey({ | ||
| 158 | environmentId: EnvironmentId.make("environment-2"), | ||
| 159 | input: originalTarget.input, | ||
| 160 | }), | ||
| 161 | ).not.toBe(environmentRpcKey(originalTarget)); | ||
| 162 | }); | ||
| 163 | }); | ||
| 164 | |||
| 165 | describe("Atom.fn mutation semantics", () => { | ||
| 166 | it.effect("interrupts the previous invocation when the same mutation atom is written again", () => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | const firstLatch = Latch.makeUnsafe(); | ||
| 169 | const secondLatch = Latch.makeUnsafe(); | ||
| 170 | const interrupted: string[] = []; | ||
| 171 | const mutation = Atom.fn((id: "first" | "second") => | ||
| 172 | (id === "first" ? firstLatch : secondLatch).await.pipe( | ||
| 173 | Effect.as(id), | ||
| 174 | Effect.onInterrupt(() => | ||
| 175 | Effect.sync(() => { | ||
| 176 | interrupted.push(id); | ||
| 177 | }), | ||
| 178 | ), | ||
| 179 | ), | ||
| 180 | ); | ||
| 181 | const registry = AtomRegistry.make(); | ||
| 182 | const unmount = registry.mount(mutation); | ||
| 183 | |||
| 184 | registry.set(mutation, "first"); | ||
| 185 | registry.set(mutation, "second"); | ||
| 186 | yield* Effect.yieldNow; | ||
| 187 | |||
| 188 | expect(interrupted).toEqual(["first"]); | ||
| 189 | |||
| 190 | secondLatch.openUnsafe(); | ||
| 191 | expect( | ||
| 192 | yield* AtomRegistry.getResult(registry, mutation, { | ||
| 193 | suspendOnWaiting: true, | ||
| 194 | }), | ||
| 195 | ).toBe("second"); | ||
| 196 | |||
| 197 | unmount(); | ||
| 198 | registry.dispose(); | ||
| 199 | }), | ||
| 200 | ); | ||
| 201 | |||
| 202 | it.effect("keeps stream mutations waiting until the final emitted value", () => | ||
| 203 | Effect.gen(function* () { | ||
| 204 | const completionLatch = Latch.makeUnsafe(); | ||
| 205 | const mutation = Atom.fn(() => | ||
| 206 | Stream.make("progress").pipe( | ||
| 207 | Stream.concat(Stream.fromEffect(completionLatch.await.pipe(Effect.as("done")))), | ||
| 208 | ), | ||
| 209 | ); | ||
| 210 | const registry = AtomRegistry.make(); | ||
| 211 | const unmount = registry.mount(mutation); | ||
| 212 | |||
| 213 | registry.set(mutation, undefined); | ||
| 214 | |||
| 215 | const progress = registry.get(mutation); | ||
| 216 | expect(AsyncResult.isSuccess(progress)).toBe(true); | ||
| 217 | if (AsyncResult.isSuccess(progress)) { | ||
| 218 | expect(progress.value).toBe("progress"); | ||
| 219 | expect(progress.waiting).toBe(true); | ||
| 220 | } | ||
| 221 | |||
| 222 | completionLatch.openUnsafe(); | ||
| 223 | expect( | ||
| 224 | yield* AtomRegistry.getResult(registry, mutation, { | ||
| 225 | suspendOnWaiting: true, | ||
| 226 | }), | ||
| 227 | ).toBe("done"); | ||
| 228 | |||
| 229 | unmount(); | ||
| 230 | registry.dispose(); | ||
| 231 | }), | ||
| 232 | ); | ||
| 233 | |||
| 234 | it.effect( | ||
| 235 | "allows concurrent effects to finish but does not correlate results to individual writes", | ||
| 236 | () => | ||
| 237 | Effect.gen(function* () { | ||
| 238 | const firstLatch = Latch.makeUnsafe(); | ||
| 239 | const secondLatch = Latch.makeUnsafe(); | ||
| 240 | const mutation = Atom.fn<never, "first" | "second", "first" | "second">( | ||
| 241 | (id: "first" | "second") => | ||
| 242 | (id === "first" ? firstLatch : secondLatch).await.pipe(Effect.as(id)), | ||
| 243 | { concurrent: true }, | ||
| 244 | ); | ||
| 245 | const registry = AtomRegistry.make(); | ||
| 246 | const unmount = registry.mount(mutation); | ||
| 247 | |||
| 248 | registry.set(mutation, "first"); | ||
| 249 | const firstResult = yield* AtomRegistry.getResult(registry, mutation, { | ||
| 250 | suspendOnWaiting: true, | ||
| 251 | }).pipe(Effect.forkChild({ startImmediately: true })); | ||
| 252 | registry.set(mutation, "second"); | ||
| 253 | const secondResult = yield* AtomRegistry.getResult(registry, mutation, { | ||
| 254 | suspendOnWaiting: true, | ||
| 255 | }).pipe(Effect.forkChild({ startImmediately: true })); | ||
| 256 | |||
| 257 | secondLatch.openUnsafe(); | ||
| 258 | yield* Effect.yieldNow; | ||
| 259 | |||
| 260 | const stillWaiting = registry.get(mutation); | ||
| 261 | expect(stillWaiting.waiting).toBe(true); | ||
| 262 | |||
| 263 | firstLatch.openUnsafe(); | ||
| 264 | |||
| 265 | expect(yield* Fiber.join(firstResult)).toBe("first"); | ||
| 266 | expect(yield* Fiber.join(secondResult)).toBe("first"); | ||
| 267 | |||
| 268 | unmount(); | ||
| 269 | registry.dispose(); | ||
| 270 | }), | ||
| 271 | ); | ||
| 272 | }); | ||
| 273 | |||
| 274 | describe("executeAtomQuery", () => { | ||
| 275 | it("keeps concurrent query results correlated to their atoms", async () => { | ||
| 276 | const firstLatch = Latch.makeUnsafe(); | ||
| 277 | const secondLatch = Latch.makeUnsafe(); | ||
| 278 | const firstAtom = Atom.make(firstLatch.await.pipe(Effect.as("first"))); | ||
| 279 | const secondAtom = Atom.make(secondLatch.await.pipe(Effect.as("second"))); | ||
| 280 | const registry = AtomRegistry.make(); | ||
| 281 | |||
| 282 | const firstResult = executeAtomQuery(registry, firstAtom); | ||
| 283 | const secondResult = executeAtomQuery(registry, secondAtom); | ||
| 284 | |||
| 285 | secondLatch.openUnsafe(); | ||
| 286 | firstLatch.openUnsafe(); | ||
| 287 | |||
| 288 | const [first, second] = await Promise.all([firstResult, secondResult]); | ||
| 289 | expect(first._tag).toBe("Success"); | ||
| 290 | expect(second._tag).toBe("Success"); | ||
| 291 | if (first._tag === "Success" && second._tag === "Success") { | ||
| 292 | expect(first.value).toBe("first"); | ||
| 293 | expect(second.value).toBe("second"); | ||
| 294 | } | ||
| 295 | |||
| 296 | registry.dispose(); | ||
| 297 | }); | ||
| 298 | }); | ||
| 299 | |||
| 300 | describe("runtime command runner", () => { | ||
| 301 | it("encodes custom command rejections as defects", async () => { | ||
| 302 | const defect = new Error("custom command rejected"); | ||
| 303 | const registry = AtomRegistry.make(); | ||
| 304 | const result = await runAtomCommand( | ||
| 305 | registry, | ||
| 306 | { | ||
| 307 | label: "test.rejected-command", | ||
| 308 | run: () => Promise.reject(defect), | ||
| 309 | }, | ||
| 310 | undefined, | ||
| 311 | { reportDefect: false }, | ||
| 312 | ); | ||
| 313 | |||
| 314 | expect(result._tag).toBe("Failure"); | ||
| 315 | if (result._tag === "Failure") { | ||
| 316 | expect(Cause.hasDies(result.cause)).toBe(true); | ||
| 317 | expect(Cause.squash(result.cause)).toBe(defect); | ||
| 318 | } | ||
| 319 | registry.dispose(); | ||
| 320 | }); | ||
| 321 | |||
| 322 | it("settles generated command scheduler defects from direct callers", async () => { | ||
| 323 | const defect = new Error("invalid command key"); | ||
| 324 | const runtime = Atom.runtime(Layer.empty); | ||
| 325 | const command = createRuntimeCommand(runtime, { | ||
| 326 | label: "test.invalid-key", | ||
| 327 | concurrency: { | ||
| 328 | mode: "serial", | ||
| 329 | key: () => { | ||
| 330 | throw defect; | ||
| 331 | }, | ||
| 332 | }, | ||
| 333 | execute: () => Effect.void, | ||
| 334 | }); | ||
| 335 | const registry = AtomRegistry.make(); | ||
| 336 | |||
| 337 | const result = await command.run(registry, undefined); | ||
| 338 | expect(result._tag).toBe("Failure"); | ||
| 339 | if (result._tag === "Failure") { | ||
| 340 | expect(Cause.hasDies(result.cause)).toBe(true); | ||
| 341 | expect(Cause.squash(result.cause)).toBe(defect); | ||
| 342 | } | ||
| 343 | registry.dispose(); | ||
| 344 | }); | ||
| 345 | |||
| 346 | it("correlates parallel invocation results", async () => { | ||
| 347 | const firstLatch = Latch.makeUnsafe(); | ||
| 348 | const secondLatch = Latch.makeUnsafe(); | ||
| 349 | const runtime = Atom.runtime(Layer.empty); | ||
| 350 | const command = createRuntimeCommand(runtime, { | ||
| 351 | label: "test.parallel", | ||
| 352 | execute: (id: "first" | "second") => | ||
| 353 | (id === "first" ? firstLatch : secondLatch).await.pipe(Effect.as(id)), | ||
| 354 | }); | ||
| 355 | const registry = AtomRegistry.make(); | ||
| 356 | |||
| 357 | const first = command.run(registry, "first"); | ||
| 358 | const second = command.run(registry, "second"); | ||
| 359 | secondLatch.openUnsafe(); | ||
| 360 | firstLatch.openUnsafe(); | ||
| 361 | |||
| 362 | expect(await first).toMatchObject({ _tag: "Success", value: "first", waiting: false }); | ||
| 363 | expect(await second).toMatchObject({ _tag: "Success", value: "second", waiting: false }); | ||
| 364 | registry.dispose(); | ||
| 365 | }); | ||
| 366 | |||
| 367 | it("serializes commands that share a scheduler and lane", async () => { | ||
| 368 | const firstLatch = Latch.makeUnsafe(); | ||
| 369 | const events: string[] = []; | ||
| 370 | const runtime = Atom.runtime(Layer.empty); | ||
| 371 | const scheduler = createAtomCommandScheduler(); | ||
| 372 | const concurrency = { mode: "serial" as const, key: () => "shared" }; | ||
| 373 | const firstCommand = createRuntimeCommand(runtime, { | ||
| 374 | label: "test.first", | ||
| 375 | scheduler, | ||
| 376 | concurrency, | ||
| 377 | execute: () => | ||
| 378 | Effect.sync(() => events.push("first:start")).pipe( | ||
| 379 | Effect.andThen(firstLatch.await), | ||
| 380 | Effect.tap(() => Effect.sync(() => events.push("first:end"))), | ||
| 381 | ), | ||
| 382 | }); | ||
| 383 | const secondCommand = createRuntimeCommand(runtime, { | ||
| 384 | label: "test.second", | ||
| 385 | scheduler, | ||
| 386 | concurrency, | ||
| 387 | execute: () => Effect.sync(() => events.push("second:start")), | ||
| 388 | }); | ||
| 389 | const registry = AtomRegistry.make(); | ||
| 390 | |||
| 391 | const first = firstCommand.run(registry, undefined); | ||
| 392 | const second = secondCommand.run(registry, undefined); | ||
| 393 | await Promise.resolve(); | ||
| 394 | expect(events).toEqual(["first:start"]); | ||
| 395 | |||
| 396 | firstLatch.openUnsafe(); | ||
| 397 | await Promise.all([first, second]); | ||
| 398 | expect(events).toEqual(["first:start", "first:end", "second:start"]); | ||
| 399 | registry.dispose(); | ||
| 400 | }); | ||
| 401 | |||
| 402 | it("deduplicates single-flight commands by key", async () => { | ||
| 403 | const latch = Latch.makeUnsafe(); | ||
| 404 | let executions = 0; | ||
| 405 | const runtime = Atom.runtime(Layer.empty); | ||
| 406 | const command = createRuntimeCommand(runtime, { | ||
| 407 | label: "test.single-flight", | ||
| 408 | concurrency: { mode: "singleFlight", key: (key: string) => key }, | ||
| 409 | execute: () => | ||
| 410 | Effect.sync(() => executions++).pipe(Effect.andThen(latch.await), Effect.as("done")), | ||
| 411 | }); | ||
| 412 | const registry = AtomRegistry.make(); | ||
| 413 | |||
| 414 | const first = command.run(registry, "same"); | ||
| 415 | const second = command.run(registry, "same"); | ||
| 416 | latch.openUnsafe(); | ||
| 417 | |||
| 418 | expect(await first).toMatchObject({ _tag: "Success", value: "done", waiting: false }); | ||
| 419 | expect(await second).toMatchObject({ _tag: "Success", value: "done", waiting: false }); | ||
| 420 | expect(executions).toBe(1); | ||
| 421 | registry.dispose(); | ||
| 422 | }); | ||
| 423 | |||
| 424 | it("coalesces pending latest-value commands without interrupting the active call", async () => { | ||
| 425 | const firstLatch = Latch.makeUnsafe(); | ||
| 426 | const executed: number[] = []; | ||
| 427 | const runtime = Atom.runtime(Layer.empty); | ||
| 428 | const command = createRuntimeCommand(runtime, { | ||
| 429 | label: "test.latest", | ||
| 430 | concurrency: { mode: "latest", key: () => "shared" }, | ||
| 431 | execute: (value: number) => | ||
| 432 | Effect.sync(() => executed.push(value)).pipe( | ||
| 433 | Effect.andThen(value === 1 ? firstLatch.await : Effect.void), | ||
| 434 | Effect.as(value), | ||
| 435 | ), | ||
| 436 | }); | ||
| 437 | const registry = AtomRegistry.make(); | ||
| 438 | |||
| 439 | const first = command.run(registry, 1); | ||
| 440 | await Promise.resolve(); | ||
| 441 | const second = command.run(registry, 2); | ||
| 442 | const third = command.run(registry, 3); | ||
| 443 | firstLatch.openUnsafe(); | ||
| 444 | |||
| 445 | expect(await first).toMatchObject({ _tag: "Success", value: 1, waiting: false }); | ||
| 446 | expect(await second).toMatchObject({ _tag: "Success", value: 3, waiting: false }); | ||
| 447 | expect(await third).toMatchObject({ _tag: "Success", value: 3, waiting: false }); | ||
| 448 | expect(executed).toEqual([1, 3]); | ||
| 449 | registry.dispose(); | ||
| 450 | }); | ||
| 451 | }); | ||
packages/client-runtime/src/state/runtime.ts created+651| ... | @@ -0,0 +1,651 @@ | ||
| 1 | import { EnvironmentId, type EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Result from "effect/Result"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 9 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 10 | |||
| 11 | import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 12 | import { | ||
| 13 | type EnvironmentRpcInput, | ||
| 14 | type EnvironmentRpcStreamFailure, | ||
| 15 | type EnvironmentRpcStreamValue, | ||
| 16 | type EnvironmentStreamCommandRpcTag, | ||
| 17 | type EnvironmentSubscriptionRpcTag, | ||
| 18 | type EnvironmentUnaryRpcTag, | ||
| 19 | request, | ||
| 20 | runStream, | ||
| 21 | subscribe, | ||
| 22 | } from "../rpc/client.ts"; | ||
| 23 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | ||
| 24 | |||
| 25 | interface EnvironmentAtomOptions<Input, A, E, R> { | ||
| 26 | readonly label: string; | ||
| 27 | readonly execute: (input: Input) => Effect.Effect<A, E, R>; | ||
| 28 | readonly scheduler?: AtomCommandScheduler; | ||
| 29 | readonly concurrency?: AtomCommandConcurrency<{ | ||
| 30 | readonly environmentId: EnvironmentIdType; | ||
| 31 | readonly input: Input; | ||
| 32 | }>; | ||
| 33 | } | ||
| 34 | |||
| 35 | interface EnvironmentQueryAtomOptions<Input, A, E, R> extends EnvironmentAtomOptions< | ||
| 36 | Input, | ||
| 37 | A, | ||
| 38 | E, | ||
| 39 | R | ||
| 40 | > { | ||
| 41 | readonly staleTimeMs?: number; | ||
| 42 | readonly idleTtlMs?: number; | ||
| 43 | readonly refreshIntervalMs?: number; | ||
| 44 | } | ||
| 45 | |||
| 46 | interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> { | ||
| 47 | readonly label: string; | ||
| 48 | readonly subscribe: (input: Input) => Stream.Stream<A, E, R>; | ||
| 49 | readonly idleTtlMs?: number; | ||
| 50 | } | ||
| 51 | |||
| 52 | export type SettledAsyncResult<A, E> = AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>; | ||
| 53 | |||
| 54 | export type AtomCommandResult<A, E> = SettledAsyncResult<A, E>; | ||
| 55 | |||
| 56 | export type AtomCommandSuccess<R> = R extends AtomCommandResult<infer A, infer _E> ? A : never; | ||
| 57 | |||
| 58 | export type AtomCommandFailure<R> = R extends AtomCommandResult<infer _A, infer E> ? E : never; | ||
| 59 | |||
| 60 | export interface AtomCommandOptions { | ||
| 61 | readonly label?: string; | ||
| 62 | readonly reportFailure?: boolean; | ||
| 63 | readonly reportDefect?: boolean; | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface AtomCommandReporter { | ||
| 67 | readonly warn: (message: string, cause: Cause.Cause<unknown>) => void; | ||
| 68 | readonly error: (message: string, cause: Cause.Cause<unknown>) => void; | ||
| 69 | } | ||
| 70 | |||
| 71 | export interface AtomCommand<W, A, E> { | ||
| 72 | readonly label: string; | ||
| 73 | readonly run: (registry: AtomRegistry.AtomRegistry, input: W) => Promise<AtomCommandResult<A, E>>; | ||
| 74 | } | ||
| 75 | |||
| 76 | export type AtomCommandConcurrency<W> = | ||
| 77 | /** Every invocation runs independently. */ | ||
| 78 | | { readonly mode: "parallel" } | ||
| 79 | | { | ||
| 80 | /** | ||
| 81 | * `serial` preserves every invocation in FIFO order, `singleFlight` shares an active | ||
| 82 | * invocation, and `latest` coalesces queued invocations to the newest input. | ||
| 83 | */ | ||
| 84 | readonly mode: "serial" | "singleFlight" | "latest"; | ||
| 85 | readonly key: (input: W) => string; | ||
| 86 | }; | ||
| 87 | |||
| 88 | interface AtomCommandSchedulerState { | ||
| 89 | readonly serial: Map<string, Promise<unknown>>; | ||
| 90 | readonly singleFlight: Map<string, Promise<unknown>>; | ||
| 91 | readonly latest: Map<string, AtomCommandLatestLane>; | ||
| 92 | } | ||
| 93 | |||
| 94 | interface AtomCommandLatestBatch { | ||
| 95 | execute: () => Promise<AtomCommandResult<unknown, unknown>>; | ||
| 96 | readonly resolve: Array<(result: AtomCommandResult<unknown, unknown>) => void>; | ||
| 97 | } | ||
| 98 | |||
| 99 | interface AtomCommandLatestLane { | ||
| 100 | running: boolean; | ||
| 101 | pending: AtomCommandLatestBatch | undefined; | ||
| 102 | } | ||
| 103 | |||
| 104 | export interface AtomCommandScheduler { | ||
| 105 | readonly schedule: <W, A, E>( | ||
| 106 | registry: AtomRegistry.AtomRegistry, | ||
| 107 | concurrency: AtomCommandConcurrency<W>, | ||
| 108 | input: W, | ||
| 109 | execute: () => Promise<AtomCommandResult<A, E>>, | ||
| 110 | ) => Promise<AtomCommandResult<A, E>>; | ||
| 111 | } | ||
| 112 | |||
| 113 | async function settleAtomCommandResult<A, E>( | ||
| 114 | execute: () => Promise<AtomCommandResult<A, E>>, | ||
| 115 | ): Promise<AtomCommandResult<A, E>> { | ||
| 116 | try { | ||
| 117 | return await execute(); | ||
| 118 | } catch (defect) { | ||
| 119 | return AsyncResult.failure(Cause.die(defect)); | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | export function createAtomCommandScheduler(): AtomCommandScheduler { | ||
| 124 | const registryStates = new WeakMap<AtomRegistry.AtomRegistry, AtomCommandSchedulerState>(); | ||
| 125 | |||
| 126 | const stateFor = (registry: AtomRegistry.AtomRegistry): AtomCommandSchedulerState => { | ||
| 127 | const existing = registryStates.get(registry); | ||
| 128 | if (existing !== undefined) { | ||
| 129 | return existing; | ||
| 130 | } | ||
| 131 | const state: AtomCommandSchedulerState = { | ||
| 132 | serial: new Map(), | ||
| 133 | singleFlight: new Map(), | ||
| 134 | latest: new Map(), | ||
| 135 | }; | ||
| 136 | registryStates.set(registry, state); | ||
| 137 | return state; | ||
| 138 | }; | ||
| 139 | |||
| 140 | return { | ||
| 141 | schedule: <W, A, E>( | ||
| 142 | registry: AtomRegistry.AtomRegistry, | ||
| 143 | concurrency: AtomCommandConcurrency<W>, | ||
| 144 | input: W, | ||
| 145 | execute: () => Promise<AtomCommandResult<A, E>>, | ||
| 146 | ): Promise<AtomCommandResult<A, E>> => { | ||
| 147 | if (concurrency.mode === "parallel") { | ||
| 148 | return execute(); | ||
| 149 | } | ||
| 150 | |||
| 151 | const key = concurrency.key(input); | ||
| 152 | const state = stateFor(registry); | ||
| 153 | if (concurrency.mode === "singleFlight") { | ||
| 154 | const existing = state.singleFlight.get(key) as | ||
| 155 | | Promise<AtomCommandResult<A, E>> | ||
| 156 | | undefined; | ||
| 157 | if (existing !== undefined) { | ||
| 158 | return existing; | ||
| 159 | } | ||
| 160 | const current = execute(); | ||
| 161 | state.singleFlight.set(key, current); | ||
| 162 | void current.then( | ||
| 163 | () => { | ||
| 164 | if (state.singleFlight.get(key) === current) { | ||
| 165 | state.singleFlight.delete(key); | ||
| 166 | } | ||
| 167 | }, | ||
| 168 | () => { | ||
| 169 | if (state.singleFlight.get(key) === current) { | ||
| 170 | state.singleFlight.delete(key); | ||
| 171 | } | ||
| 172 | }, | ||
| 173 | ); | ||
| 174 | return current; | ||
| 175 | } | ||
| 176 | |||
| 177 | if (concurrency.mode === "serial") { | ||
| 178 | const previous = state.serial.get(key); | ||
| 179 | const current = previous === undefined ? execute() : previous.then(execute, execute); | ||
| 180 | state.serial.set(key, current); | ||
| 181 | void current.then( | ||
| 182 | () => { | ||
| 183 | if (state.serial.get(key) === current) { | ||
| 184 | state.serial.delete(key); | ||
| 185 | } | ||
| 186 | }, | ||
| 187 | () => { | ||
| 188 | if (state.serial.get(key) === current) { | ||
| 189 | state.serial.delete(key); | ||
| 190 | } | ||
| 191 | }, | ||
| 192 | ); | ||
| 193 | return current; | ||
| 194 | } | ||
| 195 | |||
| 196 | let lane = state.latest.get(key); | ||
| 197 | if (lane === undefined) { | ||
| 198 | lane = { running: false, pending: undefined }; | ||
| 199 | state.latest.set(key, lane); | ||
| 200 | } | ||
| 201 | const activeLane = lane; | ||
| 202 | |||
| 203 | const result = new Promise<AtomCommandResult<A, E>>((resolve) => { | ||
| 204 | if (activeLane.pending === undefined) { | ||
| 205 | activeLane.pending = { | ||
| 206 | execute: execute as () => Promise<AtomCommandResult<unknown, unknown>>, | ||
| 207 | resolve: [resolve as (result: AtomCommandResult<unknown, unknown>) => void], | ||
| 208 | }; | ||
| 209 | return; | ||
| 210 | } | ||
| 211 | activeLane.pending.execute = execute as () => Promise<AtomCommandResult<unknown, unknown>>; | ||
| 212 | activeLane.pending.resolve.push( | ||
| 213 | resolve as (result: AtomCommandResult<unknown, unknown>) => void, | ||
| 214 | ); | ||
| 215 | }); | ||
| 216 | |||
| 217 | if (!activeLane.running) { | ||
| 218 | activeLane.running = true; | ||
| 219 | void (async () => { | ||
| 220 | while (activeLane.pending !== undefined) { | ||
| 221 | const batch = activeLane.pending; | ||
| 222 | activeLane.pending = undefined; | ||
| 223 | let batchResult: AtomCommandResult<unknown, unknown>; | ||
| 224 | try { | ||
| 225 | batchResult = await batch.execute(); | ||
| 226 | } catch (defect) { | ||
| 227 | batchResult = AsyncResult.failure(Cause.die(defect)); | ||
| 228 | } | ||
| 229 | for (const resolve of batch.resolve) { | ||
| 230 | resolve(batchResult); | ||
| 231 | } | ||
| 232 | } | ||
| 233 | activeLane.running = false; | ||
| 234 | if (state.latest.get(key) === activeLane) { | ||
| 235 | state.latest.delete(key); | ||
| 236 | } | ||
| 237 | })(); | ||
| 238 | } | ||
| 239 | |||
| 240 | return result; | ||
| 241 | }, | ||
| 242 | }; | ||
| 243 | } | ||
| 244 | |||
| 245 | export async function runAtomCommand<W, A, E>( | ||
| 246 | registry: AtomRegistry.AtomRegistry, | ||
| 247 | command: AtomCommand<W, A, E>, | ||
| 248 | input: W, | ||
| 249 | options: AtomCommandOptions = {}, | ||
| 250 | reporter: AtomCommandReporter = console, | ||
| 251 | ): Promise<AtomCommandResult<A, E>> { | ||
| 252 | const result = await settleAtomCommandResult(() => command.run(registry, input)); | ||
| 253 | reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter); | ||
| 254 | return result; | ||
| 255 | } | ||
| 256 | |||
| 257 | export function mapAtomCommandResult<A, E, B>( | ||
| 258 | result: AtomCommandResult<A, E>, | ||
| 259 | map: (value: A) => B, | ||
| 260 | ): AtomCommandResult<B, E> { | ||
| 261 | return result._tag === "Success" | ||
| 262 | ? AsyncResult.success(map(result.value)) | ||
| 263 | : AsyncResult.failure(result.cause); | ||
| 264 | } | ||
| 265 | |||
| 266 | export function isAtomCommandInterrupted(result: AtomCommandResult<unknown, unknown>): boolean { | ||
| 267 | return result._tag === "Failure" && Cause.hasInterruptsOnly(result.cause); | ||
| 268 | } | ||
| 269 | |||
| 270 | export function squashAtomCommandFailure(result: { | ||
| 271 | readonly cause: Cause.Cause<unknown>; | ||
| 272 | }): unknown { | ||
| 273 | return Cause.squash(result.cause); | ||
| 274 | } | ||
| 275 | |||
| 276 | export async function settleAsyncResult<A, E>( | ||
| 277 | execute: () => Promise<Exit.Exit<A, E>>, | ||
| 278 | ): Promise<SettledAsyncResult<A, E>> { | ||
| 279 | try { | ||
| 280 | return AsyncResult.fromExit(await execute()); | ||
| 281 | } catch (defect) { | ||
| 282 | return AsyncResult.failure(Cause.die(defect)); | ||
| 283 | } | ||
| 284 | } | ||
| 285 | |||
| 286 | export async function executeAtomCommand<A, E>( | ||
| 287 | execute: () => Promise<Exit.Exit<A, E>>, | ||
| 288 | options: AtomCommandOptions = {}, | ||
| 289 | reporter: AtomCommandReporter = console, | ||
| 290 | ): Promise<AtomCommandResult<A, E>> { | ||
| 291 | const result = await settleAsyncResult(execute); | ||
| 292 | reportAtomCommandResult(result, options, reporter); | ||
| 293 | return result; | ||
| 294 | } | ||
| 295 | |||
| 296 | export async function executeAtomQuery<A, E>( | ||
| 297 | registry: AtomRegistry.AtomRegistry, | ||
| 298 | atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, | ||
| 299 | options: AtomCommandOptions = {}, | ||
| 300 | reporter: AtomCommandReporter = console, | ||
| 301 | ): Promise<AtomCommandResult<A, E>> { | ||
| 302 | const query = Effect.scoped( | ||
| 303 | Effect.gen(function* () { | ||
| 304 | yield* AtomRegistry.mount(registry, atom); | ||
| 305 | return yield* AtomRegistry.getResult(registry, atom, { | ||
| 306 | suspendOnWaiting: true, | ||
| 307 | }); | ||
| 308 | }), | ||
| 309 | ); | ||
| 310 | return executeAtomCommand(() => Effect.runPromiseExit(query), options, reporter); | ||
| 311 | } | ||
| 312 | |||
| 313 | export function createRuntimeCommand<R, ER, W, A, E>( | ||
| 314 | runtime: Atom.AtomRuntime<R, ER>, | ||
| 315 | options: { | ||
| 316 | readonly label: string; | ||
| 317 | readonly execute: (input: W, registry: AtomRegistry.AtomRegistry) => Effect.Effect<A, E, R>; | ||
| 318 | readonly scheduler?: AtomCommandScheduler; | ||
| 319 | readonly concurrency?: AtomCommandConcurrency<W>; | ||
| 320 | }, | ||
| 321 | ): AtomCommand<W, A, E | ER> { | ||
| 322 | const scheduler = options.scheduler ?? createAtomCommandScheduler(); | ||
| 323 | const concurrency = options.concurrency ?? { mode: "parallel" as const }; | ||
| 324 | return { | ||
| 325 | label: options.label, | ||
| 326 | run: (registry, input) => | ||
| 327 | settleAtomCommandResult(() => | ||
| 328 | scheduler.schedule(registry, concurrency, input, () => { | ||
| 329 | const atom = runtime | ||
| 330 | .atom(options.execute(input, registry)) | ||
| 331 | .pipe(Atom.withLabel(options.label)); | ||
| 332 | return executeAtomQuery(registry, atom, { reportDefect: false, reportFailure: false }); | ||
| 333 | }), | ||
| 334 | ), | ||
| 335 | }; | ||
| 336 | } | ||
| 337 | |||
| 338 | export function createRuntimeStreamCommand<R, ER, W, A, E>( | ||
| 339 | runtime: Atom.AtomRuntime<R, ER>, | ||
| 340 | options: { | ||
| 341 | readonly label: string; | ||
| 342 | readonly execute: (input: W, registry: AtomRegistry.AtomRegistry) => Stream.Stream<A, E, R>; | ||
| 343 | readonly scheduler?: AtomCommandScheduler; | ||
| 344 | readonly concurrency?: AtomCommandConcurrency<W>; | ||
| 345 | }, | ||
| 346 | ): AtomCommand<W, A, E | ER | Cause.NoSuchElementError> { | ||
| 347 | const scheduler = options.scheduler ?? createAtomCommandScheduler(); | ||
| 348 | const concurrency = options.concurrency ?? { mode: "parallel" as const }; | ||
| 349 | return { | ||
| 350 | label: options.label, | ||
| 351 | run: (registry, input) => | ||
| 352 | settleAtomCommandResult(() => | ||
| 353 | scheduler.schedule(registry, concurrency, input, () => { | ||
| 354 | const atom = runtime | ||
| 355 | .atom(options.execute(input, registry)) | ||
| 356 | .pipe(Atom.withLabel(options.label)); | ||
| 357 | return executeAtomQuery(registry, atom, { reportDefect: false, reportFailure: false }); | ||
| 358 | }), | ||
| 359 | ), | ||
| 360 | }; | ||
| 361 | } | ||
| 362 | |||
| 363 | export function reportAtomCommandResult( | ||
| 364 | result: AtomCommandResult<unknown, unknown>, | ||
| 365 | options: AtomCommandOptions = {}, | ||
| 366 | reporter: AtomCommandReporter = console, | ||
| 367 | ): void { | ||
| 368 | if (AsyncResult.isSuccess(result) || Cause.hasInterruptsOnly(result.cause)) { | ||
| 369 | return; | ||
| 370 | } | ||
| 371 | |||
| 372 | const label = options.label ?? "atom command"; | ||
| 373 | if (Cause.hasDies(result.cause)) { | ||
| 374 | if (options.reportDefect ?? true) { | ||
| 375 | reporter.error(`[atom-command] ${label} defected`, result.cause); | ||
| 376 | } | ||
| 377 | } else if (options.reportFailure ?? true) { | ||
| 378 | reporter.warn(`[atom-command] ${label} failed`, result.cause); | ||
| 379 | } | ||
| 380 | } | ||
| 381 | |||
| 382 | export async function settlePromise<A>( | ||
| 383 | execute: () => Promise<A>, | ||
| 384 | ): Promise<AtomCommandResult<A, never>> { | ||
| 385 | try { | ||
| 386 | return AsyncResult.success(await execute()); | ||
| 387 | } catch (defect) { | ||
| 388 | return AsyncResult.failure(Cause.die(defect)); | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 392 | export function environmentRpcKey<Input>(target: { | ||
| 393 | readonly environmentId: EnvironmentIdType; | ||
| 394 | readonly input: Input; | ||
| 395 | }): string { | ||
| 396 | return JSON.stringify([target.environmentId, target.input]); | ||
| 397 | } | ||
| 398 | |||
| 399 | function parseEnvironmentRpcKey<Input>(key: string): { | ||
| 400 | readonly environmentId: EnvironmentIdType; | ||
| 401 | readonly input: Input; | ||
| 402 | } { | ||
| 403 | const decoded = JSON.parse(key) as [EnvironmentIdType, Input]; | ||
| 404 | return { | ||
| 405 | environmentId: EnvironmentId.make(decoded[0]), | ||
| 406 | input: decoded[1], | ||
| 407 | }; | ||
| 408 | } | ||
| 409 | |||
| 410 | export function runInEnvironment<A, E, R>( | ||
| 411 | environmentId: EnvironmentIdType, | ||
| 412 | effect: Effect.Effect<A, E, R>, | ||
| 413 | ): Effect.Effect< | ||
| 414 | A, | ||
| 415 | E | EnvironmentNotRegisteredError, | ||
| 416 | EnvironmentRegistry | Exclude<R, EnvironmentSupervisor> | ||
| 417 | > { | ||
| 418 | return EnvironmentRegistry.pipe( | ||
| 419 | Effect.flatMap((registry) => registry.run(environmentId, effect)), | ||
| 420 | ); | ||
| 421 | } | ||
| 422 | |||
| 423 | export function runStreamInEnvironment<A, E, R>( | ||
| 424 | environmentId: EnvironmentIdType, | ||
| 425 | stream: Stream.Stream<A, E, R>, | ||
| 426 | ): Stream.Stream< | ||
| 427 | A, | ||
| 428 | E | EnvironmentNotRegisteredError, | ||
| 429 | EnvironmentRegistry | Exclude<R, EnvironmentSupervisor> | ||
| 430 | > { | ||
| 431 | return Stream.unwrap( | ||
| 432 | EnvironmentRegistry.pipe(Effect.map((registry) => registry.runStream(environmentId, stream))), | ||
| 433 | ); | ||
| 434 | } | ||
| 435 | |||
| 436 | export function followStreamInEnvironment<A, E, R>( | ||
| 437 | environmentId: EnvironmentIdType, | ||
| 438 | stream: Stream.Stream<A, E, R>, | ||
| 439 | ): Stream.Stream<A, E, EnvironmentRegistry | Exclude<R, EnvironmentSupervisor>> { | ||
| 440 | return Stream.unwrap( | ||
| 441 | EnvironmentRegistry.pipe( | ||
| 442 | Effect.map((registry) => registry.followStream(environmentId, stream)), | ||
| 443 | ), | ||
| 444 | ); | ||
| 445 | } | ||
| 446 | |||
| 447 | function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>( | ||
| 448 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>, | ||
| 449 | options: EnvironmentQueryAtomOptions<Input, A, E, EnvironmentSupervisor | R>, | ||
| 450 | ): (target: { | ||
| 451 | readonly environmentId: EnvironmentIdType; | ||
| 452 | readonly input: Input; | ||
| 453 | }) => Atom.Atom<AsyncResult.AsyncResult<A, E | ER | Error>> { | ||
| 454 | const rpcGenerationAtom = Atom.family((environmentId: EnvironmentIdType) => | ||
| 455 | runtime.atom( | ||
| 456 | followStreamInEnvironment( | ||
| 457 | environmentId, | ||
| 458 | Stream.unwrap( | ||
| 459 | EnvironmentSupervisor.pipe( | ||
| 460 | Effect.map((supervisor) => | ||
| 461 | SubscriptionRef.changes(supervisor.state).pipe( | ||
| 462 | Stream.filterMap((state) => | ||
| 463 | state.phase === "connected" ? Result.succeed(state.generation) : Result.failVoid, | ||
| 464 | ), | ||
| 465 | Stream.changes, | ||
| 466 | Stream.map<number, number | null>((generation) => generation), | ||
| 467 | ), | ||
| 468 | ), | ||
| 469 | ), | ||
| 470 | ), | ||
| 471 | ), | ||
| 472 | { initialValue: null }, | ||
| 473 | ), | ||
| 474 | ); | ||
| 475 | const family = Atom.family((key: string) => { | ||
| 476 | const target = parseEnvironmentRpcKey<Input>(key); | ||
| 477 | const idleTtlMs = options.idleTtlMs ?? 5 * 60_000; | ||
| 478 | const queryAtom = runtime | ||
| 479 | .atom((get) => { | ||
| 480 | const generation = Option.getOrNull( | ||
| 481 | AsyncResult.value(get(rpcGenerationAtom(target.environmentId))), | ||
| 482 | ); | ||
| 483 | if (generation === null) { | ||
| 484 | return Effect.never; | ||
| 485 | } | ||
| 486 | return runInEnvironment(target.environmentId, options.execute(target.input)); | ||
| 487 | }) | ||
| 488 | .pipe( | ||
| 489 | Atom.swr({ | ||
| 490 | staleTime: options.staleTimeMs ?? 30_000, | ||
| 491 | revalidateOnMount: true, | ||
| 492 | }), | ||
| 493 | Atom.setIdleTTL(idleTtlMs), | ||
| 494 | ); | ||
| 495 | return ( | ||
| 496 | options.refreshIntervalMs === undefined | ||
| 497 | ? queryAtom | ||
| 498 | : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs)) | ||
| 499 | ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`${options.label}:${key}`)); | ||
| 500 | }); | ||
| 501 | return (target) => family(environmentRpcKey(target)); | ||
| 502 | } | ||
| 503 | |||
| 504 | export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>( | ||
| 505 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>, | ||
| 506 | options: EnvironmentSubscriptionAtomOptions<Input, A, E, EnvironmentSupervisor | R>, | ||
| 507 | ) { | ||
| 508 | const family = Atom.family((key: string) => { | ||
| 509 | const target = parseEnvironmentRpcKey<Input>(key); | ||
| 510 | return runtime | ||
| 511 | .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input))) | ||
| 512 | .pipe( | ||
| 513 | Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000), | ||
| 514 | Atom.withLabel(`${options.label}:${key}`), | ||
| 515 | ); | ||
| 516 | }); | ||
| 517 | return (target: { readonly environmentId: EnvironmentIdType; readonly input: Input }) => | ||
| 518 | family(environmentRpcKey(target)); | ||
| 519 | } | ||
| 520 | |||
| 521 | export function createEnvironmentCommand<R, ER, Input, A, E>( | ||
| 522 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>, | ||
| 523 | options: EnvironmentAtomOptions<Input, A, E, EnvironmentSupervisor | R>, | ||
| 524 | ) { | ||
| 525 | return createRuntimeCommand(runtime, { | ||
| 526 | label: options.label, | ||
| 527 | ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), | ||
| 528 | ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), | ||
| 529 | execute: (target) => runInEnvironment(target.environmentId, options.execute(target.input)), | ||
| 530 | }); | ||
| 531 | } | ||
| 532 | |||
| 533 | function createEnvironmentStreamCommand<R, ER, Input, A, E>( | ||
| 534 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>, | ||
| 535 | options: { | ||
| 536 | readonly label: string; | ||
| 537 | readonly execute: (input: Input) => Stream.Stream<A, E, EnvironmentSupervisor | R>; | ||
| 538 | readonly scheduler?: AtomCommandScheduler; | ||
| 539 | readonly concurrency?: AtomCommandConcurrency<{ | ||
| 540 | readonly environmentId: EnvironmentIdType; | ||
| 541 | readonly input: Input; | ||
| 542 | }>; | ||
| 543 | }, | ||
| 544 | ) { | ||
| 545 | return createRuntimeStreamCommand(runtime, { | ||
| 546 | label: options.label, | ||
| 547 | ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), | ||
| 548 | ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), | ||
| 549 | execute: (target) => | ||
| 550 | runStreamInEnvironment(target.environmentId, options.execute(target.input)).pipe( | ||
| 551 | Stream.withSpan(options.label), | ||
| 552 | ), | ||
| 553 | }); | ||
| 554 | } | ||
| 555 | |||
| 556 | export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends EnvironmentUnaryRpcTag>( | ||
| 557 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>, | ||
| 558 | options: { | ||
| 559 | readonly label: string; | ||
| 560 | readonly tag: TTag; | ||
| 561 | readonly staleTimeMs?: number; | ||
| 562 | readonly idleTtlMs?: number; | ||
| 563 | readonly refreshIntervalMs?: number; | ||
| 564 | }, | ||
| 565 | ) { | ||
| 566 | return createEnvironmentQueryAtomFamily(runtime, { | ||
| 567 | label: options.label, | ||
| 568 | ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), | ||
| 569 | ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), | ||
| 570 | ...(options.refreshIntervalMs === undefined | ||
| 571 | ? {} | ||
| 572 | : { refreshIntervalMs: options.refreshIntervalMs }), | ||
| 573 | execute: (input: EnvironmentRpcInput<TTag>) => request(options.tag, input), | ||
| 574 | }); | ||
| 575 | } | ||
| 576 | |||
| 577 | export function createEnvironmentRpcSubscriptionAtomFamily< | ||
| 578 | R, | ||
| 579 | ER, | ||
| 580 | TTag extends EnvironmentSubscriptionRpcTag, | ||
| 581 | B = EnvironmentRpcStreamValue<TTag>, | ||
| 582 | >( | ||
| 583 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>, | ||
| 584 | options: { | ||
| 585 | readonly label: string; | ||
| 586 | readonly tag: TTag; | ||
| 587 | readonly idleTtlMs?: number; | ||
| 588 | readonly transform?: ( | ||
| 589 | stream: Stream.Stream< | ||
| 590 | EnvironmentRpcStreamValue<TTag>, | ||
| 591 | EnvironmentRpcStreamFailure<TTag>, | ||
| 592 | EnvironmentSupervisor | R | ||
| 593 | >, | ||
| 594 | ) => Stream.Stream<B, EnvironmentRpcStreamFailure<TTag>, EnvironmentSupervisor | R>; | ||
| 595 | }, | ||
| 596 | ) { | ||
| 597 | return createEnvironmentSubscriptionAtomFamily(runtime, { | ||
| 598 | label: options.label, | ||
| 599 | ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), | ||
| 600 | subscribe: (input: EnvironmentRpcInput<TTag>) => { | ||
| 601 | const stream = subscribe(options.tag, input); | ||
| 602 | return options.transform === undefined | ||
| 603 | ? (stream as Stream.Stream<B, EnvironmentRpcStreamFailure<TTag>, EnvironmentSupervisor | R>) | ||
| 604 | : options.transform(stream); | ||
| 605 | }, | ||
| 606 | }); | ||
| 607 | } | ||
| 608 | |||
| 609 | export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnaryRpcTag>( | ||
| 610 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>, | ||
| 611 | options: { | ||
| 612 | readonly label: string; | ||
| 613 | readonly tag: TTag; | ||
| 614 | readonly scheduler?: AtomCommandScheduler; | ||
| 615 | readonly concurrency?: AtomCommandConcurrency<{ | ||
| 616 | readonly environmentId: EnvironmentIdType; | ||
| 617 | readonly input: EnvironmentRpcInput<TTag>; | ||
| 618 | }>; | ||
| 619 | }, | ||
| 620 | ) { | ||
| 621 | return createEnvironmentCommand(runtime, { | ||
| 622 | label: options.label, | ||
| 623 | ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), | ||
| 624 | ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), | ||
| 625 | execute: (input: EnvironmentRpcInput<TTag>) => request(options.tag, input), | ||
| 626 | }); | ||
| 627 | } | ||
| 628 | |||
| 629 | export function createEnvironmentRpcStreamCommand< | ||
| 630 | R, | ||
| 631 | ER, | ||
| 632 | TTag extends EnvironmentStreamCommandRpcTag, | ||
| 633 | >( | ||
| 634 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>, | ||
| 635 | options: { | ||
| 636 | readonly label: string; | ||
| 637 | readonly tag: TTag; | ||
| 638 | readonly scheduler?: AtomCommandScheduler; | ||
| 639 | readonly concurrency?: AtomCommandConcurrency<{ | ||
| 640 | readonly environmentId: EnvironmentIdType; | ||
| 641 | readonly input: EnvironmentRpcInput<TTag>; | ||
| 642 | }>; | ||
| 643 | }, | ||
| 644 | ) { | ||
| 645 | return createEnvironmentStreamCommand(runtime, { | ||
| 646 | label: options.label, | ||
| 647 | ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), | ||
| 648 | ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), | ||
| 649 | execute: (input: EnvironmentRpcInput<TTag>) => runStream(options.tag, input), | ||
| 650 | }); | ||
| 651 | } | ||
packages/client-runtime/src/state/server.test.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | |||
| 5 | import { applyServerConfigProjection, projectServerWelcome } from "./server.ts"; | ||
| 6 | |||
| 7 | const CONFIG = { | ||
| 8 | availableEditors: [], | ||
| 9 | issues: [], | ||
| 10 | keybindings: {}, | ||
| 11 | keybindingsConfigPath: null, | ||
| 12 | observability: null, | ||
| 13 | providers: [], | ||
| 14 | settings: {}, | ||
| 15 | } as unknown as ServerConfig; | ||
| 16 | |||
| 17 | describe("server state projection", () => { | ||
| 18 | it("applies every config category to the projected snapshot", () => { | ||
| 19 | const snapshot = applyServerConfigProjection(Option.none(), { | ||
| 20 | version: 1, | ||
| 21 | type: "snapshot", | ||
| 22 | config: CONFIG, | ||
| 23 | }); | ||
| 24 | const settings = { ...CONFIG.settings }; | ||
| 25 | const projected = applyServerConfigProjection(snapshot, { | ||
| 26 | version: 1, | ||
| 27 | type: "settingsUpdated", | ||
| 28 | payload: { settings }, | ||
| 29 | }); | ||
| 30 | |||
| 31 | const result = Option.getOrThrow(projected); | ||
| 32 | expect(result.config.settings).toBe(settings); | ||
| 33 | expect(result.latestEvent.type).toBe("settingsUpdated"); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("retains welcome when a ready event follows in the same stream chunk", () => { | ||
| 37 | const welcome = { | ||
| 38 | environment: {} as ServerLifecycleWelcomePayload["environment"], | ||
| 39 | cwd: "/repo", | ||
| 40 | projectName: "repo", | ||
| 41 | } as ServerLifecycleWelcomePayload; | ||
| 42 | const [afterWelcome] = projectServerWelcome(Option.none(), { | ||
| 43 | type: "welcome", | ||
| 44 | payload: welcome, | ||
| 45 | }); | ||
| 46 | const [afterReady, emitted] = projectServerWelcome(afterWelcome, { | ||
| 47 | type: "ready", | ||
| 48 | payload: {}, | ||
| 49 | }); | ||
| 50 | |||
| 51 | expect(Option.getOrThrow(afterReady)).toBe(welcome); | ||
| 52 | expect(emitted).toEqual([]); | ||
| 53 | }); | ||
| 54 | }); | ||
packages/client-runtime/src/state/server.ts created+182| ... | @@ -0,0 +1,182 @@ | ||
| 1 | import { | ||
| 2 | type EnvironmentId, | ||
| 3 | type ServerConfig, | ||
| 4 | type ServerConfigStreamEvent, | ||
| 5 | type ServerLifecycleWelcomePayload, | ||
| 6 | WS_METHODS, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | createAtomCommandScheduler, | ||
| 14 | createEnvironmentRpcCommand, | ||
| 15 | createEnvironmentRpcQueryAtomFamily, | ||
| 16 | createEnvironmentRpcSubscriptionAtomFamily, | ||
| 17 | } from "./runtime.ts"; | ||
| 18 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 19 | |||
| 20 | export interface ServerConfigProjection { | ||
| 21 | readonly config: ServerConfig; | ||
| 22 | readonly latestEvent: ServerConfigStreamEvent; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function applyServerConfigProjection( | ||
| 26 | current: Option.Option<ServerConfigProjection>, | ||
| 27 | event: ServerConfigStreamEvent, | ||
| 28 | ): Option.Option<ServerConfigProjection> { | ||
| 29 | switch (event.type) { | ||
| 30 | case "snapshot": | ||
| 31 | return Option.some({ | ||
| 32 | config: event.config, | ||
| 33 | latestEvent: event, | ||
| 34 | }); | ||
| 35 | case "keybindingsUpdated": | ||
| 36 | return Option.map(current, (projection) => ({ | ||
| 37 | config: { | ||
| 38 | ...projection.config, | ||
| 39 | keybindings: event.payload.keybindings, | ||
| 40 | issues: event.payload.issues, | ||
| 41 | }, | ||
| 42 | latestEvent: event, | ||
| 43 | })); | ||
| 44 | case "providerStatuses": | ||
| 45 | return Option.map(current, (projection) => ({ | ||
| 46 | config: { | ||
| 47 | ...projection.config, | ||
| 48 | providers: event.payload.providers, | ||
| 49 | }, | ||
| 50 | latestEvent: event, | ||
| 51 | })); | ||
| 52 | case "settingsUpdated": | ||
| 53 | return Option.map(current, (projection) => ({ | ||
| 54 | config: { | ||
| 55 | ...projection.config, | ||
| 56 | settings: event.payload.settings, | ||
| 57 | }, | ||
| 58 | latestEvent: event, | ||
| 59 | })); | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | export function projectServerConfig( | ||
| 64 | current: Option.Option<ServerConfigProjection>, | ||
| 65 | event: ServerConfigStreamEvent, | ||
| 66 | ): readonly [Option.Option<ServerConfigProjection>, ReadonlyArray<ServerConfigProjection>] { | ||
| 67 | const next = applyServerConfigProjection(current, event); | ||
| 68 | return [next, Option.toArray(next)]; | ||
| 69 | } | ||
| 70 | |||
| 71 | export function projectServerWelcome( | ||
| 72 | current: Option.Option<ServerLifecycleWelcomePayload>, | ||
| 73 | event: { | ||
| 74 | readonly type: "welcome" | "ready"; | ||
| 75 | readonly payload: unknown; | ||
| 76 | }, | ||
| 77 | ): readonly [ | ||
| 78 | Option.Option<ServerLifecycleWelcomePayload>, | ||
| 79 | ReadonlyArray<ServerLifecycleWelcomePayload>, | ||
| 80 | ] { | ||
| 81 | if (event.type !== "welcome") { | ||
| 82 | return [current, []]; | ||
| 83 | } | ||
| 84 | const welcome = event.payload as ServerLifecycleWelcomePayload; | ||
| 85 | return [Option.some(welcome), [welcome]]; | ||
| 86 | } | ||
| 87 | |||
| 88 | export function createServerEnvironmentAtoms<R, E>( | ||
| 89 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 90 | options: { | ||
| 91 | readonly initialConfigValueAtom: ( | ||
| 92 | environmentId: EnvironmentId, | ||
| 93 | ) => Atom.Atom<ServerConfig | null>; | ||
| 94 | }, | ||
| 95 | ) { | ||
| 96 | const configScheduler = createAtomCommandScheduler(); | ||
| 97 | const configConcurrency = { | ||
| 98 | mode: "serial" as const, | ||
| 99 | key: ({ environmentId }: { readonly environmentId: string }) => environmentId, | ||
| 100 | }; | ||
| 101 | const configProjection = createEnvironmentRpcSubscriptionAtomFamily(runtime, { | ||
| 102 | label: "environment-data:server:config-projection", | ||
| 103 | tag: WS_METHODS.subscribeServerConfig, | ||
| 104 | transform: (stream) => | ||
| 105 | stream.pipe(Stream.mapAccum(Option.none<ServerConfigProjection>, projectServerConfig)), | ||
| 106 | }); | ||
| 107 | const emptyConfigAtom = Atom.make<ServerConfig | null>(null).pipe( | ||
| 108 | Atom.withLabel("environment-data:server:config:empty"), | ||
| 109 | ); | ||
| 110 | const configValueAtom = Atom.family((environmentId: EnvironmentId | null) => { | ||
| 111 | if (environmentId === null) { | ||
| 112 | return emptyConfigAtom; | ||
| 113 | } | ||
| 114 | return Atom.make((get): ServerConfig | null => { | ||
| 115 | const projection = Option.getOrNull( | ||
| 116 | AsyncResult.value(get(configProjection({ environmentId, input: {} }))), | ||
| 117 | ); | ||
| 118 | return projection?.config ?? get(options.initialConfigValueAtom(environmentId)); | ||
| 119 | }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); | ||
| 120 | }); | ||
| 121 | |||
| 122 | return { | ||
| 123 | configValueAtom, | ||
| 124 | traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 125 | label: "environment-data:server:trace-diagnostics", | ||
| 126 | tag: WS_METHODS.serverGetTraceDiagnostics, | ||
| 127 | }), | ||
| 128 | processDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 129 | label: "environment-data:server:process-diagnostics", | ||
| 130 | tag: WS_METHODS.serverGetProcessDiagnostics, | ||
| 131 | }), | ||
| 132 | processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 133 | label: "environment-data:server:process-resource-history", | ||
| 134 | tag: WS_METHODS.serverGetProcessResourceHistory, | ||
| 135 | }), | ||
| 136 | configProjection, | ||
| 137 | welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | ||
| 138 | label: "environment-data:server:welcome", | ||
| 139 | tag: WS_METHODS.subscribeServerLifecycle, | ||
| 140 | transform: (stream) => | ||
| 141 | stream.pipe( | ||
| 142 | Stream.mapAccum(Option.none<ServerLifecycleWelcomePayload>, projectServerWelcome), | ||
| 143 | ), | ||
| 144 | }), | ||
| 145 | refreshProviders: createEnvironmentRpcCommand(runtime, { | ||
| 146 | label: "environment-data:server:refresh-providers", | ||
| 147 | tag: WS_METHODS.serverRefreshProviders, | ||
| 148 | concurrency: { | ||
| 149 | mode: "singleFlight", | ||
| 150 | key: ({ environmentId }) => environmentId, | ||
| 151 | }, | ||
| 152 | }), | ||
| 153 | updateProvider: createEnvironmentRpcCommand(runtime, { | ||
| 154 | label: "environment-data:server:update-provider", | ||
| 155 | tag: WS_METHODS.serverUpdateProvider, | ||
| 156 | scheduler: configScheduler, | ||
| 157 | concurrency: configConcurrency, | ||
| 158 | }), | ||
| 159 | upsertKeybinding: createEnvironmentRpcCommand(runtime, { | ||
| 160 | label: "environment-data:server:upsert-keybinding", | ||
| 161 | tag: WS_METHODS.serverUpsertKeybinding, | ||
| 162 | scheduler: configScheduler, | ||
| 163 | concurrency: configConcurrency, | ||
| 164 | }), | ||
| 165 | removeKeybinding: createEnvironmentRpcCommand(runtime, { | ||
| 166 | label: "environment-data:server:remove-keybinding", | ||
| 167 | tag: WS_METHODS.serverRemoveKeybinding, | ||
| 168 | scheduler: configScheduler, | ||
| 169 | concurrency: configConcurrency, | ||
| 170 | }), | ||
| 171 | updateSettings: createEnvironmentRpcCommand(runtime, { | ||
| 172 | label: "environment-data:server:update-settings", | ||
| 173 | tag: WS_METHODS.serverUpdateSettings, | ||
| 174 | scheduler: configScheduler, | ||
| 175 | concurrency: configConcurrency, | ||
| 176 | }), | ||
| 177 | signalProcess: createEnvironmentRpcCommand(runtime, { | ||
| 178 | label: "environment-data:server:signal-process", | ||
| 179 | tag: WS_METHODS.serverSignalProcess, | ||
| 180 | }), | ||
| 181 | }; | ||
| 182 | } | ||
packages/client-runtime/src/state/session.test.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | |||
| 6 | import { initialConfigOption } from "./session.ts"; | ||
| 7 | |||
| 8 | class TestConfigError extends Schema.TaggedErrorClass<TestConfigError>()("TestConfigError", { | ||
| 9 | message: Schema.String, | ||
| 10 | }) {} | ||
| 11 | |||
| 12 | describe("environment session state", () => { | ||
| 13 | it.effect("turns an initial config failure into an empty value", () => | ||
| 14 | Effect.gen(function* () { | ||
| 15 | const result = yield* initialConfigOption( | ||
| 16 | Effect.fail(new TestConfigError({ message: "temporary failure" })), | ||
| 17 | ); | ||
| 18 | expect(Option.isNone(result)).toBe(true); | ||
| 19 | }), | ||
| 20 | ); | ||
| 21 | }); | ||
packages/client-runtime/src/state/session.ts created+88| ... | @@ -0,0 +1,88 @@ | ||
| 1 | import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Stream from "effect/Stream"; | ||
| 5 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 7 | |||
| 8 | import { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 9 | import type { PreparedConnection } from "../connection/model.ts"; | ||
| 10 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | ||
| 11 | import { followStreamInEnvironment } from "./runtime.ts"; | ||
| 12 | |||
| 13 | export function initialConfigOption<E>( | ||
| 14 | initialConfig: Effect.Effect<ServerConfig, E>, | ||
| 15 | ): Effect.Effect<Option.Option<ServerConfig>> { | ||
| 16 | return initialConfig.pipe( | ||
| 17 | Effect.map(Option.some), | ||
| 18 | Effect.catch((error) => | ||
| 19 | Effect.logWarning("Could not load the initial environment configuration.", { | ||
| 20 | error, | ||
| 21 | }).pipe(Effect.as(Option.none<ServerConfig>())), | ||
| 22 | ), | ||
| 23 | ); | ||
| 24 | } | ||
| 25 | |||
| 26 | export function createEnvironmentSessionAtoms<R, E>( | ||
| 27 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 28 | ) { | ||
| 29 | const configAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 30 | runtime.atom( | ||
| 31 | followStreamInEnvironment( | ||
| 32 | environmentId, | ||
| 33 | Stream.unwrap( | ||
| 34 | EnvironmentSupervisor.pipe( | ||
| 35 | Effect.map((supervisor) => | ||
| 36 | SubscriptionRef.changes(supervisor.session).pipe( | ||
| 37 | Stream.mapEffect( | ||
| 38 | Option.match({ | ||
| 39 | onNone: () => Effect.succeed(Option.none<ServerConfig>()), | ||
| 40 | onSome: (session) => initialConfigOption(session.initialConfig), | ||
| 41 | }), | ||
| 42 | ), | ||
| 43 | ), | ||
| 44 | ), | ||
| 45 | ), | ||
| 46 | ), | ||
| 47 | ), | ||
| 48 | { initialValue: Option.none() }, | ||
| 49 | ), | ||
| 50 | ); | ||
| 51 | |||
| 52 | const configValueAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 53 | Atom.make((get): ServerConfig | null => | ||
| 54 | Option.getOrNull( | ||
| 55 | Option.getOrElse(AsyncResult.value(get(configAtom(environmentId))), () => Option.none()), | ||
| 56 | ), | ||
| 57 | ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)), | ||
| 58 | ); | ||
| 59 | |||
| 60 | const preparedConnectionAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 61 | runtime.atom( | ||
| 62 | followStreamInEnvironment( | ||
| 63 | environmentId, | ||
| 64 | Stream.unwrap( | ||
| 65 | EnvironmentSupervisor.pipe( | ||
| 66 | Effect.map((supervisor) => SubscriptionRef.changes(supervisor.prepared)), | ||
| 67 | ), | ||
| 68 | ), | ||
| 69 | ), | ||
| 70 | { initialValue: Option.none<PreparedConnection>() }, | ||
| 71 | ), | ||
| 72 | ); | ||
| 73 | |||
| 74 | const preparedConnectionValueAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 75 | Atom.make((get) => | ||
| 76 | Option.getOrElse(AsyncResult.value(get(preparedConnectionAtom(environmentId))), () => | ||
| 77 | Option.none<PreparedConnection>(), | ||
| 78 | ), | ||
| 79 | ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), | ||
| 80 | ); | ||
| 81 | |||
| 82 | return { | ||
| 83 | configAtom, | ||
| 84 | configValueAtom, | ||
| 85 | preparedConnectionAtom, | ||
| 86 | preparedConnectionValueAtom, | ||
| 87 | }; | ||
| 88 | } | ||
packages/client-runtime/src/state/shell-sync.test.ts created+123| ... | @@ -0,0 +1,123 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | ORCHESTRATION_WS_METHODS, | ||
| 4 | type OrchestrationShellSnapshot, | ||
| 5 | type OrchestrationShellStreamItem, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Queue from "effect/Queue"; | ||
| 11 | import * as Stream from "effect/Stream"; | ||
| 12 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | AVAILABLE_CONNECTION_STATE, | ||
| 16 | PrimaryConnectionTarget, | ||
| 17 | type PreparedConnection, | ||
| 18 | } from "../connection/model.ts"; | ||
| 19 | import { | ||
| 20 | EnvironmentSupervisor, | ||
| 21 | type EnvironmentSupervisorService, | ||
| 22 | } from "../connection/supervisor.ts"; | ||
| 23 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | ||
| 24 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 25 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | ||
| 26 | import { makeEnvironmentShellState } from "./shell.ts"; | ||
| 27 | |||
| 28 | const TARGET = new PrimaryConnectionTarget({ | ||
| 29 | environmentId: EnvironmentId.make("environment-1"), | ||
| 30 | label: "Test environment", | ||
| 31 | httpBaseUrl: "https://environment.example.test", | ||
| 32 | wsBaseUrl: "wss://environment.example.test", | ||
| 33 | }); | ||
| 34 | |||
| 35 | const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { | ||
| 36 | snapshotSequence: 1, | ||
| 37 | projects: [], | ||
| 38 | threads: [], | ||
| 39 | updatedAt: "2026-06-06T00:00:00.000Z", | ||
| 40 | }; | ||
| 41 | |||
| 42 | function session(client: WsRpcProtocolClient): RpcSession { | ||
| 43 | return { | ||
| 44 | client, | ||
| 45 | initialConfig: Effect.never, | ||
| 46 | ready: Effect.void, | ||
| 47 | probe: Effect.void, | ||
| 48 | closed: Effect.never, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | describe("environment shell synchronization", () => { | ||
| 53 | it.effect("publishes live state before persistence and preserves it when ready", () => | ||
| 54 | Effect.gen(function* () { | ||
| 55 | const events = yield* Queue.unbounded<OrchestrationShellStreamItem>(); | ||
| 56 | const client = { | ||
| 57 | [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), | ||
| 58 | } as unknown as WsRpcProtocolClient; | ||
| 59 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); | ||
| 60 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession>>( | ||
| 61 | Option.some(session(client)), | ||
| 62 | ); | ||
| 63 | const supervisor = EnvironmentSupervisor.of({ | ||
| 64 | target: TARGET, | ||
| 65 | state: supervisorState, | ||
| 66 | session: activeSession, | ||
| 67 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | ||
| 68 | connect: Effect.void, | ||
| 69 | disconnect: Effect.void, | ||
| 70 | retryNow: Effect.void, | ||
| 71 | } satisfies EnvironmentSupervisorService); | ||
| 72 | const cache = EnvironmentCacheStore.of({ | ||
| 73 | loadShell: () => Effect.succeed(Option.none()), | ||
| 74 | saveShell: () => Effect.never, | ||
| 75 | loadThread: () => Effect.succeed(Option.none()), | ||
| 76 | saveThread: () => Effect.void, | ||
| 77 | removeThread: () => Effect.void, | ||
| 78 | clear: () => Effect.void, | ||
| 79 | }); | ||
| 80 | const shellState = yield* makeEnvironmentShellState().pipe( | ||
| 81 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 82 | Effect.provideService(EnvironmentCacheStore, cache), | ||
| 83 | ); | ||
| 84 | |||
| 85 | yield* SubscriptionRef.set(supervisorState, { | ||
| 86 | desired: true, | ||
| 87 | network: "online", | ||
| 88 | phase: "connecting", | ||
| 89 | stage: "synchronizing", | ||
| 90 | attempt: 1, | ||
| 91 | generation: 0, | ||
| 92 | lastFailure: null, | ||
| 93 | retryAt: null, | ||
| 94 | }); | ||
| 95 | yield* Queue.offer(events, { | ||
| 96 | kind: "snapshot", | ||
| 97 | snapshot: LIVE_SHELL_SNAPSHOT, | ||
| 98 | }); | ||
| 99 | yield* SubscriptionRef.changes(shellState).pipe( | ||
| 100 | Stream.filter((state) => state.status === "live"), | ||
| 101 | Stream.runHead, | ||
| 102 | ); | ||
| 103 | |||
| 104 | yield* SubscriptionRef.set(supervisorState, { | ||
| 105 | desired: true, | ||
| 106 | network: "online", | ||
| 107 | phase: "connected", | ||
| 108 | stage: null, | ||
| 109 | attempt: 1, | ||
| 110 | generation: 1, | ||
| 111 | lastFailure: null, | ||
| 112 | retryAt: null, | ||
| 113 | }); | ||
| 114 | for (let index = 0; index < 10; index += 1) { | ||
| 115 | yield* Effect.yieldNow; | ||
| 116 | } | ||
| 117 | |||
| 118 | const state = yield* SubscriptionRef.get(shellState); | ||
| 119 | expect(state.status).toBe("live"); | ||
| 120 | expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT); | ||
| 121 | }), | ||
| 122 | ); | ||
| 123 | }); | ||
packages/client-runtime/src/state/shell.test.ts created+130| ... | @@ -0,0 +1,130 @@ | ||
| 1 | import type { ServerConfig } from "@t3tools/contracts"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 6 | |||
| 7 | import { PrimaryConnectionTarget } from "../connection/model.ts"; | ||
| 8 | import type { EnvironmentShellState } from "./shell.ts"; | ||
| 9 | import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts"; | ||
| 10 | |||
| 11 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | ||
| 12 | const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); | ||
| 13 | |||
| 14 | function environmentEntry(environmentId: EnvironmentId, label: string) { | ||
| 15 | return { | ||
| 16 | target: new PrimaryConnectionTarget({ | ||
| 17 | environmentId, | ||
| 18 | label, | ||
| 19 | httpBaseUrl: `https://${environmentId}.example.test`, | ||
| 20 | wsBaseUrl: `wss://${environmentId}.example.test`, | ||
| 21 | }), | ||
| 22 | profile: Option.none(), | ||
| 23 | }; | ||
| 24 | } | ||
| 25 | |||
| 26 | function shellState(input: { | ||
| 27 | readonly status: EnvironmentShellState["status"]; | ||
| 28 | readonly updatedAt?: string; | ||
| 29 | readonly error?: string; | ||
| 30 | readonly snapshotSequence?: number; | ||
| 31 | }): EnvironmentShellState { | ||
| 32 | return { | ||
| 33 | snapshot: | ||
| 34 | input.updatedAt === undefined | ||
| 35 | ? Option.none() | ||
| 36 | : Option.some({ | ||
| 37 | snapshotSequence: input.snapshotSequence ?? 1, | ||
| 38 | updatedAt: input.updatedAt, | ||
| 39 | projects: [], | ||
| 40 | threads: [], | ||
| 41 | }), | ||
| 42 | status: input.status, | ||
| 43 | error: input.error === undefined ? Option.none() : Option.some(input.error), | ||
| 44 | }; | ||
| 45 | } | ||
| 46 | |||
| 47 | function makeHarness() { | ||
| 48 | const shellStateAtoms = Atom.family((environmentId: EnvironmentId) => | ||
| 49 | Atom.make<EnvironmentShellState>( | ||
| 50 | environmentId === ENVIRONMENT_ID | ||
| 51 | ? shellState({ | ||
| 52 | status: "cached", | ||
| 53 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 54 | }) | ||
| 55 | : shellState({ | ||
| 56 | status: "synchronizing", | ||
| 57 | updatedAt: "2026-06-02T00:00:00.000Z", | ||
| 58 | error: "Retrying.", | ||
| 59 | }), | ||
| 60 | ), | ||
| 61 | ); | ||
| 62 | const configAtoms = Atom.family((_environmentId: EnvironmentId) => | ||
| 63 | Atom.make<ServerConfig | null>(null), | ||
| 64 | ); | ||
| 65 | const catalogValueAtom = Atom.make({ | ||
| 66 | isReady: true, | ||
| 67 | entries: new Map([ | ||
| 68 | [ENVIRONMENT_ID, environmentEntry(ENVIRONMENT_ID, "Environment")], | ||
| 69 | [OTHER_ENVIRONMENT_ID, environmentEntry(OTHER_ENVIRONMENT_ID, "Other environment")], | ||
| 70 | ]), | ||
| 71 | }); | ||
| 72 | const summaryAtom = createEnvironmentShellSummaryAtom({ | ||
| 73 | catalogValueAtom, | ||
| 74 | shellStateValueAtom: shellStateAtoms, | ||
| 75 | }); | ||
| 76 | const serverConfigsAtom = createEnvironmentServerConfigsAtom({ | ||
| 77 | catalogValueAtom, | ||
| 78 | configValueAtom: configAtoms, | ||
| 79 | }); | ||
| 80 | |||
| 81 | return { | ||
| 82 | registry: AtomRegistry.make(), | ||
| 83 | shellStateAtom: shellStateAtoms, | ||
| 84 | configAtom: configAtoms, | ||
| 85 | summaryAtom, | ||
| 86 | serverConfigsAtom, | ||
| 87 | }; | ||
| 88 | } | ||
| 89 | |||
| 90 | describe("environment shell projections", () => { | ||
| 91 | it("summarizes shell state and preserves identity when only irrelevant snapshot data changes", () => { | ||
| 92 | const harness = makeHarness(); | ||
| 93 | const summary = harness.registry.get(harness.summaryAtom); | ||
| 94 | |||
| 95 | expect(summary).toEqual({ | ||
| 96 | hasSnapshot: true, | ||
| 97 | hasSynchronizingShell: true, | ||
| 98 | hasCachedShell: true, | ||
| 99 | hasLiveShell: false, | ||
| 100 | firstError: "Retrying.", | ||
| 101 | latestSnapshotUpdatedAt: "2026-06-02T00:00:00.000Z", | ||
| 102 | }); | ||
| 103 | |||
| 104 | harness.registry.set( | ||
| 105 | harness.shellStateAtom(ENVIRONMENT_ID), | ||
| 106 | shellState({ | ||
| 107 | status: "cached", | ||
| 108 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 109 | snapshotSequence: 2, | ||
| 110 | }), | ||
| 111 | ); | ||
| 112 | |||
| 113 | expect(harness.registry.get(harness.summaryAtom)).toBe(summary); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("preserves server-config map identity until a config reference changes", () => { | ||
| 117 | const harness = makeHarness(); | ||
| 118 | const empty = harness.registry.get(harness.serverConfigsAtom); | ||
| 119 | const config = { cwd: "/repo" } as ServerConfig; | ||
| 120 | |||
| 121 | harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); | ||
| 122 | const withConfig = harness.registry.get(harness.serverConfigsAtom); | ||
| 123 | |||
| 124 | expect(withConfig).not.toBe(empty); | ||
| 125 | expect(withConfig.get(ENVIRONMENT_ID)).toBe(config); | ||
| 126 | |||
| 127 | harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); | ||
| 128 | expect(harness.registry.get(harness.serverConfigsAtom)).toBe(withConfig); | ||
| 129 | }); | ||
| 130 | }); | ||
packages/client-runtime/src/state/shell.ts created+314| ... | @@ -0,0 +1,314 @@ | ||
| 1 | import { | ||
| 2 | ORCHESTRATION_WS_METHODS, | ||
| 3 | type EnvironmentId, | ||
| 4 | type OrchestrationShellSnapshot, | ||
| 5 | type OrchestrationShellStreamItem, | ||
| 6 | type ServerConfig, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Cause from "effect/Cause"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | import * as Queue from "effect/Queue"; | ||
| 12 | import * as Stream from "effect/Stream"; | ||
| 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 14 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 15 | |||
| 16 | import { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 17 | import { connectionProjectionPhase } from "../connection/model.ts"; | ||
| 18 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | ||
| 19 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | ||
| 20 | import { subscribe } from "../rpc/client.ts"; | ||
| 21 | import { applyShellStreamEvent } from "./shellReducer.ts"; | ||
| 22 | import type { EnvironmentCatalogState } from "./connections.ts"; | ||
| 23 | import { followStreamInEnvironment } from "./runtime.ts"; | ||
| 24 | |||
| 25 | export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; | ||
| 26 | |||
| 27 | export interface EnvironmentShellState { | ||
| 28 | readonly snapshot: Option.Option<OrchestrationShellSnapshot>; | ||
| 29 | readonly status: EnvironmentShellStatus; | ||
| 30 | readonly error: Option.Option<string>; | ||
| 31 | } | ||
| 32 | |||
| 33 | const EMPTY_SHELL_STATE: EnvironmentShellState = { | ||
| 34 | snapshot: Option.none(), | ||
| 35 | status: "empty", | ||
| 36 | error: Option.none(), | ||
| 37 | }; | ||
| 38 | |||
| 39 | function shellStatusForSnapshot( | ||
| 40 | snapshot: Option.Option<OrchestrationShellSnapshot>, | ||
| 41 | ): EnvironmentShellStatus { | ||
| 42 | return Option.isSome(snapshot) ? "cached" : "empty"; | ||
| 43 | } | ||
| 44 | |||
| 45 | function formatShellError(error: unknown): string { | ||
| 46 | return error instanceof Error && error.message.trim().length > 0 | ||
| 47 | ? error.message | ||
| 48 | : "Could not synchronize environment data."; | ||
| 49 | } | ||
| 50 | |||
| 51 | export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { | ||
| 52 | const supervisor = yield* EnvironmentSupervisor; | ||
| 53 | const cache = yield* EnvironmentCacheStore; | ||
| 54 | const environmentId = supervisor.target.environmentId; | ||
| 55 | const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( | ||
| 56 | Effect.catch((error) => | ||
| 57 | Effect.logWarning("Could not load cached environment shell.").pipe( | ||
| 58 | Effect.annotateLogs({ | ||
| 59 | environmentId, | ||
| 60 | error: error.message, | ||
| 61 | }), | ||
| 62 | Effect.as(Option.none<OrchestrationShellSnapshot>()), | ||
| 63 | ), | ||
| 64 | ), | ||
| 65 | ); | ||
| 66 | const state = yield* SubscriptionRef.make<EnvironmentShellState>({ | ||
| 67 | snapshot: cachedSnapshot, | ||
| 68 | status: shellStatusForSnapshot(cachedSnapshot), | ||
| 69 | error: Option.none(), | ||
| 70 | }); | ||
| 71 | const persistence = yield* Queue.sliding<OrchestrationShellSnapshot>(1); | ||
| 72 | |||
| 73 | const persist = Effect.fn("EnvironmentShellState.persist")(function* ( | ||
| 74 | snapshot: OrchestrationShellSnapshot, | ||
| 75 | ) { | ||
| 76 | yield* cache.saveShell(environmentId, snapshot).pipe( | ||
| 77 | Effect.catch((error) => | ||
| 78 | Effect.logWarning("Could not persist environment shell cache.").pipe( | ||
| 79 | Effect.annotateLogs({ | ||
| 80 | environmentId, | ||
| 81 | error: error.message, | ||
| 82 | }), | ||
| 83 | ), | ||
| 84 | ), | ||
| 85 | ); | ||
| 86 | }); | ||
| 87 | |||
| 88 | yield* Stream.fromQueue(persistence).pipe( | ||
| 89 | Stream.debounce("500 millis"), | ||
| 90 | Stream.runForEach(persist), | ||
| 91 | Effect.forkScoped, | ||
| 92 | ); | ||
| 93 | |||
| 94 | const setDisconnected = SubscriptionRef.update(state, (current) => ({ | ||
| 95 | ...current, | ||
| 96 | status: shellStatusForSnapshot(current.snapshot), | ||
| 97 | })); | ||
| 98 | const setSynchronizing = SubscriptionRef.update(state, (current) => ({ | ||
| 99 | ...current, | ||
| 100 | status: "synchronizing" as const, | ||
| 101 | error: Option.none(), | ||
| 102 | })); | ||
| 103 | const setReady = SubscriptionRef.update(state, (current) => | ||
| 104 | current.status === "live" | ||
| 105 | ? current | ||
| 106 | : { | ||
| 107 | ...current, | ||
| 108 | status: "synchronizing" as const, | ||
| 109 | error: Option.none(), | ||
| 110 | }, | ||
| 111 | ); | ||
| 112 | const setStreamError = (error: unknown) => | ||
| 113 | SubscriptionRef.update(state, (current) => ({ | ||
| 114 | ...current, | ||
| 115 | status: shellStatusForSnapshot(current.snapshot), | ||
| 116 | error: Option.some(formatShellError(error)), | ||
| 117 | })); | ||
| 118 | |||
| 119 | const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( | ||
| 120 | item: OrchestrationShellStreamItem, | ||
| 121 | ) { | ||
| 122 | const current = yield* SubscriptionRef.get(state); | ||
| 123 | const nextSnapshot = | ||
| 124 | item.kind === "snapshot" | ||
| 125 | ? item.snapshot | ||
| 126 | : Option.match(current.snapshot, { | ||
| 127 | onNone: () => null, | ||
| 128 | onSome: (snapshot) => | ||
| 129 | item.sequence > snapshot.snapshotSequence | ||
| 130 | ? applyShellStreamEvent(snapshot, item) | ||
| 131 | : snapshot, | ||
| 132 | }); | ||
| 133 | if (nextSnapshot === null) { | ||
| 134 | return; | ||
| 135 | } | ||
| 136 | |||
| 137 | yield* SubscriptionRef.set(state, { | ||
| 138 | snapshot: Option.some(nextSnapshot), | ||
| 139 | status: "live", | ||
| 140 | error: Option.none(), | ||
| 141 | }); | ||
| 142 | yield* Queue.offer(persistence, nextSnapshot); | ||
| 143 | }); | ||
| 144 | |||
| 145 | yield* subscribe( | ||
| 146 | ORCHESTRATION_WS_METHODS.subscribeShell, | ||
| 147 | {}, | ||
| 148 | { | ||
| 149 | onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), | ||
| 150 | }, | ||
| 151 | ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); | ||
| 152 | yield* SubscriptionRef.changes(supervisor.state).pipe( | ||
| 153 | Stream.runForEach((connectionState) => { | ||
| 154 | switch (connectionProjectionPhase(connectionState)) { | ||
| 155 | case "synchronizing": | ||
| 156 | return setSynchronizing; | ||
| 157 | case "disconnected": | ||
| 158 | return setDisconnected; | ||
| 159 | case "ready": | ||
| 160 | return setReady; | ||
| 161 | } | ||
| 162 | }), | ||
| 163 | Effect.forkScoped, | ||
| 164 | ); | ||
| 165 | |||
| 166 | return state; | ||
| 167 | }); | ||
| 168 | |||
| 169 | export function shellStateChanges(environmentId: EnvironmentId) { | ||
| 170 | return followStreamInEnvironment( | ||
| 171 | environmentId, | ||
| 172 | Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))), | ||
| 173 | ); | ||
| 174 | } | ||
| 175 | |||
| 176 | export interface EnvironmentShellSummary { | ||
| 177 | readonly hasSnapshot: boolean; | ||
| 178 | readonly hasSynchronizingShell: boolean; | ||
| 179 | readonly hasCachedShell: boolean; | ||
| 180 | readonly hasLiveShell: boolean; | ||
| 181 | readonly firstError: string | null; | ||
| 182 | readonly latestSnapshotUpdatedAt: string | null; | ||
| 183 | } | ||
| 184 | |||
| 185 | const EMPTY_ENVIRONMENT_SHELL_SUMMARY: EnvironmentShellSummary = Object.freeze({ | ||
| 186 | hasSnapshot: false, | ||
| 187 | hasSynchronizingShell: false, | ||
| 188 | hasCachedShell: false, | ||
| 189 | hasLiveShell: false, | ||
| 190 | firstError: null, | ||
| 191 | latestSnapshotUpdatedAt: null, | ||
| 192 | }); | ||
| 193 | |||
| 194 | const EMPTY_SERVER_CONFIGS: ReadonlyMap<EnvironmentId, ServerConfig> = new Map(); | ||
| 195 | |||
| 196 | function shellSummariesEqual( | ||
| 197 | left: EnvironmentShellSummary, | ||
| 198 | right: EnvironmentShellSummary, | ||
| 199 | ): boolean { | ||
| 200 | return ( | ||
| 201 | left.hasSnapshot === right.hasSnapshot && | ||
| 202 | left.hasSynchronizingShell === right.hasSynchronizingShell && | ||
| 203 | left.hasCachedShell === right.hasCachedShell && | ||
| 204 | left.hasLiveShell === right.hasLiveShell && | ||
| 205 | left.firstError === right.firstError && | ||
| 206 | left.latestSnapshotUpdatedAt === right.latestSnapshotUpdatedAt | ||
| 207 | ); | ||
| 208 | } | ||
| 209 | |||
| 210 | function mapsEqual<K, V>(left: ReadonlyMap<K, V>, right: ReadonlyMap<K, V>): boolean { | ||
| 211 | if (left.size !== right.size) { | ||
| 212 | return false; | ||
| 213 | } | ||
| 214 | for (const [key, value] of left) { | ||
| 215 | if (right.get(key) !== value) { | ||
| 216 | return false; | ||
| 217 | } | ||
| 218 | } | ||
| 219 | return true; | ||
| 220 | } | ||
| 221 | |||
| 222 | export function createEnvironmentShellSummaryAtom(input: { | ||
| 223 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; | ||
| 224 | readonly shellStateValueAtom: (environmentId: EnvironmentId) => Atom.Atom<EnvironmentShellState>; | ||
| 225 | }) { | ||
| 226 | let previousSummary = EMPTY_ENVIRONMENT_SHELL_SUMMARY; | ||
| 227 | return Atom.make((get) => { | ||
| 228 | let hasSnapshot = false; | ||
| 229 | let hasSynchronizingShell = false; | ||
| 230 | let hasCachedShell = false; | ||
| 231 | let hasLiveShell = false; | ||
| 232 | let firstError: string | null = null; | ||
| 233 | let latestSnapshotUpdatedAt: string | null = null; | ||
| 234 | |||
| 235 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | ||
| 236 | const state = get(input.shellStateValueAtom(environmentId)); | ||
| 237 | hasSynchronizingShell ||= state.status === "synchronizing"; | ||
| 238 | hasCachedShell ||= state.status === "cached"; | ||
| 239 | hasLiveShell ||= state.status === "live"; | ||
| 240 | if (firstError === null) { | ||
| 241 | firstError = Option.getOrNull(state.error); | ||
| 242 | } | ||
| 243 | if (Option.isNone(state.snapshot)) { | ||
| 244 | continue; | ||
| 245 | } | ||
| 246 | hasSnapshot = true; | ||
| 247 | const updatedAt = state.snapshot.value.updatedAt; | ||
| 248 | if (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt) { | ||
| 249 | latestSnapshotUpdatedAt = updatedAt; | ||
| 250 | } | ||
| 251 | } | ||
| 252 | |||
| 253 | const next: EnvironmentShellSummary = { | ||
| 254 | hasSnapshot, | ||
| 255 | hasSynchronizingShell, | ||
| 256 | hasCachedShell, | ||
| 257 | hasLiveShell, | ||
| 258 | firstError, | ||
| 259 | latestSnapshotUpdatedAt, | ||
| 260 | }; | ||
| 261 | if (shellSummariesEqual(previousSummary, next)) { | ||
| 262 | return previousSummary; | ||
| 263 | } | ||
| 264 | previousSummary = next; | ||
| 265 | return previousSummary; | ||
| 266 | }).pipe(Atom.withLabel("environment-shell-summary")); | ||
| 267 | } | ||
| 268 | |||
| 269 | export function createEnvironmentServerConfigsAtom(input: { | ||
| 270 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; | ||
| 271 | readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>; | ||
| 272 | }) { | ||
| 273 | let previousServerConfigs = EMPTY_SERVER_CONFIGS; | ||
| 274 | return Atom.make((get) => { | ||
| 275 | const next = new Map<EnvironmentId, ServerConfig>(); | ||
| 276 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | ||
| 277 | const config = get(input.configValueAtom(environmentId)); | ||
| 278 | if (config !== null) { | ||
| 279 | next.set(environmentId, config); | ||
| 280 | } | ||
| 281 | } | ||
| 282 | if (mapsEqual(previousServerConfigs, next)) { | ||
| 283 | return previousServerConfigs; | ||
| 284 | } | ||
| 285 | previousServerConfigs = next; | ||
| 286 | return previousServerConfigs; | ||
| 287 | }).pipe(Atom.withLabel("environment-server-configs")); | ||
| 288 | } | ||
| 289 | |||
| 290 | export function createEnvironmentShellAtoms<R, E>( | ||
| 291 | runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>, | ||
| 292 | ) { | ||
| 293 | const stateAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 294 | runtime.atom(shellStateChanges(environmentId), { | ||
| 295 | initialValue: EMPTY_SHELL_STATE, | ||
| 296 | }), | ||
| 297 | ); | ||
| 298 | |||
| 299 | const stateValueAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 300 | Atom.make((get) => | ||
| 301 | Option.getOrElse(AsyncResult.value(get(stateAtom(environmentId))), () => EMPTY_SHELL_STATE), | ||
| 302 | ).pipe(Atom.withLabel(`environment-shell-state-value:${environmentId}`)), | ||
| 303 | ); | ||
| 304 | |||
| 305 | return { | ||
| 306 | stateAtom, | ||
| 307 | stateValueAtom, | ||
| 308 | }; | ||
| 309 | } | ||
| 310 | |||
| 311 | export * from "./models.ts"; | ||
| 312 | export * from "./shellCommands.ts"; | ||
| 313 | export * from "./shellReducer.ts"; | ||
| 314 | export * from "./snapshots.ts"; | ||
packages/client-runtime/src/state/shellCommands.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import { createEnvironmentRpcCommand } from "./runtime.ts"; | ||
| 5 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 6 | |||
| 7 | export function createShellEnvironmentAtoms<R, E>( | ||
| 8 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 9 | ) { | ||
| 10 | return { | ||
| 11 | openInEditor: createEnvironmentRpcCommand(runtime, { | ||
| 12 | label: "environment-data:shell:open-in-editor", | ||
| 13 | tag: WS_METHODS.shellOpenInEditor, | ||
| 14 | }), | ||
| 15 | }; | ||
| 16 | } | ||
packages/client-runtime/src/state/shellReducer.test.ts created+163| ... | @@ -0,0 +1,163 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import { applyShellStreamEvent } from "./shellReducer.ts"; | ||
| 7 | |||
| 8 | const baseSnapshot: OrchestrationShellSnapshot = { | ||
| 9 | snapshotSequence: 0, | ||
| 10 | projects: [], | ||
| 11 | threads: [], | ||
| 12 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 13 | }; | ||
| 14 | |||
| 15 | const stubProject = { | ||
| 16 | id: ProjectId.make("project-1"), | ||
| 17 | title: "Test Project", | ||
| 18 | workspaceRoot: "/workspace/test", | ||
| 19 | repositoryIdentity: null, | ||
| 20 | defaultModelSelection: null, | ||
| 21 | scripts: [], | ||
| 22 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 23 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 24 | } as const; | ||
| 25 | |||
| 26 | const stubThread = { | ||
| 27 | id: ThreadId.make("thread-1"), | ||
| 28 | projectId: ProjectId.make("project-1"), | ||
| 29 | title: "Test Thread", | ||
| 30 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 31 | runtimeMode: "full-access" as const, | ||
| 32 | interactionMode: "default" as const, | ||
| 33 | branch: null, | ||
| 34 | worktreePath: null, | ||
| 35 | latestTurn: null, | ||
| 36 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 37 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 38 | archivedAt: null, | ||
| 39 | latestUserMessageAt: null, | ||
| 40 | hasPendingApprovals: false, | ||
| 41 | hasPendingUserInput: false, | ||
| 42 | hasActionableProposedPlan: false, | ||
| 43 | session: null, | ||
| 44 | } as const; | ||
| 45 | |||
| 46 | describe("applyShellStreamEvent", () => { | ||
| 47 | describe("project-upserted", () => { | ||
| 48 | it("adds a new project", () => { | ||
| 49 | const event: OrchestrationShellStreamEvent = { | ||
| 50 | kind: "project-upserted", | ||
| 51 | sequence: 1, | ||
| 52 | project: stubProject, | ||
| 53 | }; | ||
| 54 | |||
| 55 | const next = applyShellStreamEvent(baseSnapshot, event); | ||
| 56 | |||
| 57 | expect(next.projects).toHaveLength(1); | ||
| 58 | expect(next.projects[0]?.id).toBe("project-1"); | ||
| 59 | expect(next.snapshotSequence).toBe(1); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("updates an existing project", () => { | ||
| 63 | const snapshotWithProject: OrchestrationShellSnapshot = { | ||
| 64 | ...baseSnapshot, | ||
| 65 | projects: [stubProject], | ||
| 66 | }; | ||
| 67 | |||
| 68 | const updatedProject = { ...stubProject, title: "Updated Title" }; | ||
| 69 | const event: OrchestrationShellStreamEvent = { | ||
| 70 | kind: "project-upserted", | ||
| 71 | sequence: 2, | ||
| 72 | project: updatedProject, | ||
| 73 | }; | ||
| 74 | |||
| 75 | const next = applyShellStreamEvent(snapshotWithProject, event); | ||
| 76 | |||
| 77 | expect(next.projects).toHaveLength(1); | ||
| 78 | expect(next.projects[0]?.title).toBe("Updated Title"); | ||
| 79 | expect(next.snapshotSequence).toBe(2); | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | describe("project-removed", () => { | ||
| 84 | it("removes a project by id", () => { | ||
| 85 | const snapshotWithProject: OrchestrationShellSnapshot = { | ||
| 86 | ...baseSnapshot, | ||
| 87 | projects: [stubProject], | ||
| 88 | }; | ||
| 89 | |||
| 90 | const event: OrchestrationShellStreamEvent = { | ||
| 91 | kind: "project-removed", | ||
| 92 | sequence: 3, | ||
| 93 | projectId: ProjectId.make("project-1"), | ||
| 94 | }; | ||
| 95 | |||
| 96 | const next = applyShellStreamEvent(snapshotWithProject, event); | ||
| 97 | |||
| 98 | expect(next.projects).toHaveLength(0); | ||
| 99 | expect(next.snapshotSequence).toBe(3); | ||
| 100 | }); | ||
| 101 | }); | ||
| 102 | |||
| 103 | describe("thread-upserted", () => { | ||
| 104 | it("adds a new thread", () => { | ||
| 105 | const event: OrchestrationShellStreamEvent = { | ||
| 106 | kind: "thread-upserted", | ||
| 107 | sequence: 4, | ||
| 108 | thread: stubThread, | ||
| 109 | }; | ||
| 110 | |||
| 111 | const next = applyShellStreamEvent(baseSnapshot, event); | ||
| 112 | |||
| 113 | expect(next.threads).toHaveLength(1); | ||
| 114 | expect(next.threads[0]?.id).toBe("thread-1"); | ||
| 115 | expect(next.snapshotSequence).toBe(4); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("updates an existing thread", () => { | ||
| 119 | const snapshotWithThread: OrchestrationShellSnapshot = { | ||
| 120 | ...baseSnapshot, | ||
| 121 | threads: [stubThread], | ||
| 122 | }; | ||
| 123 | |||
| 124 | const updatedThread = { ...stubThread, title: "Updated Thread" }; | ||
| 125 | const event: OrchestrationShellStreamEvent = { | ||
| 126 | kind: "thread-upserted", | ||
| 127 | sequence: 5, | ||
| 128 | thread: updatedThread, | ||
| 129 | }; | ||
| 130 | |||
| 131 | const next = applyShellStreamEvent(snapshotWithThread, event); | ||
| 132 | |||
| 133 | expect(next.threads).toHaveLength(1); | ||
| 134 | expect(next.threads[0]?.title).toBe("Updated Thread"); | ||
| 135 | }); | ||
| 136 | }); | ||
| 137 | |||
| 138 | describe("thread-removed", () => { | ||
| 139 | it("removes a thread by id", () => { | ||
| 140 | const snapshotWithThread: OrchestrationShellSnapshot = { | ||
| 141 | ...baseSnapshot, | ||
| 142 | threads: [stubThread], | ||
| 143 | }; | ||
| 144 | |||
| 145 | const event: OrchestrationShellStreamEvent = { | ||
| 146 | kind: "thread-removed", | ||
| 147 | sequence: 6, | ||
| 148 | threadId: ThreadId.make("thread-1"), | ||
| 149 | }; | ||
| 150 | |||
| 151 | const next = applyShellStreamEvent(snapshotWithThread, event); | ||
| 152 | |||
| 153 | expect(next.threads).toHaveLength(0); | ||
| 154 | expect(next.snapshotSequence).toBe(6); | ||
| 155 | }); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it("returns original snapshot for unrecognized event kinds", () => { | ||
| 159 | const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any; | ||
| 160 | const next = applyShellStreamEvent(baseSnapshot, unknownEvent); | ||
| 161 | expect(next).toBe(baseSnapshot); | ||
| 162 | }); | ||
| 163 | }); | ||
packages/client-runtime/src/state/shellReducer.ts created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | import * as Arr from "effect/Array"; | ||
| 2 | import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | /** | ||
| 5 | * Reduce a single shell stream event into an existing snapshot, returning a new | ||
| 6 | * snapshot with the event's changes applied. This is a pure reducer that both | ||
| 7 | * web and mobile can use to keep their local shell snapshot in sync. | ||
| 8 | * | ||
| 9 | * Returns the original snapshot reference unchanged if the event is not | ||
| 10 | * recognized (forward-compatible). | ||
| 11 | */ | ||
| 12 | export function applyShellStreamEvent( | ||
| 13 | snapshot: OrchestrationShellSnapshot, | ||
| 14 | event: OrchestrationShellStreamEvent, | ||
| 15 | ): OrchestrationShellSnapshot { | ||
| 16 | switch (event.kind) { | ||
| 17 | case "project-upserted": { | ||
| 18 | const projects = snapshot.projects.some((p) => p.id === event.project.id) | ||
| 19 | ? Arr.map(snapshot.projects, (p) => (p.id === event.project.id ? event.project : p)) | ||
| 20 | : Arr.append(snapshot.projects, event.project); | ||
| 21 | return { ...snapshot, projects, snapshotSequence: event.sequence }; | ||
| 22 | } | ||
| 23 | case "project-removed": | ||
| 24 | return { | ||
| 25 | ...snapshot, | ||
| 26 | projects: Arr.filter(snapshot.projects, (p) => p.id !== event.projectId), | ||
| 27 | snapshotSequence: event.sequence, | ||
| 28 | }; | ||
| 29 | case "thread-upserted": { | ||
| 30 | const threads = snapshot.threads.some((t) => t.id === event.thread.id) | ||
| 31 | ? Arr.map(snapshot.threads, (t) => (t.id === event.thread.id ? event.thread : t)) | ||
| 32 | : Arr.append(snapshot.threads, event.thread); | ||
| 33 | return { ...snapshot, threads, snapshotSequence: event.sequence }; | ||
| 34 | } | ||
| 35 | case "thread-removed": | ||
| 36 | return { | ||
| 37 | ...snapshot, | ||
| 38 | threads: Arr.filter(snapshot.threads, (t) => t.id !== event.threadId), | ||
| 39 | snapshotSequence: event.sequence, | ||
| 40 | }; | ||
| 41 | default: | ||
| 42 | return snapshot; | ||
| 43 | } | ||
| 44 | } | ||
packages/client-runtime/src/state/snapshots.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import type { EnvironmentShellState } from "./shell.ts"; | ||
| 6 | |||
| 7 | export function createEnvironmentSnapshotAtom<E>( | ||
| 8 | shellStateAtom: ( | ||
| 9 | environmentId: EnvironmentId, | ||
| 10 | ) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, E>>, | ||
| 11 | ) { | ||
| 12 | return Atom.family((environmentId: EnvironmentId) => | ||
| 13 | Atom.make((get): OrchestrationShellSnapshot | null => | ||
| 14 | Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), { | ||
| 15 | onNone: () => null, | ||
| 16 | onSome: (state) => Option.getOrNull(state.snapshot), | ||
| 17 | }), | ||
| 18 | ).pipe(Atom.withLabel(`environment-snapshot:${environmentId}`)), | ||
| 19 | ); | ||
| 20 | } | ||
packages/client-runtime/src/state/sourceControl.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | createAtomCommandScheduler, | ||
| 6 | createEnvironmentRpcCommand, | ||
| 7 | createEnvironmentRpcQueryAtomFamily, | ||
| 8 | } from "./runtime.ts"; | ||
| 9 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 10 | import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; | ||
| 11 | |||
| 12 | export function createSourceControlEnvironmentAtoms<R, E>( | ||
| 13 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 14 | ) { | ||
| 15 | const commandScheduler = createAtomCommandScheduler(); | ||
| 16 | return { | ||
| 17 | discovery: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 18 | label: "environment-data:server:source-control-discovery", | ||
| 19 | tag: WS_METHODS.serverDiscoverSourceControl, | ||
| 20 | }), | ||
| 21 | repository: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 22 | label: "environment-data:source-control:repository", | ||
| 23 | tag: WS_METHODS.sourceControlLookupRepository, | ||
| 24 | }), | ||
| 25 | cloneRepository: createEnvironmentRpcCommand(runtime, { | ||
| 26 | label: "environment-data:source-control:clone-repository", | ||
| 27 | tag: WS_METHODS.sourceControlCloneRepository, | ||
| 28 | scheduler: commandScheduler, | ||
| 29 | concurrency: { | ||
| 30 | mode: "serial", | ||
| 31 | key: ({ environmentId }) => environmentId, | ||
| 32 | }, | ||
| 33 | }), | ||
| 34 | publishRepository: createEnvironmentRpcCommand(runtime, { | ||
| 35 | label: "environment-data:source-control:publish-repository", | ||
| 36 | tag: WS_METHODS.sourceControlPublishRepository, | ||
| 37 | scheduler: vcsCommandScheduler, | ||
| 38 | concurrency: vcsCommandConcurrency, | ||
| 39 | }), | ||
| 40 | }; | ||
| 41 | } | ||
packages/client-runtime/src/state/terminal.ts created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | import { type TerminalSummary, WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import * as Stream from "effect/Stream"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | createAtomCommandScheduler, | ||
| 7 | createEnvironmentRpcCommand, | ||
| 8 | createEnvironmentRpcSubscriptionAtomFamily, | ||
| 9 | createEnvironmentSubscriptionAtomFamily, | ||
| 10 | } from "./runtime.ts"; | ||
| 11 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 12 | import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; | ||
| 13 | import { | ||
| 14 | applyTerminalAttachStreamEvent, | ||
| 15 | applyTerminalMetadataStreamEvent, | ||
| 16 | EMPTY_TERMINAL_BUFFER_STATE, | ||
| 17 | } from "./terminalSession.ts"; | ||
| 18 | |||
| 19 | export function createTerminalEnvironmentAtoms<R, E>( | ||
| 20 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 21 | ) { | ||
| 22 | const lifecycleScheduler = createAtomCommandScheduler(); | ||
| 23 | const resizeScheduler = createAtomCommandScheduler(); | ||
| 24 | const terminalThreadKey = ({ | ||
| 25 | environmentId, | ||
| 26 | input, | ||
| 27 | }: { | ||
| 28 | readonly environmentId: string; | ||
| 29 | readonly input: { readonly threadId: string; readonly terminalId?: string | undefined }; | ||
| 30 | }) => JSON.stringify([environmentId, input.threadId]); | ||
| 31 | const terminalSessionKey = ({ | ||
| 32 | environmentId, | ||
| 33 | input, | ||
| 34 | }: { | ||
| 35 | readonly environmentId: string; | ||
| 36 | readonly input: { readonly threadId: string; readonly terminalId?: string | undefined }; | ||
| 37 | }) => JSON.stringify([environmentId, input.threadId, input.terminalId ?? null]); | ||
| 38 | const lifecycleConcurrency = { mode: "serial" as const, key: terminalThreadKey }; | ||
| 39 | return { | ||
| 40 | attach: createEnvironmentSubscriptionAtomFamily(runtime, { | ||
| 41 | label: "environment-data:terminal:attach", | ||
| 42 | subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.terminalAttach>) => | ||
| 43 | subscribe(WS_METHODS.terminalAttach, input).pipe( | ||
| 44 | Stream.scan(EMPTY_TERMINAL_BUFFER_STATE, applyTerminalAttachStreamEvent), | ||
| 45 | ), | ||
| 46 | }), | ||
| 47 | events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | ||
| 48 | label: "environment-data:terminal:events", | ||
| 49 | tag: WS_METHODS.subscribeTerminalEvents, | ||
| 50 | }), | ||
| 51 | metadata: createEnvironmentSubscriptionAtomFamily(runtime, { | ||
| 52 | label: "environment-data:terminal:metadata", | ||
| 53 | subscribe: (_input: null) => | ||
| 54 | subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( | ||
| 55 | Stream.scan([] as ReadonlyArray<TerminalSummary>, applyTerminalMetadataStreamEvent), | ||
| 56 | ), | ||
| 57 | }), | ||
| 58 | open: createEnvironmentRpcCommand(runtime, { | ||
| 59 | label: "environment-data:terminal:open", | ||
| 60 | tag: WS_METHODS.terminalOpen, | ||
| 61 | scheduler: lifecycleScheduler, | ||
| 62 | concurrency: lifecycleConcurrency, | ||
| 63 | }), | ||
| 64 | write: createEnvironmentRpcCommand(runtime, { | ||
| 65 | label: "environment-data:terminal:write", | ||
| 66 | tag: WS_METHODS.terminalWrite, | ||
| 67 | }), | ||
| 68 | resize: createEnvironmentRpcCommand(runtime, { | ||
| 69 | label: "environment-data:terminal:resize", | ||
| 70 | tag: WS_METHODS.terminalResize, | ||
| 71 | scheduler: resizeScheduler, | ||
| 72 | concurrency: { mode: "latest", key: terminalSessionKey }, | ||
| 73 | }), | ||
| 74 | clear: createEnvironmentRpcCommand(runtime, { | ||
| 75 | label: "environment-data:terminal:clear", | ||
| 76 | tag: WS_METHODS.terminalClear, | ||
| 77 | scheduler: lifecycleScheduler, | ||
| 78 | concurrency: lifecycleConcurrency, | ||
| 79 | }), | ||
| 80 | restart: createEnvironmentRpcCommand(runtime, { | ||
| 81 | label: "environment-data:terminal:restart", | ||
| 82 | tag: WS_METHODS.terminalRestart, | ||
| 83 | scheduler: lifecycleScheduler, | ||
| 84 | concurrency: lifecycleConcurrency, | ||
| 85 | }), | ||
| 86 | close: createEnvironmentRpcCommand(runtime, { | ||
| 87 | label: "environment-data:terminal:close", | ||
| 88 | tag: WS_METHODS.terminalClose, | ||
| 89 | scheduler: lifecycleScheduler, | ||
| 90 | concurrency: lifecycleConcurrency, | ||
| 91 | }), | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 95 | export * from "./terminalSession.ts"; | ||
packages/client-runtime/src/state/terminalSession.test.ts created+187| ... | @@ -0,0 +1,187 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | applyTerminalAttachStreamEvent, | ||
| 7 | applyTerminalMetadataStreamEvent, | ||
| 8 | combineTerminalSessionState, | ||
| 9 | EMPTY_TERMINAL_BUFFER_STATE, | ||
| 10 | selectRunningSubprocessTerminalIds, | ||
| 11 | } from "./terminalSession.ts"; | ||
| 12 | |||
| 13 | const TARGET = { | ||
| 14 | environmentId: EnvironmentId.make("env-local"), | ||
| 15 | threadId: ThreadId.make("thread-1"), | ||
| 16 | terminalId: "term-1", | ||
| 17 | } as const; | ||
| 18 | |||
| 19 | const BASE_SNAPSHOT: TerminalSessionSnapshot = { | ||
| 20 | threadId: TARGET.threadId, | ||
| 21 | terminalId: TARGET.terminalId, | ||
| 22 | cwd: "/repo", | ||
| 23 | worktreePath: null, | ||
| 24 | status: "running", | ||
| 25 | pid: 123, | ||
| 26 | history: "hello", | ||
| 27 | exitCode: null, | ||
| 28 | exitSignal: null, | ||
| 29 | label: "Terminal 1", | ||
| 30 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 31 | }; | ||
| 32 | |||
| 33 | describe("terminal session reducers", () => { | ||
| 34 | it("prefers live attach status over stale metadata after the attach stream starts", () => { | ||
| 35 | const summary = applyTerminalMetadataStreamEvent([], { | ||
| 36 | type: "snapshot", | ||
| 37 | terminals: [ | ||
| 38 | { | ||
| 39 | threadId: BASE_SNAPSHOT.threadId, | ||
| 40 | terminalId: BASE_SNAPSHOT.terminalId, | ||
| 41 | cwd: BASE_SNAPSHOT.cwd, | ||
| 42 | worktreePath: BASE_SNAPSHOT.worktreePath, | ||
| 43 | status: "running", | ||
| 44 | pid: BASE_SNAPSHOT.pid, | ||
| 45 | exitCode: BASE_SNAPSHOT.exitCode, | ||
| 46 | exitSignal: BASE_SNAPSHOT.exitSignal, | ||
| 47 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 48 | hasRunningSubprocess: false, | ||
| 49 | label: BASE_SNAPSHOT.label, | ||
| 50 | }, | ||
| 51 | ], | ||
| 52 | })[0]!; | ||
| 53 | const attached = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { | ||
| 54 | type: "error", | ||
| 55 | threadId: TARGET.threadId, | ||
| 56 | terminalId: TARGET.terminalId, | ||
| 57 | message: "Terminal disconnected.", | ||
| 58 | }); | ||
| 59 | |||
| 60 | expect(combineTerminalSessionState(summary, attached)).toMatchObject({ | ||
| 61 | status: "error", | ||
| 62 | error: "Terminal disconnected.", | ||
| 63 | version: 1, | ||
| 64 | }); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("uses metadata status before an attach stream has emitted", () => { | ||
| 68 | const summary = applyTerminalMetadataStreamEvent([], { | ||
| 69 | type: "snapshot", | ||
| 70 | terminals: [ | ||
| 71 | { | ||
| 72 | threadId: BASE_SNAPSHOT.threadId, | ||
| 73 | terminalId: BASE_SNAPSHOT.terminalId, | ||
| 74 | cwd: BASE_SNAPSHOT.cwd, | ||
| 75 | worktreePath: BASE_SNAPSHOT.worktreePath, | ||
| 76 | status: "running", | ||
| 77 | pid: BASE_SNAPSHOT.pid, | ||
| 78 | exitCode: BASE_SNAPSHOT.exitCode, | ||
| 79 | exitSignal: BASE_SNAPSHOT.exitSignal, | ||
| 80 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 81 | hasRunningSubprocess: false, | ||
| 82 | label: BASE_SNAPSHOT.label, | ||
| 83 | }, | ||
| 84 | ], | ||
| 85 | })[0]!; | ||
| 86 | |||
| 87 | expect(combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE).status).toBe( | ||
| 88 | "running", | ||
| 89 | ); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("does not treat an idle running shell as a running subprocess", () => { | ||
| 93 | const idleSession = { | ||
| 94 | target: TARGET, | ||
| 95 | state: { | ||
| 96 | ...combineTerminalSessionState(null, EMPTY_TERMINAL_BUFFER_STATE), | ||
| 97 | status: "running" as const, | ||
| 98 | hasRunningSubprocess: false, | ||
| 99 | }, | ||
| 100 | }; | ||
| 101 | const activeSession = { | ||
| 102 | target: { ...TARGET, terminalId: "term-2" }, | ||
| 103 | state: { | ||
| 104 | ...idleSession.state, | ||
| 105 | hasRunningSubprocess: true, | ||
| 106 | }, | ||
| 107 | }; | ||
| 108 | |||
| 109 | expect(selectRunningSubprocessTerminalIds([idleSession, activeSession])).toEqual(["term-2"]); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it("reduces attach snapshots and output without an imperative session manager", () => { | ||
| 113 | const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { | ||
| 114 | type: "snapshot", | ||
| 115 | snapshot: BASE_SNAPSHOT, | ||
| 116 | }); | ||
| 117 | const output = applyTerminalAttachStreamEvent( | ||
| 118 | snapshot, | ||
| 119 | { | ||
| 120 | type: "output", | ||
| 121 | threadId: TARGET.threadId, | ||
| 122 | terminalId: TARGET.terminalId, | ||
| 123 | data: " world", | ||
| 124 | }, | ||
| 125 | 8, | ||
| 126 | ); | ||
| 127 | |||
| 128 | expect(output).toMatchObject({ | ||
| 129 | buffer: "lo world", | ||
| 130 | status: "running", | ||
| 131 | error: null, | ||
| 132 | version: 2, | ||
| 133 | }); | ||
| 134 | }); | ||
| 135 | |||
| 136 | it("reduces terminal metadata snapshots, upserts, and removals", () => { | ||
| 137 | const initial = applyTerminalMetadataStreamEvent([], { | ||
| 138 | type: "snapshot", | ||
| 139 | terminals: [ | ||
| 140 | { | ||
| 141 | threadId: BASE_SNAPSHOT.threadId, | ||
| 142 | terminalId: BASE_SNAPSHOT.terminalId, | ||
| 143 | cwd: BASE_SNAPSHOT.cwd, | ||
| 144 | worktreePath: BASE_SNAPSHOT.worktreePath, | ||
| 145 | status: BASE_SNAPSHOT.status, | ||
| 146 | pid: BASE_SNAPSHOT.pid, | ||
| 147 | exitCode: BASE_SNAPSHOT.exitCode, | ||
| 148 | exitSignal: BASE_SNAPSHOT.exitSignal, | ||
| 149 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 150 | hasRunningSubprocess: false, | ||
| 151 | label: BASE_SNAPSHOT.label, | ||
| 152 | }, | ||
| 153 | ], | ||
| 154 | }); | ||
| 155 | const updated = applyTerminalMetadataStreamEvent(initial, { | ||
| 156 | type: "upsert", | ||
| 157 | terminal: { | ||
| 158 | ...initial[0]!, | ||
| 159 | hasRunningSubprocess: true, | ||
| 160 | }, | ||
| 161 | }); | ||
| 162 | const removed = applyTerminalMetadataStreamEvent(updated, { | ||
| 163 | type: "remove", | ||
| 164 | threadId: TARGET.threadId, | ||
| 165 | terminalId: TARGET.terminalId, | ||
| 166 | }); | ||
| 167 | |||
| 168 | expect(updated).toHaveLength(1); | ||
| 169 | expect(updated[0]?.hasRunningSubprocess).toBe(true); | ||
| 170 | expect(removed).toEqual([]); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("caps retained output by UTF-8 byte length", () => { | ||
| 174 | const state = applyTerminalAttachStreamEvent( | ||
| 175 | EMPTY_TERMINAL_BUFFER_STATE, | ||
| 176 | { | ||
| 177 | type: "output", | ||
| 178 | threadId: TARGET.threadId, | ||
| 179 | terminalId: TARGET.terminalId, | ||
| 180 | data: "🙂🙂", | ||
| 181 | }, | ||
| 182 | 4, | ||
| 183 | ); | ||
| 184 | |||
| 185 | expect(state.buffer).toBe("🙂"); | ||
| 186 | }); | ||
| 187 | }); | ||
packages/client-runtime/src/state/terminalSession.ts created+194| ... | @@ -0,0 +1,194 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | TerminalAttachStreamEvent, | ||
| 4 | TerminalMetadataStreamEvent, | ||
| 5 | TerminalSessionSnapshot, | ||
| 6 | TerminalSummary, | ||
| 7 | ThreadId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | export interface TerminalSessionState { | ||
| 11 | readonly summary: TerminalSummary | null; | ||
| 12 | readonly buffer: string; | ||
| 13 | readonly status: TerminalSessionSnapshot["status"] | "closed"; | ||
| 14 | readonly error: string | null; | ||
| 15 | readonly hasRunningSubprocess: boolean; | ||
| 16 | readonly updatedAt: string | null; | ||
| 17 | readonly version: number; | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface TerminalBufferState { | ||
| 21 | readonly buffer: string; | ||
| 22 | readonly status: TerminalSessionSnapshot["status"] | "closed"; | ||
| 23 | readonly error: string | null; | ||
| 24 | readonly updatedAt: string | null; | ||
| 25 | readonly version: number; | ||
| 26 | } | ||
| 27 | |||
| 28 | export interface KnownTerminalSessionTarget { | ||
| 29 | readonly environmentId: EnvironmentId; | ||
| 30 | readonly threadId: ThreadId; | ||
| 31 | readonly terminalId: string; | ||
| 32 | } | ||
| 33 | |||
| 34 | export interface KnownTerminalSession { | ||
| 35 | readonly target: KnownTerminalSessionTarget; | ||
| 36 | readonly state: TerminalSessionState; | ||
| 37 | } | ||
| 38 | |||
| 39 | export function selectRunningSubprocessTerminalIds( | ||
| 40 | sessions: ReadonlyArray<KnownTerminalSession>, | ||
| 41 | ): ReadonlyArray<string> { | ||
| 42 | return sessions | ||
| 43 | .filter((session) => session.state.hasRunningSubprocess) | ||
| 44 | .map((session) => session.target.terminalId); | ||
| 45 | } | ||
| 46 | |||
| 47 | export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ | ||
| 48 | buffer: "", | ||
| 49 | status: "closed", | ||
| 50 | error: null, | ||
| 51 | updatedAt: null, | ||
| 52 | version: 0, | ||
| 53 | }); | ||
| 54 | |||
| 55 | export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({ | ||
| 56 | summary: null, | ||
| 57 | buffer: "", | ||
| 58 | status: "closed", | ||
| 59 | error: null, | ||
| 60 | hasRunningSubprocess: false, | ||
| 61 | updatedAt: null, | ||
| 62 | version: 0, | ||
| 63 | }); | ||
| 64 | |||
| 65 | export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; | ||
| 66 | const textEncoder = new TextEncoder(); | ||
| 67 | const textDecoder = new TextDecoder(); | ||
| 68 | |||
| 69 | function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { | ||
| 70 | if (maxBufferBytes <= 0) { | ||
| 71 | return ""; | ||
| 72 | } | ||
| 73 | |||
| 74 | const encoded = textEncoder.encode(buffer); | ||
| 75 | if (encoded.byteLength <= maxBufferBytes) { | ||
| 76 | return buffer; | ||
| 77 | } | ||
| 78 | |||
| 79 | let start = encoded.byteLength - maxBufferBytes; | ||
| 80 | while (start < encoded.length) { | ||
| 81 | const byte = encoded[start]; | ||
| 82 | if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { | ||
| 83 | break; | ||
| 84 | } | ||
| 85 | start += 1; | ||
| 86 | } | ||
| 87 | |||
| 88 | return textDecoder.decode(encoded.subarray(start)); | ||
| 89 | } | ||
| 90 | |||
| 91 | export function terminalBufferStateFromSnapshot( | ||
| 92 | snapshot: TerminalSessionSnapshot, | ||
| 93 | maxBufferBytes: number, | ||
| 94 | ): TerminalBufferState { | ||
| 95 | return { | ||
| 96 | buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), | ||
| 97 | status: snapshot.status, | ||
| 98 | error: null, | ||
| 99 | updatedAt: snapshot.updatedAt, | ||
| 100 | version: 1, | ||
| 101 | }; | ||
| 102 | } | ||
| 103 | |||
| 104 | function latestTimestamp(left: string | null, right: string | null): string | null { | ||
| 105 | if (left === null) return right; | ||
| 106 | if (right === null) return left; | ||
| 107 | return Date.parse(left) >= Date.parse(right) ? left : right; | ||
| 108 | } | ||
| 109 | |||
| 110 | export function combineTerminalSessionState( | ||
| 111 | summary: TerminalSummary | null, | ||
| 112 | buffer: TerminalBufferState, | ||
| 113 | ): TerminalSessionState { | ||
| 114 | return { | ||
| 115 | summary, | ||
| 116 | buffer: buffer.buffer, | ||
| 117 | status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), | ||
| 118 | error: buffer.error, | ||
| 119 | hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, | ||
| 120 | updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), | ||
| 121 | version: buffer.version, | ||
| 122 | }; | ||
| 123 | } | ||
| 124 | |||
| 125 | export function applyTerminalAttachStreamEvent( | ||
| 126 | current: TerminalBufferState, | ||
| 127 | event: TerminalAttachStreamEvent, | ||
| 128 | maxBufferBytes = DEFAULT_MAX_TERMINAL_BUFFER_BYTES, | ||
| 129 | ): TerminalBufferState { | ||
| 130 | switch (event.type) { | ||
| 131 | case "snapshot": | ||
| 132 | case "restarted": | ||
| 133 | return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); | ||
| 134 | case "output": | ||
| 135 | return { | ||
| 136 | ...current, | ||
| 137 | buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), | ||
| 138 | status: current.status === "closed" ? "running" : current.status, | ||
| 139 | error: null, | ||
| 140 | version: current.version + 1, | ||
| 141 | }; | ||
| 142 | case "cleared": | ||
| 143 | return { | ||
| 144 | ...current, | ||
| 145 | buffer: "", | ||
| 146 | error: null, | ||
| 147 | version: current.version + 1, | ||
| 148 | }; | ||
| 149 | case "exited": | ||
| 150 | return { | ||
| 151 | ...current, | ||
| 152 | status: "exited", | ||
| 153 | error: null, | ||
| 154 | version: current.version + 1, | ||
| 155 | }; | ||
| 156 | case "closed": | ||
| 157 | return { | ||
| 158 | ...current, | ||
| 159 | status: "closed", | ||
| 160 | error: null, | ||
| 161 | version: current.version + 1, | ||
| 162 | }; | ||
| 163 | case "error": | ||
| 164 | return { | ||
| 165 | ...current, | ||
| 166 | status: "error", | ||
| 167 | error: event.message, | ||
| 168 | version: current.version + 1, | ||
| 169 | }; | ||
| 170 | case "activity": | ||
| 171 | return current; | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | export function applyTerminalMetadataStreamEvent( | ||
| 176 | current: ReadonlyArray<TerminalSummary>, | ||
| 177 | event: TerminalMetadataStreamEvent, | ||
| 178 | ): ReadonlyArray<TerminalSummary> { | ||
| 179 | if (event.type === "snapshot") { | ||
| 180 | return event.terminals; | ||
| 181 | } | ||
| 182 | if (event.type === "remove") { | ||
| 183 | return current.filter( | ||
| 184 | (terminal) => | ||
| 185 | terminal.threadId !== event.threadId || terminal.terminalId !== event.terminalId, | ||
| 186 | ); | ||
| 187 | } | ||
| 188 | const next = current.filter( | ||
| 189 | (terminal) => | ||
| 190 | terminal.threadId !== event.terminal.threadId || | ||
| 191 | terminal.terminalId !== event.terminal.terminalId, | ||
| 192 | ); | ||
| 193 | return [...next, event.terminal]; | ||
| 194 | } | ||
packages/client-runtime/src/state/threadCommands.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import * as Crypto from "effect/Crypto"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts"; | ||
| 5 | import { | ||
| 6 | type ArchiveThreadInput, | ||
| 7 | type CreateThreadInput, | ||
| 8 | type DeleteThreadInput, | ||
| 9 | type InterruptThreadTurnInput, | ||
| 10 | type RespondToThreadApprovalInput, | ||
| 11 | type RespondToThreadUserInputInput, | ||
| 12 | type RevertThreadCheckpointInput, | ||
| 13 | type SetThreadInteractionModeInput, | ||
| 14 | type SetThreadRuntimeModeInput, | ||
| 15 | type StartThreadTurnInput, | ||
| 16 | type StopThreadSessionInput, | ||
| 17 | type UnarchiveThreadInput, | ||
| 18 | type UpdateThreadMetadataInput, | ||
| 19 | archiveThread, | ||
| 20 | createThread, | ||
| 21 | deleteThread, | ||
| 22 | interruptThreadTurn, | ||
| 23 | respondToThreadApproval, | ||
| 24 | respondToThreadUserInput, | ||
| 25 | revertThreadCheckpoint, | ||
| 26 | setThreadInteractionMode, | ||
| 27 | setThreadRuntimeMode, | ||
| 28 | startThreadTurn, | ||
| 29 | stopThreadSession, | ||
| 30 | unarchiveThread, | ||
| 31 | updateThreadMetadata, | ||
| 32 | } from "../operations/commands.ts"; | ||
| 33 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 34 | |||
| 35 | export type { | ||
| 36 | ArchiveThreadInput, | ||
| 37 | CreateThreadInput, | ||
| 38 | DeleteThreadInput, | ||
| 39 | InterruptThreadTurnInput, | ||
| 40 | RespondToThreadApprovalInput, | ||
| 41 | RespondToThreadUserInputInput, | ||
| 42 | RevertThreadCheckpointInput, | ||
| 43 | SetThreadInteractionModeInput, | ||
| 44 | SetThreadRuntimeModeInput, | ||
| 45 | StartThreadTurnInput, | ||
| 46 | StopThreadSessionInput, | ||
| 47 | UnarchiveThreadInput, | ||
| 48 | UpdateThreadMetadataInput, | ||
| 49 | } from "../operations/commands.ts"; | ||
| 50 | |||
| 51 | export function createThreadEnvironmentAtoms<R, E>( | ||
| 52 | runtime: Atom.AtomRuntime<EnvironmentRegistry | Crypto.Crypto | R, E>, | ||
| 53 | ) { | ||
| 54 | const scheduler = createAtomCommandScheduler(); | ||
| 55 | const concurrency = { | ||
| 56 | mode: "serial" as const, | ||
| 57 | key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => | ||
| 58 | JSON.stringify([environmentId, input.threadId]), | ||
| 59 | }; | ||
| 60 | return { | ||
| 61 | create: createEnvironmentCommand(runtime, { | ||
| 62 | label: "environment-data:commands:thread:create", | ||
| 63 | execute: (input: CreateThreadInput) => createThread(input), | ||
| 64 | scheduler, | ||
| 65 | concurrency, | ||
| 66 | }), | ||
| 67 | delete: createEnvironmentCommand(runtime, { | ||
| 68 | label: "environment-data:commands:thread:delete", | ||
| 69 | execute: (input: DeleteThreadInput) => deleteThread(input), | ||
| 70 | scheduler, | ||
| 71 | concurrency, | ||
| 72 | }), | ||
| 73 | archive: createEnvironmentCommand(runtime, { | ||
| 74 | label: "environment-data:commands:thread:archive", | ||
| 75 | execute: (input: ArchiveThreadInput) => archiveThread(input), | ||
| 76 | scheduler, | ||
| 77 | concurrency, | ||
| 78 | }), | ||
| 79 | unarchive: createEnvironmentCommand(runtime, { | ||
| 80 | label: "environment-data:commands:thread:unarchive", | ||
| 81 | execute: (input: UnarchiveThreadInput) => unarchiveThread(input), | ||
| 82 | scheduler, | ||
| 83 | concurrency, | ||
| 84 | }), | ||
| 85 | updateMetadata: createEnvironmentCommand(runtime, { | ||
| 86 | label: "environment-data:commands:thread:update-metadata", | ||
| 87 | execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), | ||
| 88 | scheduler, | ||
| 89 | concurrency, | ||
| 90 | }), | ||
| 91 | setRuntimeMode: createEnvironmentCommand(runtime, { | ||
| 92 | label: "environment-data:commands:thread:set-runtime-mode", | ||
| 93 | execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), | ||
| 94 | scheduler, | ||
| 95 | concurrency, | ||
| 96 | }), | ||
| 97 | setInteractionMode: createEnvironmentCommand(runtime, { | ||
| 98 | label: "environment-data:commands:thread:set-interaction-mode", | ||
| 99 | execute: (input: SetThreadInteractionModeInput) => setThreadInteractionMode(input), | ||
| 100 | scheduler, | ||
| 101 | concurrency, | ||
| 102 | }), | ||
| 103 | startTurn: createEnvironmentCommand(runtime, { | ||
| 104 | label: "environment-data:commands:thread:start-turn", | ||
| 105 | execute: (input: StartThreadTurnInput) => startThreadTurn(input), | ||
| 106 | scheduler, | ||
| 107 | concurrency, | ||
| 108 | }), | ||
| 109 | interruptTurn: createEnvironmentCommand(runtime, { | ||
| 110 | label: "environment-data:commands:thread:interrupt-turn", | ||
| 111 | execute: (input: InterruptThreadTurnInput) => interruptThreadTurn(input), | ||
| 112 | scheduler, | ||
| 113 | concurrency, | ||
| 114 | }), | ||
| 115 | respondToApproval: createEnvironmentCommand(runtime, { | ||
| 116 | label: "environment-data:commands:thread:respond-to-approval", | ||
| 117 | execute: (input: RespondToThreadApprovalInput) => respondToThreadApproval(input), | ||
| 118 | scheduler, | ||
| 119 | concurrency, | ||
| 120 | }), | ||
| 121 | respondToUserInput: createEnvironmentCommand(runtime, { | ||
| 122 | label: "environment-data:commands:thread:respond-to-user-input", | ||
| 123 | execute: (input: RespondToThreadUserInputInput) => respondToThreadUserInput(input), | ||
| 124 | scheduler, | ||
| 125 | concurrency, | ||
| 126 | }), | ||
| 127 | revertCheckpoint: createEnvironmentCommand(runtime, { | ||
| 128 | label: "environment-data:commands:thread:revert-checkpoint", | ||
| 129 | execute: (input: RevertThreadCheckpointInput) => revertThreadCheckpoint(input), | ||
| 130 | scheduler, | ||
| 131 | concurrency, | ||
| 132 | }), | ||
| 133 | stopSession: createEnvironmentCommand(runtime, { | ||
| 134 | label: "environment-data:commands:thread:stop-session", | ||
| 135 | execute: (input: StopThreadSessionInput) => stopThreadSession(input), | ||
| 136 | scheduler, | ||
| 137 | concurrency, | ||
| 138 | }), | ||
| 139 | }; | ||
| 140 | } | ||
packages/client-runtime/src/state/threadDetail.ts created+185| ... | @@ -0,0 +1,185 @@ | ||
| 1 | import type { | ||
| 2 | OrchestrationCheckpointSummary, | ||
| 3 | OrchestrationLatestTurn, | ||
| 4 | OrchestrationMessage, | ||
| 5 | OrchestrationProposedPlan, | ||
| 6 | OrchestrationSession, | ||
| 7 | OrchestrationThread, | ||
| 8 | OrchestrationThreadActivity, | ||
| 9 | ScopedThreadRef, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 13 | |||
| 14 | import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; | ||
| 15 | import { scopeThread } from "./models.ts"; | ||
| 16 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; | ||
| 17 | import { parseThreadKey, threadKey } from "./entities.ts"; | ||
| 18 | |||
| 19 | const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]); | ||
| 20 | const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]); | ||
| 21 | const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]); | ||
| 22 | const EMPTY_CHECKPOINTS: ReadonlyArray<OrchestrationCheckpointSummary> = Object.freeze([]); | ||
| 23 | const THREAD_DETAIL_IDLE_TTL_MS = 5 * 60_000; | ||
| 24 | |||
| 25 | /** | ||
| 26 | * Combine detail-only collections with the shell's authoritative thread metadata. | ||
| 27 | * | ||
| 28 | * Shell and detail subscriptions are intentionally independent. A cached detail can | ||
| 29 | * therefore briefly outlive a newer shell snapshot after reconnecting. Workspace | ||
| 30 | * consumers must use the shell branch/worktree/project fields so they do not target | ||
| 31 | * a stale checkout while retaining messages, activities, plans, and checkpoints | ||
| 32 | * from the detail subscription. | ||
| 33 | */ | ||
| 34 | export function mergeEnvironmentThread( | ||
| 35 | detail: EnvironmentThread | null, | ||
| 36 | shell: EnvironmentThreadShell | null, | ||
| 37 | ): EnvironmentThread | null { | ||
| 38 | if (detail === null || shell === null) { | ||
| 39 | return detail; | ||
| 40 | } | ||
| 41 | if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) { | ||
| 42 | return detail; | ||
| 43 | } | ||
| 44 | |||
| 45 | return { | ||
| 46 | ...detail, | ||
| 47 | environmentId: shell.environmentId, | ||
| 48 | id: shell.id, | ||
| 49 | projectId: shell.projectId, | ||
| 50 | title: shell.title, | ||
| 51 | modelSelection: shell.modelSelection, | ||
| 52 | runtimeMode: shell.runtimeMode, | ||
| 53 | interactionMode: shell.interactionMode, | ||
| 54 | branch: shell.branch, | ||
| 55 | worktreePath: shell.worktreePath, | ||
| 56 | latestTurn: shell.latestTurn, | ||
| 57 | createdAt: shell.createdAt, | ||
| 58 | updatedAt: shell.updatedAt, | ||
| 59 | archivedAt: shell.archivedAt, | ||
| 60 | session: shell.session, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | export function createEnvironmentThreadDetailAtoms<E>( | ||
| 65 | threadStateAtom: ( | ||
| 66 | environmentId: ScopedThreadRef["environmentId"], | ||
| 67 | threadId: ScopedThreadRef["threadId"], | ||
| 68 | ) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentThreadState, E>>, | ||
| 69 | ) { | ||
| 70 | const threadStateValueAtomFamily = Atom.family((key: string) => { | ||
| 71 | const ref = parseThreadKey(key); | ||
| 72 | return Atom.make((get) => | ||
| 73 | Option.getOrElse( | ||
| 74 | AsyncResult.value(get(threadStateAtom(ref.environmentId, ref.threadId))), | ||
| 75 | () => EMPTY_ENVIRONMENT_THREAD_STATE, | ||
| 76 | ), | ||
| 77 | ).pipe( | ||
| 78 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 79 | Atom.withLabel(`environment-thread-state-value:${key}`), | ||
| 80 | ); | ||
| 81 | }); | ||
| 82 | |||
| 83 | const threadDetailAtomFamily = Atom.family((key: string) => { | ||
| 84 | const ref = parseThreadKey(key); | ||
| 85 | let previousSource: OrchestrationThread | null = null; | ||
| 86 | let previousValue: EnvironmentThread | null = null; | ||
| 87 | return Atom.make((get) => { | ||
| 88 | const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); | ||
| 89 | if (source === previousSource) { | ||
| 90 | return previousValue; | ||
| 91 | } | ||
| 92 | previousSource = source; | ||
| 93 | previousValue = source === null ? null : scopeThread(ref.environmentId, source); | ||
| 94 | return previousValue; | ||
| 95 | }).pipe( | ||
| 96 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 97 | Atom.withLabel(`environment-thread-detail:${key}`), | ||
| 98 | ); | ||
| 99 | }); | ||
| 100 | |||
| 101 | const threadStatusAtomFamily = Atom.family((key: string) => | ||
| 102 | Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe( | ||
| 103 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 104 | Atom.withLabel(`environment-thread-status:${key}`), | ||
| 105 | ), | ||
| 106 | ); | ||
| 107 | |||
| 108 | const threadErrorAtomFamily = Atom.family((key: string) => | ||
| 109 | Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( | ||
| 110 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 111 | Atom.withLabel(`environment-thread-error:${key}`), | ||
| 112 | ), | ||
| 113 | ); | ||
| 114 | |||
| 115 | const threadMessagesAtomFamily = Atom.family((key: string) => | ||
| 116 | Atom.make( | ||
| 117 | (get): ReadonlyArray<OrchestrationMessage> => | ||
| 118 | get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES, | ||
| 119 | ).pipe( | ||
| 120 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 121 | Atom.withLabel(`environment-thread-messages:${key}`), | ||
| 122 | ), | ||
| 123 | ); | ||
| 124 | |||
| 125 | const threadActivitiesAtomFamily = Atom.family((key: string) => | ||
| 126 | Atom.make( | ||
| 127 | (get): ReadonlyArray<OrchestrationThreadActivity> => | ||
| 128 | get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES, | ||
| 129 | ).pipe( | ||
| 130 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 131 | Atom.withLabel(`environment-thread-activities:${key}`), | ||
| 132 | ), | ||
| 133 | ); | ||
| 134 | |||
| 135 | const threadProposedPlansAtomFamily = Atom.family((key: string) => | ||
| 136 | Atom.make( | ||
| 137 | (get): ReadonlyArray<OrchestrationProposedPlan> => | ||
| 138 | get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS, | ||
| 139 | ).pipe( | ||
| 140 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 141 | Atom.withLabel(`environment-thread-proposed-plans:${key}`), | ||
| 142 | ), | ||
| 143 | ); | ||
| 144 | |||
| 145 | const threadCheckpointsAtomFamily = Atom.family((key: string) => | ||
| 146 | Atom.make( | ||
| 147 | (get): ReadonlyArray<OrchestrationCheckpointSummary> => | ||
| 148 | get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS, | ||
| 149 | ).pipe( | ||
| 150 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 151 | Atom.withLabel(`environment-thread-checkpoints:${key}`), | ||
| 152 | ), | ||
| 153 | ); | ||
| 154 | |||
| 155 | const threadSessionAtomFamily = Atom.family((key: string) => | ||
| 156 | Atom.make( | ||
| 157 | (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null, | ||
| 158 | ).pipe( | ||
| 159 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 160 | Atom.withLabel(`environment-thread-session:${key}`), | ||
| 161 | ), | ||
| 162 | ); | ||
| 163 | |||
| 164 | const threadLatestTurnAtomFamily = Atom.family((key: string) => | ||
| 165 | Atom.make( | ||
| 166 | (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null, | ||
| 167 | ).pipe( | ||
| 168 | Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), | ||
| 169 | Atom.withLabel(`environment-thread-latest-turn:${key}`), | ||
| 170 | ), | ||
| 171 | ); | ||
| 172 | |||
| 173 | return { | ||
| 174 | stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)), | ||
| 175 | detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)), | ||
| 176 | statusAtom: (ref: ScopedThreadRef) => threadStatusAtomFamily(threadKey(ref)), | ||
| 177 | errorAtom: (ref: ScopedThreadRef) => threadErrorAtomFamily(threadKey(ref)), | ||
| 178 | messagesAtom: (ref: ScopedThreadRef) => threadMessagesAtomFamily(threadKey(ref)), | ||
| 179 | activitiesAtom: (ref: ScopedThreadRef) => threadActivitiesAtomFamily(threadKey(ref)), | ||
| 180 | proposedPlansAtom: (ref: ScopedThreadRef) => threadProposedPlansAtomFamily(threadKey(ref)), | ||
| 181 | checkpointsAtom: (ref: ScopedThreadRef) => threadCheckpointsAtomFamily(threadKey(ref)), | ||
| 182 | sessionAtom: (ref: ScopedThreadRef) => threadSessionAtomFamily(threadKey(ref)), | ||
| 183 | latestTurnAtom: (ref: ScopedThreadRef) => threadLatestTurnAtomFamily(threadKey(ref)), | ||
| 184 | }; | ||
| 185 | } | ||
packages/client-runtime/src/state/threadReducer.test.ts created+700| ... | @@ -0,0 +1,700 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CheckpointRef, | ||
| 5 | EventId, | ||
| 6 | MessageId, | ||
| 7 | ProjectId, | ||
| 8 | ProviderInstanceId, | ||
| 9 | ThreadId, | ||
| 10 | TurnId, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import type { OrchestrationThread } from "@t3tools/contracts"; | ||
| 13 | |||
| 14 | import { applyThreadDetailEvent } from "./threadReducer.ts"; | ||
| 15 | |||
| 16 | const baseEventFields = { | ||
| 17 | eventId: EventId.make("event-1"), | ||
| 18 | commandId: null, | ||
| 19 | causationEventId: null, | ||
| 20 | correlationId: null, | ||
| 21 | metadata: {}, | ||
| 22 | } as const; | ||
| 23 | |||
| 24 | const baseThread: OrchestrationThread = { | ||
| 25 | id: ThreadId.make("thread-1"), | ||
| 26 | projectId: ProjectId.make("project-1"), | ||
| 27 | title: "Test Thread", | ||
| 28 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 29 | runtimeMode: "full-access", | ||
| 30 | interactionMode: "default", | ||
| 31 | branch: null, | ||
| 32 | worktreePath: null, | ||
| 33 | latestTurn: null, | ||
| 34 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 35 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 36 | archivedAt: null, | ||
| 37 | deletedAt: null, | ||
| 38 | messages: [], | ||
| 39 | proposedPlans: [], | ||
| 40 | activities: [], | ||
| 41 | checkpoints: [], | ||
| 42 | session: null, | ||
| 43 | }; | ||
| 44 | |||
| 45 | describe("applyThreadDetailEvent", () => { | ||
| 46 | describe("project events", () => { | ||
| 47 | it("returns unchanged for project.created", () => { | ||
| 48 | const result = applyThreadDetailEvent(baseThread, { | ||
| 49 | ...baseEventFields, | ||
| 50 | sequence: 1, | ||
| 51 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 52 | aggregateKind: "project", | ||
| 53 | aggregateId: ProjectId.make("project-1"), | ||
| 54 | type: "project.created", | ||
| 55 | payload: { | ||
| 56 | projectId: ProjectId.make("project-1"), | ||
| 57 | title: "T3 Code", | ||
| 58 | workspaceRoot: "/repo", | ||
| 59 | repositoryIdentity: null, | ||
| 60 | defaultModelSelection: null, | ||
| 61 | scripts: [], | ||
| 62 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 63 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 64 | deletedAt: null, | ||
| 65 | }, | ||
| 66 | } as any); | ||
| 67 | expect(result.kind).toBe("unchanged"); | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | |||
| 71 | describe("thread.created", () => { | ||
| 72 | it("creates a fresh thread", () => { | ||
| 73 | const result = applyThreadDetailEvent(baseThread, { | ||
| 74 | ...baseEventFields, | ||
| 75 | sequence: 1, | ||
| 76 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 77 | aggregateKind: "thread", | ||
| 78 | aggregateId: ThreadId.make("thread-2"), | ||
| 79 | type: "thread.created", | ||
| 80 | payload: { | ||
| 81 | threadId: ThreadId.make("thread-2"), | ||
| 82 | projectId: ProjectId.make("project-1"), | ||
| 83 | title: "New Thread", | ||
| 84 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 85 | runtimeMode: "full-access", | ||
| 86 | interactionMode: "default", | ||
| 87 | branch: "main", | ||
| 88 | worktreePath: null, | ||
| 89 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 90 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 91 | }, | ||
| 92 | }); | ||
| 93 | |||
| 94 | expect(result.kind).toBe("updated"); | ||
| 95 | if (result.kind === "updated") { | ||
| 96 | expect(result.thread.id).toBe("thread-2"); | ||
| 97 | expect(result.thread.title).toBe("New Thread"); | ||
| 98 | expect(result.thread.branch).toBe("main"); | ||
| 99 | expect(result.thread.messages).toEqual([]); | ||
| 100 | expect(result.thread.session).toBeNull(); | ||
| 101 | } | ||
| 102 | }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | describe("thread.deleted", () => { | ||
| 106 | it("returns deleted signal", () => { | ||
| 107 | const result = applyThreadDetailEvent(baseThread, { | ||
| 108 | ...baseEventFields, | ||
| 109 | sequence: 2, | ||
| 110 | occurredAt: "2026-04-01T02:00:00.000Z", | ||
| 111 | aggregateKind: "thread", | ||
| 112 | aggregateId: ThreadId.make("thread-1"), | ||
| 113 | type: "thread.deleted", | ||
| 114 | payload: { | ||
| 115 | threadId: ThreadId.make("thread-1"), | ||
| 116 | deletedAt: "2026-04-01T02:00:00.000Z", | ||
| 117 | }, | ||
| 118 | }); | ||
| 119 | expect(result.kind).toBe("deleted"); | ||
| 120 | }); | ||
| 121 | }); | ||
| 122 | |||
| 123 | describe("thread.archived / thread.unarchived", () => { | ||
| 124 | it("sets archivedAt", () => { | ||
| 125 | const result = applyThreadDetailEvent(baseThread, { | ||
| 126 | ...baseEventFields, | ||
| 127 | sequence: 3, | ||
| 128 | occurredAt: "2026-04-01T03:00:00.000Z", | ||
| 129 | aggregateKind: "thread", | ||
| 130 | aggregateId: ThreadId.make("thread-1"), | ||
| 131 | type: "thread.archived", | ||
| 132 | payload: { | ||
| 133 | threadId: ThreadId.make("thread-1"), | ||
| 134 | archivedAt: "2026-04-01T03:00:00.000Z", | ||
| 135 | updatedAt: "2026-04-01T03:00:00.000Z", | ||
| 136 | }, | ||
| 137 | }); | ||
| 138 | |||
| 139 | expect(result.kind).toBe("updated"); | ||
| 140 | if (result.kind === "updated") { | ||
| 141 | expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z"); | ||
| 142 | } | ||
| 143 | }); | ||
| 144 | |||
| 145 | it("clears archivedAt", () => { | ||
| 146 | const archivedThread = { ...baseThread, archivedAt: "2026-04-01T03:00:00.000Z" }; | ||
| 147 | const result = applyThreadDetailEvent(archivedThread, { | ||
| 148 | ...baseEventFields, | ||
| 149 | sequence: 4, | ||
| 150 | occurredAt: "2026-04-01T04:00:00.000Z", | ||
| 151 | aggregateKind: "thread", | ||
| 152 | aggregateId: ThreadId.make("thread-1"), | ||
| 153 | type: "thread.unarchived", | ||
| 154 | payload: { | ||
| 155 | threadId: ThreadId.make("thread-1"), | ||
| 156 | updatedAt: "2026-04-01T04:00:00.000Z", | ||
| 157 | }, | ||
| 158 | }); | ||
| 159 | |||
| 160 | expect(result.kind).toBe("updated"); | ||
| 161 | if (result.kind === "updated") { | ||
| 162 | expect(result.thread.archivedAt).toBeNull(); | ||
| 163 | } | ||
| 164 | }); | ||
| 165 | }); | ||
| 166 | |||
| 167 | describe("thread.meta-updated", () => { | ||
| 168 | it("patches title and branch", () => { | ||
| 169 | const result = applyThreadDetailEvent(baseThread, { | ||
| 170 | ...baseEventFields, | ||
| 171 | sequence: 5, | ||
| 172 | occurredAt: "2026-04-01T05:00:00.000Z", | ||
| 173 | aggregateKind: "thread", | ||
| 174 | aggregateId: ThreadId.make("thread-1"), | ||
| 175 | type: "thread.meta-updated", | ||
| 176 | payload: { | ||
| 177 | threadId: ThreadId.make("thread-1"), | ||
| 178 | title: "Updated Title", | ||
| 179 | branch: "feature/demo", | ||
| 180 | updatedAt: "2026-04-01T05:00:00.000Z", | ||
| 181 | }, | ||
| 182 | }); | ||
| 183 | |||
| 184 | expect(result.kind).toBe("updated"); | ||
| 185 | if (result.kind === "updated") { | ||
| 186 | expect(result.thread.title).toBe("Updated Title"); | ||
| 187 | expect(result.thread.branch).toBe("feature/demo"); | ||
| 188 | // Model selection should be unchanged since it wasn't in the payload | ||
| 189 | expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); | ||
| 190 | } | ||
| 191 | }); | ||
| 192 | }); | ||
| 193 | |||
| 194 | describe("thread.message-sent", () => { | ||
| 195 | it("appends a new message", () => { | ||
| 196 | const result = applyThreadDetailEvent(baseThread, { | ||
| 197 | ...baseEventFields, | ||
| 198 | sequence: 6, | ||
| 199 | occurredAt: "2026-04-01T06:00:00.000Z", | ||
| 200 | aggregateKind: "thread", | ||
| 201 | aggregateId: ThreadId.make("thread-1"), | ||
| 202 | type: "thread.message-sent", | ||
| 203 | payload: { | ||
| 204 | threadId: ThreadId.make("thread-1"), | ||
| 205 | messageId: MessageId.make("msg-1"), | ||
| 206 | role: "user", | ||
| 207 | text: "Hello, world!", | ||
| 208 | turnId: null, | ||
| 209 | streaming: false, | ||
| 210 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 211 | updatedAt: "2026-04-01T06:00:00.000Z", | ||
| 212 | }, | ||
| 213 | }); | ||
| 214 | |||
| 215 | expect(result.kind).toBe("updated"); | ||
| 216 | if (result.kind === "updated") { | ||
| 217 | expect(result.thread.messages).toHaveLength(1); | ||
| 218 | expect(result.thread.messages[0]?.text).toBe("Hello, world!"); | ||
| 219 | } | ||
| 220 | }); | ||
| 221 | |||
| 222 | it("appends text for streaming messages", () => { | ||
| 223 | const threadWithMessage: OrchestrationThread = { | ||
| 224 | ...baseThread, | ||
| 225 | messages: [ | ||
| 226 | { | ||
| 227 | id: MessageId.make("msg-2"), | ||
| 228 | role: "assistant", | ||
| 229 | text: "Hello", | ||
| 230 | turnId: TurnId.make("turn-1"), | ||
| 231 | streaming: true, | ||
| 232 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 233 | updatedAt: "2026-04-01T06:00:00.000Z", | ||
| 234 | }, | ||
| 235 | ], | ||
| 236 | }; | ||
| 237 | |||
| 238 | const result = applyThreadDetailEvent(threadWithMessage, { | ||
| 239 | ...baseEventFields, | ||
| 240 | sequence: 7, | ||
| 241 | occurredAt: "2026-04-01T06:01:00.000Z", | ||
| 242 | aggregateKind: "thread", | ||
| 243 | aggregateId: ThreadId.make("thread-1"), | ||
| 244 | type: "thread.message-sent", | ||
| 245 | payload: { | ||
| 246 | threadId: ThreadId.make("thread-1"), | ||
| 247 | messageId: MessageId.make("msg-2"), | ||
| 248 | role: "assistant", | ||
| 249 | text: ", world!", | ||
| 250 | turnId: TurnId.make("turn-1"), | ||
| 251 | streaming: true, | ||
| 252 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 253 | updatedAt: "2026-04-01T06:01:00.000Z", | ||
| 254 | }, | ||
| 255 | }); | ||
| 256 | |||
| 257 | expect(result.kind).toBe("updated"); | ||
| 258 | if (result.kind === "updated") { | ||
| 259 | expect(result.thread.messages).toHaveLength(1); | ||
| 260 | expect(result.thread.messages[0]?.text).toBe("Hello, world!"); | ||
| 261 | } | ||
| 262 | }); | ||
| 263 | |||
| 264 | it("updates latestTurn for assistant messages with a turn", () => { | ||
| 265 | const result = applyThreadDetailEvent(baseThread, { | ||
| 266 | ...baseEventFields, | ||
| 267 | sequence: 8, | ||
| 268 | occurredAt: "2026-04-01T07:00:00.000Z", | ||
| 269 | aggregateKind: "thread", | ||
| 270 | aggregateId: ThreadId.make("thread-1"), | ||
| 271 | type: "thread.message-sent", | ||
| 272 | payload: { | ||
| 273 | threadId: ThreadId.make("thread-1"), | ||
| 274 | messageId: MessageId.make("msg-3"), | ||
| 275 | role: "assistant", | ||
| 276 | text: "Done.", | ||
| 277 | turnId: TurnId.make("turn-1"), | ||
| 278 | streaming: false, | ||
| 279 | createdAt: "2026-04-01T07:00:00.000Z", | ||
| 280 | updatedAt: "2026-04-01T07:00:00.000Z", | ||
| 281 | }, | ||
| 282 | }); | ||
| 283 | |||
| 284 | expect(result.kind).toBe("updated"); | ||
| 285 | if (result.kind === "updated") { | ||
| 286 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 287 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 288 | expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-3"); | ||
| 289 | } | ||
| 290 | }); | ||
| 291 | |||
| 292 | it("keeps latestTurn running for interim assistant messages while the session runs the turn", () => { | ||
| 293 | const threadWithRunningSession: OrchestrationThread = { | ||
| 294 | ...baseThread, | ||
| 295 | session: { | ||
| 296 | threadId: ThreadId.make("thread-1"), | ||
| 297 | status: "running", | ||
| 298 | providerName: "claude", | ||
| 299 | runtimeMode: "full-access", | ||
| 300 | activeTurnId: TurnId.make("turn-1"), | ||
| 301 | lastError: null, | ||
| 302 | updatedAt: "2026-04-01T06:59:00.000Z", | ||
| 303 | }, | ||
| 304 | latestTurn: { | ||
| 305 | turnId: TurnId.make("turn-1"), | ||
| 306 | state: "running", | ||
| 307 | requestedAt: "2026-04-01T06:59:00.000Z", | ||
| 308 | startedAt: "2026-04-01T06:59:00.000Z", | ||
| 309 | completedAt: null, | ||
| 310 | assistantMessageId: null, | ||
| 311 | }, | ||
| 312 | }; | ||
| 313 | |||
| 314 | const result = applyThreadDetailEvent(threadWithRunningSession, { | ||
| 315 | ...baseEventFields, | ||
| 316 | sequence: 8, | ||
| 317 | occurredAt: "2026-04-01T07:00:00.000Z", | ||
| 318 | aggregateKind: "thread", | ||
| 319 | aggregateId: ThreadId.make("thread-1"), | ||
| 320 | type: "thread.message-sent", | ||
| 321 | payload: { | ||
| 322 | threadId: ThreadId.make("thread-1"), | ||
| 323 | messageId: MessageId.make("msg-3"), | ||
| 324 | role: "assistant", | ||
| 325 | text: "Interim commentary between tool calls.", | ||
| 326 | turnId: TurnId.make("turn-1"), | ||
| 327 | streaming: false, | ||
| 328 | createdAt: "2026-04-01T07:00:00.000Z", | ||
| 329 | updatedAt: "2026-04-01T07:00:00.000Z", | ||
| 330 | }, | ||
| 331 | }); | ||
| 332 | |||
| 333 | expect(result.kind).toBe("updated"); | ||
| 334 | if (result.kind === "updated") { | ||
| 335 | expect(result.thread.latestTurn?.state).toBe("running"); | ||
| 336 | expect(result.thread.latestTurn?.completedAt).toBeNull(); | ||
| 337 | } | ||
| 338 | }); | ||
| 339 | }); | ||
| 340 | |||
| 341 | describe("thread.session-set", () => { | ||
| 342 | it("settles a running latestTurn when the session leaves the running status", () => { | ||
| 343 | const threadWithRunningTurn: OrchestrationThread = { | ||
| 344 | ...baseThread, | ||
| 345 | latestTurn: { | ||
| 346 | turnId: TurnId.make("turn-1"), | ||
| 347 | state: "running", | ||
| 348 | requestedAt: "2026-04-01T07:00:00.000Z", | ||
| 349 | startedAt: "2026-04-01T07:00:00.000Z", | ||
| 350 | completedAt: null, | ||
| 351 | assistantMessageId: MessageId.make("msg-3"), | ||
| 352 | }, | ||
| 353 | }; | ||
| 354 | |||
| 355 | const result = applyThreadDetailEvent(threadWithRunningTurn, { | ||
| 356 | ...baseEventFields, | ||
| 357 | sequence: 9, | ||
| 358 | occurredAt: "2026-04-01T08:00:00.000Z", | ||
| 359 | aggregateKind: "thread", | ||
| 360 | aggregateId: ThreadId.make("thread-1"), | ||
| 361 | type: "thread.session-set", | ||
| 362 | payload: { | ||
| 363 | threadId: ThreadId.make("thread-1"), | ||
| 364 | session: { | ||
| 365 | threadId: ThreadId.make("thread-1"), | ||
| 366 | status: "ready", | ||
| 367 | providerName: "claude", | ||
| 368 | runtimeMode: "full-access", | ||
| 369 | activeTurnId: null, | ||
| 370 | lastError: null, | ||
| 371 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 372 | }, | ||
| 373 | }, | ||
| 374 | }); | ||
| 375 | |||
| 376 | expect(result.kind).toBe("updated"); | ||
| 377 | if (result.kind === "updated") { | ||
| 378 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 379 | expect(result.thread.latestTurn?.completedAt).toBe("2026-04-01T08:00:00.000Z"); | ||
| 380 | } | ||
| 381 | }); | ||
| 382 | |||
| 383 | it("updates session and latestTurn for a running session", () => { | ||
| 384 | const result = applyThreadDetailEvent(baseThread, { | ||
| 385 | ...baseEventFields, | ||
| 386 | sequence: 9, | ||
| 387 | occurredAt: "2026-04-01T08:00:00.000Z", | ||
| 388 | aggregateKind: "thread", | ||
| 389 | aggregateId: ThreadId.make("thread-1"), | ||
| 390 | type: "thread.session-set", | ||
| 391 | payload: { | ||
| 392 | threadId: ThreadId.make("thread-1"), | ||
| 393 | session: { | ||
| 394 | threadId: ThreadId.make("thread-1"), | ||
| 395 | status: "running", | ||
| 396 | providerName: "codex", | ||
| 397 | runtimeMode: "full-access", | ||
| 398 | activeTurnId: TurnId.make("turn-1"), | ||
| 399 | lastError: null, | ||
| 400 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 401 | }, | ||
| 402 | }, | ||
| 403 | }); | ||
| 404 | |||
| 405 | expect(result.kind).toBe("updated"); | ||
| 406 | if (result.kind === "updated") { | ||
| 407 | expect(result.thread.session?.status).toBe("running"); | ||
| 408 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 409 | expect(result.thread.latestTurn?.state).toBe("running"); | ||
| 410 | } | ||
| 411 | }); | ||
| 412 | }); | ||
| 413 | |||
| 414 | describe("thread.session-stop-requested", () => { | ||
| 415 | it("marks session as stopped", () => { | ||
| 416 | const threadWithSession: OrchestrationThread = { | ||
| 417 | ...baseThread, | ||
| 418 | session: { | ||
| 419 | threadId: ThreadId.make("thread-1"), | ||
| 420 | status: "running", | ||
| 421 | providerName: "codex", | ||
| 422 | runtimeMode: "full-access", | ||
| 423 | activeTurnId: TurnId.make("turn-1"), | ||
| 424 | lastError: null, | ||
| 425 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 426 | }, | ||
| 427 | }; | ||
| 428 | |||
| 429 | const result = applyThreadDetailEvent(threadWithSession, { | ||
| 430 | ...baseEventFields, | ||
| 431 | sequence: 10, | ||
| 432 | occurredAt: "2026-04-01T09:00:00.000Z", | ||
| 433 | aggregateKind: "thread", | ||
| 434 | aggregateId: ThreadId.make("thread-1"), | ||
| 435 | type: "thread.session-stop-requested", | ||
| 436 | payload: { | ||
| 437 | threadId: ThreadId.make("thread-1"), | ||
| 438 | createdAt: "2026-04-01T09:00:00.000Z", | ||
| 439 | }, | ||
| 440 | }); | ||
| 441 | |||
| 442 | expect(result.kind).toBe("updated"); | ||
| 443 | if (result.kind === "updated") { | ||
| 444 | expect(result.thread.session?.status).toBe("stopped"); | ||
| 445 | expect(result.thread.session?.activeTurnId).toBeNull(); | ||
| 446 | } | ||
| 447 | }); | ||
| 448 | |||
| 449 | it("returns unchanged when no session exists", () => { | ||
| 450 | const result = applyThreadDetailEvent(baseThread, { | ||
| 451 | ...baseEventFields, | ||
| 452 | sequence: 10, | ||
| 453 | occurredAt: "2026-04-01T09:00:00.000Z", | ||
| 454 | aggregateKind: "thread", | ||
| 455 | aggregateId: ThreadId.make("thread-1"), | ||
| 456 | type: "thread.session-stop-requested", | ||
| 457 | payload: { | ||
| 458 | threadId: ThreadId.make("thread-1"), | ||
| 459 | createdAt: "2026-04-01T09:00:00.000Z", | ||
| 460 | }, | ||
| 461 | }); | ||
| 462 | expect(result.kind).toBe("unchanged"); | ||
| 463 | }); | ||
| 464 | }); | ||
| 465 | |||
| 466 | describe("thread.proposed-plan-upserted", () => { | ||
| 467 | it("adds a proposed plan", () => { | ||
| 468 | const result = applyThreadDetailEvent(baseThread, { | ||
| 469 | ...baseEventFields, | ||
| 470 | sequence: 11, | ||
| 471 | occurredAt: "2026-04-01T10:00:00.000Z", | ||
| 472 | aggregateKind: "thread", | ||
| 473 | aggregateId: ThreadId.make("thread-1"), | ||
| 474 | type: "thread.proposed-plan-upserted", | ||
| 475 | payload: { | ||
| 476 | threadId: ThreadId.make("thread-1"), | ||
| 477 | proposedPlan: { | ||
| 478 | id: "plan-1", | ||
| 479 | turnId: TurnId.make("turn-1"), | ||
| 480 | planMarkdown: "## Plan\n- Do stuff", | ||
| 481 | implementedAt: null, | ||
| 482 | implementationThreadId: null, | ||
| 483 | createdAt: "2026-04-01T10:00:00.000Z", | ||
| 484 | updatedAt: "2026-04-01T10:00:00.000Z", | ||
| 485 | }, | ||
| 486 | }, | ||
| 487 | }); | ||
| 488 | |||
| 489 | expect(result.kind).toBe("updated"); | ||
| 490 | if (result.kind === "updated") { | ||
| 491 | expect(result.thread.proposedPlans).toHaveLength(1); | ||
| 492 | expect(result.thread.proposedPlans[0]?.id).toBe("plan-1"); | ||
| 493 | } | ||
| 494 | }); | ||
| 495 | }); | ||
| 496 | |||
| 497 | describe("thread.activity-appended", () => { | ||
| 498 | it("adds an activity", () => { | ||
| 499 | const result = applyThreadDetailEvent(baseThread, { | ||
| 500 | ...baseEventFields, | ||
| 501 | sequence: 12, | ||
| 502 | occurredAt: "2026-04-01T11:00:00.000Z", | ||
| 503 | aggregateKind: "thread", | ||
| 504 | aggregateId: ThreadId.make("thread-1"), | ||
| 505 | type: "thread.activity-appended", | ||
| 506 | payload: { | ||
| 507 | threadId: ThreadId.make("thread-1"), | ||
| 508 | activity: { | ||
| 509 | id: EventId.make("activity-1"), | ||
| 510 | tone: "tool", | ||
| 511 | kind: "file-edit", | ||
| 512 | summary: "Edited src/index.ts", | ||
| 513 | payload: {}, | ||
| 514 | turnId: TurnId.make("turn-1"), | ||
| 515 | createdAt: "2026-04-01T11:00:00.000Z", | ||
| 516 | }, | ||
| 517 | }, | ||
| 518 | }); | ||
| 519 | |||
| 520 | expect(result.kind).toBe("updated"); | ||
| 521 | if (result.kind === "updated") { | ||
| 522 | expect(result.thread.activities).toHaveLength(1); | ||
| 523 | expect(result.thread.activities[0]?.kind).toBe("file-edit"); | ||
| 524 | } | ||
| 525 | }); | ||
| 526 | |||
| 527 | it("preserves the complete activity history when live events arrive", () => { | ||
| 528 | const existingActivities = Array.from({ length: 129 }, (_, index) => ({ | ||
| 529 | id: EventId.make(`activity-${index}`), | ||
| 530 | tone: "tool" as const, | ||
| 531 | kind: "command", | ||
| 532 | summary: `Ran command ${index}`, | ||
| 533 | payload: {}, | ||
| 534 | turnId: TurnId.make("turn-1"), | ||
| 535 | sequence: index, | ||
| 536 | createdAt: "2026-04-01T11:00:00.000Z", | ||
| 537 | })); | ||
| 538 | const result = applyThreadDetailEvent( | ||
| 539 | { ...baseThread, activities: existingActivities }, | ||
| 540 | { | ||
| 541 | ...baseEventFields, | ||
| 542 | sequence: 130, | ||
| 543 | occurredAt: "2026-04-01T11:01:00.000Z", | ||
| 544 | aggregateKind: "thread", | ||
| 545 | aggregateId: ThreadId.make("thread-1"), | ||
| 546 | type: "thread.activity-appended", | ||
| 547 | payload: { | ||
| 548 | threadId: ThreadId.make("thread-1"), | ||
| 549 | activity: { | ||
| 550 | id: EventId.make("activity-129"), | ||
| 551 | tone: "tool", | ||
| 552 | kind: "command", | ||
| 553 | summary: "Ran command 129", | ||
| 554 | payload: {}, | ||
| 555 | turnId: TurnId.make("turn-1"), | ||
| 556 | sequence: 129, | ||
| 557 | createdAt: "2026-04-01T11:01:00.000Z", | ||
| 558 | }, | ||
| 559 | }, | ||
| 560 | }, | ||
| 561 | ); | ||
| 562 | |||
| 563 | expect(result.kind).toBe("updated"); | ||
| 564 | if (result.kind === "updated") { | ||
| 565 | expect(result.thread.activities).toHaveLength(130); | ||
| 566 | expect(result.thread.activities[0]?.id).toBe("activity-0"); | ||
| 567 | } | ||
| 568 | }); | ||
| 569 | }); | ||
| 570 | |||
| 571 | describe("thread.turn-diff-completed", () => { | ||
| 572 | it("adds a checkpoint and updates latestTurn", () => { | ||
| 573 | const result = applyThreadDetailEvent(baseThread, { | ||
| 574 | ...baseEventFields, | ||
| 575 | sequence: 13, | ||
| 576 | occurredAt: "2026-04-01T12:00:00.000Z", | ||
| 577 | aggregateKind: "thread", | ||
| 578 | aggregateId: ThreadId.make("thread-1"), | ||
| 579 | type: "thread.turn-diff-completed", | ||
| 580 | payload: { | ||
| 581 | threadId: ThreadId.make("thread-1"), | ||
| 582 | turnId: TurnId.make("turn-1"), | ||
| 583 | checkpointTurnCount: 1, | ||
| 584 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 585 | status: "ready", | ||
| 586 | files: [], | ||
| 587 | assistantMessageId: MessageId.make("msg-3"), | ||
| 588 | completedAt: "2026-04-01T12:00:00.000Z", | ||
| 589 | }, | ||
| 590 | }); | ||
| 591 | |||
| 592 | expect(result.kind).toBe("updated"); | ||
| 593 | if (result.kind === "updated") { | ||
| 594 | expect(result.thread.checkpoints).toHaveLength(1); | ||
| 595 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 596 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 597 | } | ||
| 598 | }); | ||
| 599 | }); | ||
| 600 | |||
| 601 | describe("thread.reverted", () => { | ||
| 602 | it("filters entities to retained turns", () => { | ||
| 603 | const threadWithData: OrchestrationThread = { | ||
| 604 | ...baseThread, | ||
| 605 | messages: [ | ||
| 606 | { | ||
| 607 | id: MessageId.make("msg-1"), | ||
| 608 | role: "user", | ||
| 609 | text: "First", | ||
| 610 | turnId: null, | ||
| 611 | streaming: false, | ||
| 612 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 613 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 614 | }, | ||
| 615 | { | ||
| 616 | id: MessageId.make("msg-2"), | ||
| 617 | role: "assistant", | ||
| 618 | text: "Response 1", | ||
| 619 | turnId: TurnId.make("turn-1"), | ||
| 620 | streaming: false, | ||
| 621 | createdAt: "2026-04-01T02:00:00.000Z", | ||
| 622 | updatedAt: "2026-04-01T02:00:00.000Z", | ||
| 623 | }, | ||
| 624 | { | ||
| 625 | id: MessageId.make("msg-3"), | ||
| 626 | role: "assistant", | ||
| 627 | text: "Response 2", | ||
| 628 | turnId: TurnId.make("turn-2"), | ||
| 629 | streaming: false, | ||
| 630 | createdAt: "2026-04-01T03:00:00.000Z", | ||
| 631 | updatedAt: "2026-04-01T03:00:00.000Z", | ||
| 632 | }, | ||
| 633 | ], | ||
| 634 | checkpoints: [ | ||
| 635 | { | ||
| 636 | turnId: TurnId.make("turn-1"), | ||
| 637 | checkpointTurnCount: 1, | ||
| 638 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 639 | status: "ready", | ||
| 640 | files: [], | ||
| 641 | assistantMessageId: MessageId.make("msg-2"), | ||
| 642 | completedAt: "2026-04-01T02:00:00.000Z", | ||
| 643 | }, | ||
| 644 | { | ||
| 645 | turnId: TurnId.make("turn-2"), | ||
| 646 | checkpointTurnCount: 2, | ||
| 647 | checkpointRef: CheckpointRef.make("ref-2"), | ||
| 648 | status: "ready", | ||
| 649 | files: [], | ||
| 650 | assistantMessageId: MessageId.make("msg-3"), | ||
| 651 | completedAt: "2026-04-01T03:00:00.000Z", | ||
| 652 | }, | ||
| 653 | ], | ||
| 654 | }; | ||
| 655 | |||
| 656 | const result = applyThreadDetailEvent(threadWithData, { | ||
| 657 | ...baseEventFields, | ||
| 658 | sequence: 14, | ||
| 659 | occurredAt: "2026-04-01T04:00:00.000Z", | ||
| 660 | aggregateKind: "thread", | ||
| 661 | aggregateId: ThreadId.make("thread-1"), | ||
| 662 | type: "thread.reverted", | ||
| 663 | payload: { | ||
| 664 | threadId: ThreadId.make("thread-1"), | ||
| 665 | turnCount: 1, | ||
| 666 | }, | ||
| 667 | }); | ||
| 668 | |||
| 669 | expect(result.kind).toBe("updated"); | ||
| 670 | if (result.kind === "updated") { | ||
| 671 | // turn-2 checkpoint is filtered out (turnCount 2 > revert target 1) | ||
| 672 | expect(result.thread.checkpoints).toHaveLength(1); | ||
| 673 | expect(result.thread.checkpoints[0]?.turnId).toBe("turn-1"); | ||
| 674 | // msg-3 (turn-2) is filtered, msg-1 (no turn) and msg-2 (turn-1) remain | ||
| 675 | expect(result.thread.messages).toHaveLength(2); | ||
| 676 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 677 | } | ||
| 678 | }); | ||
| 679 | }); | ||
| 680 | |||
| 681 | describe("no-op events", () => { | ||
| 682 | it("returns unchanged for approval-response-requested", () => { | ||
| 683 | const result = applyThreadDetailEvent(baseThread, { | ||
| 684 | ...baseEventFields, | ||
| 685 | sequence: 15, | ||
| 686 | occurredAt: "2026-04-01T13:00:00.000Z", | ||
| 687 | aggregateKind: "thread", | ||
| 688 | aggregateId: ThreadId.make("thread-1"), | ||
| 689 | type: "thread.approval-response-requested", | ||
| 690 | payload: { | ||
| 691 | threadId: ThreadId.make("thread-1"), | ||
| 692 | requestId: "req-1", | ||
| 693 | decision: "approve", | ||
| 694 | createdAt: "2026-04-01T13:00:00.000Z", | ||
| 695 | }, | ||
| 696 | } as any); | ||
| 697 | expect(result.kind).toBe("unchanged"); | ||
| 698 | }); | ||
| 699 | }); | ||
| 700 | }); | ||
packages/client-runtime/src/state/threadReducer.ts created+547| ... | @@ -0,0 +1,547 @@ | ||
| 1 | import { pipe } from "effect/Function"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import * as O from "effect/Order"; | ||
| 4 | import type { | ||
| 5 | MessageId, | ||
| 6 | OrchestrationCheckpointSummary, | ||
| 7 | OrchestrationEvent, | ||
| 8 | OrchestrationLatestTurn, | ||
| 9 | OrchestrationMessage, | ||
| 10 | OrchestrationSession, | ||
| 11 | OrchestrationThread, | ||
| 12 | OrchestrationThreadActivity, | ||
| 13 | TurnId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | export type ThreadDetailReducerResult = | ||
| 17 | | { readonly kind: "updated"; readonly thread: OrchestrationThread } | ||
| 18 | | { readonly kind: "deleted" } | ||
| 19 | | { readonly kind: "unchanged" }; | ||
| 20 | |||
| 21 | const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>( | ||
| 22 | O.mapInput(O.String, (p) => p.createdAt), | ||
| 23 | O.mapInput(O.String, (p) => p.id), | ||
| 24 | ); | ||
| 25 | |||
| 26 | const checkpointOrder = O.mapInput( | ||
| 27 | O.Number, | ||
| 28 | (cp: OrchestrationThread["checkpoints"][number]) => | ||
| 29 | cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER, | ||
| 30 | ); | ||
| 31 | |||
| 32 | const activityOrder = O.combineAll<OrchestrationThreadActivity>([ | ||
| 33 | O.mapInput(O.Number, (a) => a.sequence ?? Number.MAX_SAFE_INTEGER), | ||
| 34 | O.mapInput(O.String, (a) => a.createdAt), | ||
| 35 | O.mapInput(O.String, (a) => a.id), | ||
| 36 | ]); | ||
| 37 | |||
| 38 | /** | ||
| 39 | * Apply a single orchestration event to an `OrchestrationThread`, returning | ||
| 40 | * the updated thread, a deletion signal, or an "unchanged" marker when the | ||
| 41 | * event doesn't affect this thread. | ||
| 42 | * | ||
| 43 | * This is a pure reducer operating on contract types. UI-specific mapping | ||
| 44 | * (e.g. resolving attachment preview URLs, normalising model slugs, adding | ||
| 45 | * scoped fields like `environmentId`) is the caller's responsibility. | ||
| 46 | */ | ||
| 47 | export function applyThreadDetailEvent( | ||
| 48 | thread: OrchestrationThread, | ||
| 49 | event: OrchestrationEvent, | ||
| 50 | ): ThreadDetailReducerResult { | ||
| 51 | switch (event.type) { | ||
| 52 | // ── Project events (irrelevant to thread detail) ──────────────── | ||
| 53 | case "project.created": | ||
| 54 | case "project.meta-updated": | ||
| 55 | case "project.deleted": | ||
| 56 | return { kind: "unchanged" }; | ||
| 57 | |||
| 58 | // ── Thread lifecycle ──────────────────────────────────────────── | ||
| 59 | case "thread.created": | ||
| 60 | return { | ||
| 61 | kind: "updated", | ||
| 62 | thread: { | ||
| 63 | id: event.payload.threadId, | ||
| 64 | projectId: event.payload.projectId, | ||
| 65 | title: event.payload.title, | ||
| 66 | modelSelection: event.payload.modelSelection, | ||
| 67 | runtimeMode: event.payload.runtimeMode, | ||
| 68 | interactionMode: event.payload.interactionMode, | ||
| 69 | branch: event.payload.branch, | ||
| 70 | worktreePath: event.payload.worktreePath, | ||
| 71 | latestTurn: null, | ||
| 72 | createdAt: event.payload.createdAt, | ||
| 73 | updatedAt: event.payload.updatedAt, | ||
| 74 | archivedAt: null, | ||
| 75 | deletedAt: null, | ||
| 76 | messages: [], | ||
| 77 | proposedPlans: [], | ||
| 78 | activities: [], | ||
| 79 | checkpoints: [], | ||
| 80 | session: null, | ||
| 81 | }, | ||
| 82 | }; | ||
| 83 | |||
| 84 | case "thread.deleted": | ||
| 85 | return { kind: "deleted" }; | ||
| 86 | |||
| 87 | case "thread.archived": | ||
| 88 | return { | ||
| 89 | kind: "updated", | ||
| 90 | thread: { | ||
| 91 | ...thread, | ||
| 92 | archivedAt: event.payload.archivedAt, | ||
| 93 | updatedAt: event.payload.updatedAt, | ||
| 94 | }, | ||
| 95 | }; | ||
| 96 | |||
| 97 | case "thread.unarchived": | ||
| 98 | return { | ||
| 99 | kind: "updated", | ||
| 100 | thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, | ||
| 101 | }; | ||
| 102 | |||
| 103 | // ── Thread metadata ───────────────────────────────────────────── | ||
| 104 | case "thread.meta-updated": | ||
| 105 | return { | ||
| 106 | kind: "updated", | ||
| 107 | thread: { | ||
| 108 | ...thread, | ||
| 109 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | ||
| 110 | ...(event.payload.modelSelection !== undefined | ||
| 111 | ? { modelSelection: event.payload.modelSelection } | ||
| 112 | : {}), | ||
| 113 | ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), | ||
| 114 | ...(event.payload.worktreePath !== undefined | ||
| 115 | ? { worktreePath: event.payload.worktreePath } | ||
| 116 | : {}), | ||
| 117 | updatedAt: event.payload.updatedAt, | ||
| 118 | }, | ||
| 119 | }; | ||
| 120 | |||
| 121 | case "thread.runtime-mode-set": | ||
| 122 | return { | ||
| 123 | kind: "updated", | ||
| 124 | thread: { | ||
| 125 | ...thread, | ||
| 126 | runtimeMode: event.payload.runtimeMode, | ||
| 127 | updatedAt: event.payload.updatedAt, | ||
| 128 | }, | ||
| 129 | }; | ||
| 130 | |||
| 131 | case "thread.interaction-mode-set": | ||
| 132 | return { | ||
| 133 | kind: "updated", | ||
| 134 | thread: { | ||
| 135 | ...thread, | ||
| 136 | interactionMode: event.payload.interactionMode, | ||
| 137 | updatedAt: event.payload.updatedAt, | ||
| 138 | }, | ||
| 139 | }; | ||
| 140 | |||
| 141 | // ── Turn lifecycle ────────────────────────────────────────────── | ||
| 142 | case "thread.turn-start-requested": | ||
| 143 | return { | ||
| 144 | kind: "updated", | ||
| 145 | thread: { | ||
| 146 | ...thread, | ||
| 147 | ...(event.payload.modelSelection !== undefined | ||
| 148 | ? { modelSelection: event.payload.modelSelection } | ||
| 149 | : {}), | ||
| 150 | runtimeMode: event.payload.runtimeMode, | ||
| 151 | interactionMode: event.payload.interactionMode, | ||
| 152 | updatedAt: event.occurredAt, | ||
| 153 | }, | ||
| 154 | }; | ||
| 155 | |||
| 156 | case "thread.turn-interrupt-requested": { | ||
| 157 | if (event.payload.turnId === undefined) { | ||
| 158 | return { kind: "unchanged" }; | ||
| 159 | } | ||
| 160 | const latestTurn = thread.latestTurn; | ||
| 161 | if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { | ||
| 162 | return { kind: "unchanged" }; | ||
| 163 | } | ||
| 164 | return { | ||
| 165 | kind: "updated", | ||
| 166 | thread: { | ||
| 167 | ...thread, | ||
| 168 | latestTurn: { | ||
| 169 | ...latestTurn, | ||
| 170 | state: "interrupted", | ||
| 171 | startedAt: latestTurn.startedAt ?? event.payload.createdAt, | ||
| 172 | completedAt: latestTurn.completedAt ?? event.payload.createdAt, | ||
| 173 | }, | ||
| 174 | updatedAt: event.occurredAt, | ||
| 175 | }, | ||
| 176 | }; | ||
| 177 | } | ||
| 178 | |||
| 179 | // ── Messages ──────────────────────────────────────────────────── | ||
| 180 | case "thread.message-sent": { | ||
| 181 | const message: OrchestrationMessage = { | ||
| 182 | id: event.payload.messageId, | ||
| 183 | role: event.payload.role, | ||
| 184 | text: event.payload.text, | ||
| 185 | ...(event.payload.attachments !== undefined | ||
| 186 | ? { attachments: event.payload.attachments } | ||
| 187 | : {}), | ||
| 188 | turnId: event.payload.turnId, | ||
| 189 | streaming: event.payload.streaming, | ||
| 190 | createdAt: event.payload.createdAt, | ||
| 191 | updatedAt: event.payload.updatedAt, | ||
| 192 | }; | ||
| 193 | |||
| 194 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); | ||
| 195 | const messages = existingMessage | ||
| 196 | ? Arr.map(thread.messages, (entry) => | ||
| 197 | entry.id !== message.id | ||
| 198 | ? entry | ||
| 199 | : { | ||
| 200 | ...entry, | ||
| 201 | text: message.streaming | ||
| 202 | ? `${entry.text}${message.text}` | ||
| 203 | : message.text.length > 0 | ||
| 204 | ? message.text | ||
| 205 | : entry.text, | ||
| 206 | streaming: message.streaming, | ||
| 207 | ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), | ||
| 208 | ...(message.streaming ? {} : { updatedAt: message.updatedAt }), | ||
| 209 | ...(message.attachments !== undefined | ||
| 210 | ? { attachments: message.attachments } | ||
| 211 | : {}), | ||
| 212 | }, | ||
| 213 | ) | ||
| 214 | : Arr.append(thread.messages, message); | ||
| 215 | // Update latestTurn for assistant messages bound to a turn. A completed | ||
| 216 | // assistant message only settles the turn once the session is no longer | ||
| 217 | // running it — providers may emit several assistant messages per turn | ||
| 218 | // (commentary between tool calls), and the turn must stay unsettled | ||
| 219 | // until the provider reports turn end. | ||
| 220 | const turnStillRunning = | ||
| 221 | event.payload.turnId !== null && | ||
| 222 | thread.session?.status === "running" && | ||
| 223 | thread.session.activeTurnId === event.payload.turnId; | ||
| 224 | const settlesTurn = !event.payload.streaming && !turnStillRunning; | ||
| 225 | const latestTurn: OrchestrationThread["latestTurn"] = | ||
| 226 | event.payload.role === "assistant" && | ||
| 227 | event.payload.turnId !== null && | ||
| 228 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | ||
| 229 | ? { | ||
| 230 | turnId: event.payload.turnId, | ||
| 231 | state: settlesTurn | ||
| 232 | ? thread.latestTurn?.state === "interrupted" | ||
| 233 | ? "interrupted" | ||
| 234 | : thread.latestTurn?.state === "error" | ||
| 235 | ? "error" | ||
| 236 | : "completed" | ||
| 237 | : "running", | ||
| 238 | requestedAt: | ||
| 239 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 240 | ? thread.latestTurn.requestedAt | ||
| 241 | : event.payload.createdAt, | ||
| 242 | startedAt: | ||
| 243 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 244 | ? (thread.latestTurn.startedAt ?? event.payload.createdAt) | ||
| 245 | : event.payload.createdAt, | ||
| 246 | completedAt: settlesTurn | ||
| 247 | ? event.payload.updatedAt | ||
| 248 | : thread.latestTurn?.turnId === event.payload.turnId | ||
| 249 | ? (thread.latestTurn.completedAt ?? null) | ||
| 250 | : null, | ||
| 251 | assistantMessageId: event.payload.messageId, | ||
| 252 | } | ||
| 253 | : thread.latestTurn; | ||
| 254 | |||
| 255 | // Rebind checkpoint assistant message IDs for assistant messages. | ||
| 256 | const checkpoints = | ||
| 257 | event.payload.role === "assistant" && event.payload.turnId !== null | ||
| 258 | ? rebindCheckpointAssistantMessage( | ||
| 259 | thread.checkpoints, | ||
| 260 | event.payload.turnId, | ||
| 261 | event.payload.messageId, | ||
| 262 | ) | ||
| 263 | : thread.checkpoints; | ||
| 264 | |||
| 265 | return { | ||
| 266 | kind: "updated", | ||
| 267 | thread: { | ||
| 268 | ...thread, | ||
| 269 | messages, | ||
| 270 | checkpoints, | ||
| 271 | latestTurn, | ||
| 272 | updatedAt: event.occurredAt, | ||
| 273 | }, | ||
| 274 | }; | ||
| 275 | } | ||
| 276 | |||
| 277 | // ── Session ───────────────────────────────────────────────────── | ||
| 278 | case "thread.session-set": { | ||
| 279 | // Leaving the "running" session status is the turn-end signal: settle a | ||
| 280 | // still-running latest turn so its duration reflects the whole turn. | ||
| 281 | const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); | ||
| 282 | const latestTurn: OrchestrationLatestTurn | null = | ||
| 283 | event.payload.session.status === "running" && event.payload.session.activeTurnId !== null | ||
| 284 | ? { | ||
| 285 | turnId: event.payload.session.activeTurnId, | ||
| 286 | state: "running", | ||
| 287 | requestedAt: | ||
| 288 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 289 | ? thread.latestTurn.requestedAt | ||
| 290 | : event.payload.session.updatedAt, | ||
| 291 | startedAt: | ||
| 292 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 293 | ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) | ||
| 294 | : event.payload.session.updatedAt, | ||
| 295 | completedAt: null, | ||
| 296 | assistantMessageId: | ||
| 297 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 298 | ? thread.latestTurn.assistantMessageId | ||
| 299 | : null, | ||
| 300 | } | ||
| 301 | : thread.latestTurn !== null && | ||
| 302 | thread.latestTurn.state === "running" && | ||
| 303 | settledTurnState !== null | ||
| 304 | ? { | ||
| 305 | ...thread.latestTurn, | ||
| 306 | state: settledTurnState, | ||
| 307 | // A running turn's completedAt can only hold a mid-turn | ||
| 308 | // placeholder checkpoint timestamp — the session leaving | ||
| 309 | // "running" is the authoritative turn end. | ||
| 310 | completedAt: event.payload.session.updatedAt, | ||
| 311 | } | ||
| 312 | : thread.latestTurn; | ||
| 313 | |||
| 314 | return { | ||
| 315 | kind: "updated", | ||
| 316 | thread: { | ||
| 317 | ...thread, | ||
| 318 | session: event.payload.session, | ||
| 319 | latestTurn, | ||
| 320 | updatedAt: event.occurredAt, | ||
| 321 | }, | ||
| 322 | }; | ||
| 323 | } | ||
| 324 | |||
| 325 | case "thread.session-stop-requested": | ||
| 326 | return thread.session === null | ||
| 327 | ? { kind: "unchanged" } | ||
| 328 | : { | ||
| 329 | kind: "updated", | ||
| 330 | thread: { | ||
| 331 | ...thread, | ||
| 332 | session: { | ||
| 333 | ...thread.session, | ||
| 334 | status: "stopped", | ||
| 335 | activeTurnId: null, | ||
| 336 | updatedAt: event.payload.createdAt, | ||
| 337 | }, | ||
| 338 | updatedAt: event.occurredAt, | ||
| 339 | }, | ||
| 340 | }; | ||
| 341 | |||
| 342 | // ── Proposed plans ────────────────────────────────────────────── | ||
| 343 | case "thread.proposed-plan-upserted": { | ||
| 344 | const proposedPlan = event.payload.proposedPlan; | ||
| 345 | |||
| 346 | const proposedPlans = pipe( | ||
| 347 | thread.proposedPlans, | ||
| 348 | Arr.filter((entry) => entry.id !== proposedPlan.id), | ||
| 349 | Arr.append(proposedPlan), | ||
| 350 | Arr.sort(proposedPlanOrder), | ||
| 351 | ); | ||
| 352 | |||
| 353 | return { | ||
| 354 | kind: "updated", | ||
| 355 | thread: { ...thread, proposedPlans, updatedAt: event.occurredAt }, | ||
| 356 | }; | ||
| 357 | } | ||
| 358 | |||
| 359 | // ── Checkpoints / turn diffs ──────────────────────────────────── | ||
| 360 | case "thread.turn-diff-completed": { | ||
| 361 | const checkpoint: OrchestrationCheckpointSummary = { | ||
| 362 | turnId: event.payload.turnId, | ||
| 363 | checkpointTurnCount: event.payload.checkpointTurnCount, | ||
| 364 | checkpointRef: event.payload.checkpointRef, | ||
| 365 | status: event.payload.status, | ||
| 366 | files: event.payload.files, | ||
| 367 | assistantMessageId: event.payload.assistantMessageId, | ||
| 368 | completedAt: event.payload.completedAt, | ||
| 369 | }; | ||
| 370 | |||
| 371 | const existing = thread.checkpoints.find((entry) => entry.turnId === checkpoint.turnId); | ||
| 372 | // Don't overwrite a non-missing checkpoint with a missing one. | ||
| 373 | if (existing && existing.status !== "missing" && checkpoint.status === "missing") { | ||
| 374 | return { kind: "unchanged" }; | ||
| 375 | } | ||
| 376 | |||
| 377 | const checkpoints = pipe( | ||
| 378 | thread.checkpoints, | ||
| 379 | Arr.filter((entry) => entry.turnId !== checkpoint.turnId), | ||
| 380 | Arr.append(checkpoint), | ||
| 381 | Arr.sort(checkpointOrder), | ||
| 382 | ); | ||
| 383 | |||
| 384 | // Mid-turn diff updates produce placeholder checkpoints; record the | ||
| 385 | // checkpoint, but don't settle a turn its session is still running. | ||
| 386 | const diffTurnStillRunning = | ||
| 387 | thread.session?.status === "running" && | ||
| 388 | thread.session.activeTurnId === event.payload.turnId; | ||
| 389 | const latestTurn = | ||
| 390 | !diffTurnStillRunning && | ||
| 391 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | ||
| 392 | ? { | ||
| 393 | turnId: event.payload.turnId, | ||
| 394 | state: checkpointStatusToTurnState(event.payload.status), | ||
| 395 | requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, | ||
| 396 | startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, | ||
| 397 | completedAt: event.payload.completedAt, | ||
| 398 | assistantMessageId: event.payload.assistantMessageId, | ||
| 399 | } | ||
| 400 | : thread.latestTurn; | ||
| 401 | |||
| 402 | return { | ||
| 403 | kind: "updated", | ||
| 404 | thread: { ...thread, checkpoints, latestTurn, updatedAt: event.occurredAt }, | ||
| 405 | }; | ||
| 406 | } | ||
| 407 | |||
| 408 | // ── Revert ────────────────────────────────────────────────────── | ||
| 409 | case "thread.reverted": { | ||
| 410 | const checkpoints = pipe( | ||
| 411 | thread.checkpoints, | ||
| 412 | Arr.filter( | ||
| 413 | (entry) => | ||
| 414 | entry.checkpointTurnCount !== undefined && | ||
| 415 | entry.checkpointTurnCount <= event.payload.turnCount, | ||
| 416 | ), | ||
| 417 | Arr.sort(checkpointOrder), | ||
| 418 | ); | ||
| 419 | |||
| 420 | const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); | ||
| 421 | const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); | ||
| 422 | const proposedPlans = pipe( | ||
| 423 | thread.proposedPlans, | ||
| 424 | Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), | ||
| 425 | ); | ||
| 426 | const activities = pipe( | ||
| 427 | thread.activities, | ||
| 428 | Arr.filter((activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId)), | ||
| 429 | ); | ||
| 430 | const latestCheckpoint = checkpoints.at(-1) ?? null; | ||
| 431 | |||
| 432 | return { | ||
| 433 | kind: "updated", | ||
| 434 | thread: { | ||
| 435 | ...thread, | ||
| 436 | checkpoints, | ||
| 437 | messages, | ||
| 438 | proposedPlans, | ||
| 439 | activities, | ||
| 440 | latestTurn: | ||
| 441 | latestCheckpoint === null | ||
| 442 | ? null | ||
| 443 | : { | ||
| 444 | turnId: latestCheckpoint.turnId, | ||
| 445 | state: checkpointStatusToTurnState( | ||
| 446 | latestCheckpoint.status as "ready" | "missing" | "error", | ||
| 447 | ), | ||
| 448 | requestedAt: latestCheckpoint.completedAt, | ||
| 449 | startedAt: latestCheckpoint.completedAt, | ||
| 450 | completedAt: latestCheckpoint.completedAt, | ||
| 451 | assistantMessageId: latestCheckpoint.assistantMessageId ?? null, | ||
| 452 | }, | ||
| 453 | updatedAt: event.occurredAt, | ||
| 454 | }, | ||
| 455 | }; | ||
| 456 | } | ||
| 457 | |||
| 458 | // ── Activities ────────────────────────────────────────────────── | ||
| 459 | case "thread.activity-appended": { | ||
| 460 | const activities = pipe( | ||
| 461 | thread.activities, | ||
| 462 | Arr.filter((activity) => activity.id !== event.payload.activity.id), | ||
| 463 | Arr.append(event.payload.activity), | ||
| 464 | Arr.sort(activityOrder), | ||
| 465 | ); | ||
| 466 | |||
| 467 | return { | ||
| 468 | kind: "updated", | ||
| 469 | thread: { ...thread, activities, updatedAt: event.occurredAt }, | ||
| 470 | }; | ||
| 471 | } | ||
| 472 | |||
| 473 | // ── Events that don't mutate thread state directly ────────────── | ||
| 474 | case "thread.approval-response-requested": | ||
| 475 | case "thread.user-input-response-requested": | ||
| 476 | case "thread.checkpoint-revert-requested": | ||
| 477 | return { kind: "unchanged" }; | ||
| 478 | } | ||
| 479 | |||
| 480 | // Forward-compatible: ignore unrecognized event types. | ||
| 481 | return { kind: "unchanged" }; | ||
| 482 | } | ||
| 483 | |||
| 484 | // ── Helpers ────────────────────────────────────────────────────────── | ||
| 485 | |||
| 486 | /** | ||
| 487 | * Turn state to settle a still-running latest turn with when its session | ||
| 488 | * leaves the "running" status, or null while the session is (re)starting or | ||
| 489 | * running and the turn must stay unsettled. | ||
| 490 | */ | ||
| 491 | function settledTurnStateForSessionStatus( | ||
| 492 | status: OrchestrationSession["status"], | ||
| 493 | ): "completed" | "interrupted" | "error" | null { | ||
| 494 | switch (status) { | ||
| 495 | case "idle": | ||
| 496 | case "ready": | ||
| 497 | return "completed"; | ||
| 498 | case "error": | ||
| 499 | return "error"; | ||
| 500 | case "interrupted": | ||
| 501 | case "stopped": | ||
| 502 | return "interrupted"; | ||
| 503 | case "starting": | ||
| 504 | case "running": | ||
| 505 | return null; | ||
| 506 | } | ||
| 507 | } | ||
| 508 | |||
| 509 | function checkpointStatusToTurnState( | ||
| 510 | status: "ready" | "missing" | "error", | ||
| 511 | ): OrchestrationLatestTurn["state"] { | ||
| 512 | switch (status) { | ||
| 513 | case "ready": | ||
| 514 | return "completed"; | ||
| 515 | case "error": | ||
| 516 | return "error"; | ||
| 517 | case "missing": | ||
| 518 | return "completed"; | ||
| 519 | } | ||
| 520 | } | ||
| 521 | |||
| 522 | function rebindCheckpointAssistantMessage( | ||
| 523 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, | ||
| 524 | turnId: TurnId, | ||
| 525 | messageId: MessageId, | ||
| 526 | ): OrchestrationCheckpointSummary[] { | ||
| 527 | return Arr.map(checkpoints, (entry) => | ||
| 528 | entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, | ||
| 529 | ); | ||
| 530 | } | ||
| 531 | |||
| 532 | function retainMessagesAfterRevert( | ||
| 533 | messages: ReadonlyArray<OrchestrationMessage>, | ||
| 534 | retainedTurnIds: ReadonlySet<string>, | ||
| 535 | ): OrchestrationMessage[] { | ||
| 536 | // Keep messages that belong to a retained turn, plus system messages and | ||
| 537 | // messages without a turn binding (pre-turn-0 user messages). | ||
| 538 | return Arr.filter(messages, (message) => { | ||
| 539 | if (message.role === "system") { | ||
| 540 | return true; | ||
| 541 | } | ||
| 542 | if (message.turnId === null) { | ||
| 543 | return true; | ||
| 544 | } | ||
| 545 | return retainedTurnIds.has(message.turnId); | ||
| 546 | }); | ||
| 547 | } | ||
packages/client-runtime/src/state/threadShell.ts created+186| ... | @@ -0,0 +1,186 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationShellSnapshot, | ||
| 4 | OrchestrationThreadShell, | ||
| 5 | ProjectId, | ||
| 6 | ScopedProjectRef, | ||
| 7 | ScopedThreadRef, | ||
| 8 | ThreadId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { Atom } from "effect/unstable/reactivity"; | ||
| 11 | |||
| 12 | import type { EnvironmentThreadShell } from "./models.ts"; | ||
| 13 | import { scopeThreadShell } from "./models.ts"; | ||
| 14 | import type { EnvironmentCatalogState } from "./connections.ts"; | ||
| 15 | import { | ||
| 16 | arrayElementsEqual, | ||
| 17 | parseProjectRefCollectionKey, | ||
| 18 | parseThreadKey, | ||
| 19 | projectRefCollectionKey, | ||
| 20 | threadKey, | ||
| 21 | threadRefsEqual, | ||
| 22 | } from "./entities.ts"; | ||
| 23 | |||
| 24 | const EMPTY_THREADS: ReadonlyArray<OrchestrationThreadShell> = Object.freeze([]); | ||
| 25 | const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); | ||
| 26 | const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationThreadShell> = new Map(); | ||
| 27 | const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< | ||
| 28 | ProjectId, | ||
| 29 | ReadonlyArray<ScopedThreadRef> | ||
| 30 | > = new Map(); | ||
| 31 | |||
| 32 | export function createEnvironmentThreadShellAtoms(input: { | ||
| 33 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; | ||
| 34 | readonly snapshotAtom: ( | ||
| 35 | environmentId: EnvironmentId, | ||
| 36 | ) => Atom.Atom<OrchestrationShellSnapshot | null>; | ||
| 37 | }) { | ||
| 38 | const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 39 | Atom.make( | ||
| 40 | (get): ReadonlyArray<OrchestrationThreadShell> => | ||
| 41 | get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS, | ||
| 42 | ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)), | ||
| 43 | ); | ||
| 44 | |||
| 45 | const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 46 | Atom.make((get): ReadonlyMap<ThreadId, OrchestrationThreadShell> => { | ||
| 47 | const threads = get(environmentThreadsAtom(environmentId)); | ||
| 48 | if (threads.length === 0) { | ||
| 49 | return EMPTY_THREAD_INDEX; | ||
| 50 | } | ||
| 51 | return new Map(threads.map((thread) => [thread.id, thread] as const)); | ||
| 52 | }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)), | ||
| 53 | ); | ||
| 54 | |||
| 55 | const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => { | ||
| 56 | let previous: ReadonlyArray<ScopedThreadRef> = []; | ||
| 57 | return Atom.make((get) => { | ||
| 58 | const next = get(environmentThreadsAtom(environmentId)).map((thread) => ({ | ||
| 59 | environmentId, | ||
| 60 | threadId: thread.id, | ||
| 61 | })); | ||
| 62 | if (threadRefsEqual(previous, next)) { | ||
| 63 | return previous; | ||
| 64 | } | ||
| 65 | previous = next; | ||
| 66 | return next; | ||
| 67 | }).pipe(Atom.withLabel(`environment-thread-refs:${environmentId}`)); | ||
| 68 | }); | ||
| 69 | |||
| 70 | const environmentThreadRefsByProjectAtom = Atom.family((environmentId: EnvironmentId) => { | ||
| 71 | let previous: ReadonlyMap< | ||
| 72 | ProjectId, | ||
| 73 | ReadonlyArray<ScopedThreadRef> | ||
| 74 | > = EMPTY_THREAD_REFS_BY_PROJECT; | ||
| 75 | return Atom.make((get) => { | ||
| 76 | const grouped = new Map<ProjectId, ScopedThreadRef[]>(); | ||
| 77 | for (const thread of get(environmentThreadsAtom(environmentId))) { | ||
| 78 | const refs = grouped.get(thread.projectId); | ||
| 79 | const ref = { environmentId, threadId: thread.id }; | ||
| 80 | if (refs === undefined) { | ||
| 81 | grouped.set(thread.projectId, [ref]); | ||
| 82 | } else { | ||
| 83 | refs.push(ref); | ||
| 84 | } | ||
| 85 | } | ||
| 86 | if (grouped.size === 0) { | ||
| 87 | previous = EMPTY_THREAD_REFS_BY_PROJECT; | ||
| 88 | return previous; | ||
| 89 | } | ||
| 90 | const next = new Map<ProjectId, ReadonlyArray<ScopedThreadRef>>(); | ||
| 91 | for (const [projectId, refs] of grouped) { | ||
| 92 | const previousRefs = previous.get(projectId); | ||
| 93 | next.set( | ||
| 94 | projectId, | ||
| 95 | previousRefs !== undefined && threadRefsEqual(previousRefs, refs) ? previousRefs : refs, | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | previous = next; | ||
| 99 | return previous; | ||
| 100 | }).pipe(Atom.withLabel(`environment-thread-refs-by-project:${environmentId}`)); | ||
| 101 | }); | ||
| 102 | |||
| 103 | const threadShellAtomFamily = Atom.family((key: string) => { | ||
| 104 | const ref = parseThreadKey(key); | ||
| 105 | let previousSource: OrchestrationThreadShell | null = null; | ||
| 106 | let previousValue: EnvironmentThreadShell | null = null; | ||
| 107 | return Atom.make((get) => { | ||
| 108 | const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null; | ||
| 109 | if (source === previousSource) { | ||
| 110 | return previousValue; | ||
| 111 | } | ||
| 112 | previousSource = source; | ||
| 113 | previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source); | ||
| 114 | return previousValue; | ||
| 115 | }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); | ||
| 116 | }); | ||
| 117 | |||
| 118 | const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { | ||
| 119 | const projectRefs = parseProjectRefCollectionKey(key); | ||
| 120 | let previous: ReadonlyArray<EnvironmentThreadShell> = []; | ||
| 121 | return Atom.make((get) => { | ||
| 122 | const next: EnvironmentThreadShell[] = []; | ||
| 123 | const seen = new Set<string>(); | ||
| 124 | for (const projectRef of projectRefs) { | ||
| 125 | const refs = | ||
| 126 | get(environmentThreadRefsByProjectAtom(projectRef.environmentId)).get( | ||
| 127 | projectRef.projectId, | ||
| 128 | ) ?? EMPTY_SCOPED_THREAD_REFS; | ||
| 129 | for (const ref of refs) { | ||
| 130 | const key = threadKey(ref); | ||
| 131 | if (seen.has(key)) { | ||
| 132 | continue; | ||
| 133 | } | ||
| 134 | seen.add(key); | ||
| 135 | const thread = get(threadShellAtomFamily(key)); | ||
| 136 | if (thread !== null) { | ||
| 137 | next.push(thread); | ||
| 138 | } | ||
| 139 | } | ||
| 140 | } | ||
| 141 | if (arrayElementsEqual(previous, next)) { | ||
| 142 | return previous; | ||
| 143 | } | ||
| 144 | previous = next; | ||
| 145 | return previous; | ||
| 146 | }).pipe(Atom.withLabel(`environment-thread-shells-for-projects:${key}`)); | ||
| 147 | }); | ||
| 148 | |||
| 149 | let previousThreadRefs: ReadonlyArray<ScopedThreadRef> = []; | ||
| 150 | const threadRefsAtom = Atom.make((get) => { | ||
| 151 | const refs: ScopedThreadRef[] = []; | ||
| 152 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | ||
| 153 | refs.push(...get(environmentThreadRefsAtom(environmentId))); | ||
| 154 | } | ||
| 155 | if (threadRefsEqual(previousThreadRefs, refs)) { | ||
| 156 | return previousThreadRefs; | ||
| 157 | } | ||
| 158 | previousThreadRefs = refs; | ||
| 159 | return refs; | ||
| 160 | }).pipe(Atom.withLabel("environment-thread-refs")); | ||
| 161 | |||
| 162 | let previousThreadShells: ReadonlyArray<EnvironmentThreadShell> = []; | ||
| 163 | const threadShellsAtom = Atom.make((get) => { | ||
| 164 | const next = get(threadRefsAtom).flatMap((ref) => { | ||
| 165 | const thread = get(threadShellAtomFamily(threadKey(ref))); | ||
| 166 | return thread === null ? [] : [thread]; | ||
| 167 | }); | ||
| 168 | if (arrayElementsEqual(previousThreadShells, next)) { | ||
| 169 | return previousThreadShells; | ||
| 170 | } | ||
| 171 | previousThreadShells = next; | ||
| 172 | return previousThreadShells; | ||
| 173 | }).pipe(Atom.withLabel("environment-thread-shell-list")); | ||
| 174 | |||
| 175 | return { | ||
| 176 | environmentThreadsAtom, | ||
| 177 | environmentThreadIndexAtom, | ||
| 178 | environmentThreadRefsAtom, | ||
| 179 | environmentThreadRefsByProjectAtom, | ||
| 180 | threadRefsAtom, | ||
| 181 | threadShellsAtom, | ||
| 182 | threadShellsForProjectRefsAtom: (refs: ReadonlyArray<ScopedProjectRef>) => | ||
| 183 | threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), | ||
| 184 | threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), | ||
| 185 | }; | ||
| 186 | } | ||
packages/client-runtime/src/state/threads-sync.test.ts created+406| ... | @@ -0,0 +1,406 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | EventId, | ||
| 4 | ORCHESTRATION_WS_METHODS, | ||
| 5 | ProjectId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | type OrchestrationThread, | ||
| 9 | type OrchestrationThreadStreamItem, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { describe, expect, it } from "@effect/vitest"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Queue from "effect/Queue"; | ||
| 15 | import * as Ref from "effect/Ref"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 18 | import * as TestClock from "effect/testing/TestClock"; | ||
| 19 | |||
| 20 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | ||
| 21 | import { | ||
| 22 | AVAILABLE_CONNECTION_STATE, | ||
| 23 | PrimaryConnectionTarget, | ||
| 24 | type PreparedConnection, | ||
| 25 | type SupervisorConnectionState, | ||
| 26 | } from "../connection/model.ts"; | ||
| 27 | import { | ||
| 28 | EnvironmentSupervisor, | ||
| 29 | type EnvironmentSupervisorService, | ||
| 30 | } from "../connection/supervisor.ts"; | ||
| 31 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | ||
| 32 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 33 | import { | ||
| 34 | EMPTY_ENVIRONMENT_THREAD_STATE, | ||
| 35 | makeEnvironmentThreadState, | ||
| 36 | type EnvironmentThreadState, | ||
| 37 | } from "./threads.ts"; | ||
| 38 | |||
| 39 | const TARGET = new PrimaryConnectionTarget({ | ||
| 40 | environmentId: EnvironmentId.make("environment-1"), | ||
| 41 | label: "Test environment", | ||
| 42 | httpBaseUrl: "https://environment.example.test", | ||
| 43 | wsBaseUrl: "wss://environment.example.test", | ||
| 44 | }); | ||
| 45 | const THREAD_ID = ThreadId.make("thread-1"); | ||
| 46 | const BASE_THREAD: OrchestrationThread = { | ||
| 47 | id: THREAD_ID, | ||
| 48 | projectId: ProjectId.make("project-1"), | ||
| 49 | title: "Cached thread", | ||
| 50 | modelSelection: { | ||
| 51 | instanceId: ProviderInstanceId.make("codex"), | ||
| 52 | model: "gpt-5.4", | ||
| 53 | }, | ||
| 54 | runtimeMode: "full-access", | ||
| 55 | interactionMode: "default", | ||
| 56 | branch: "main", | ||
| 57 | worktreePath: null, | ||
| 58 | latestTurn: null, | ||
| 59 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 60 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 61 | archivedAt: null, | ||
| 62 | deletedAt: null, | ||
| 63 | messages: [], | ||
| 64 | proposedPlans: [], | ||
| 65 | activities: [], | ||
| 66 | checkpoints: [], | ||
| 67 | session: null, | ||
| 68 | }; | ||
| 69 | |||
| 70 | type TestThreadInput = OrchestrationThreadStreamItem | Error; | ||
| 71 | |||
| 72 | function testSession(client: WsRpcProtocolClient): RpcSession { | ||
| 73 | return { | ||
| 74 | client, | ||
| 75 | initialConfig: Effect.never, | ||
| 76 | ready: Effect.void, | ||
| 77 | probe: Effect.void, | ||
| 78 | closed: Effect.never, | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | function awaitThreadState( | ||
| 83 | observed: Queue.Queue<EnvironmentThreadState>, | ||
| 84 | predicate: (state: EnvironmentThreadState) => boolean, | ||
| 85 | ) { | ||
| 86 | return Queue.take(observed).pipe( | ||
| 87 | Effect.repeat({ | ||
| 88 | until: predicate, | ||
| 89 | }), | ||
| 90 | ); | ||
| 91 | } | ||
| 92 | |||
| 93 | const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { | ||
| 94 | readonly cached?: OrchestrationThread; | ||
| 95 | }) { | ||
| 96 | const inputs = yield* Queue.unbounded<TestThreadInput>(); | ||
| 97 | const observed = yield* Queue.unbounded<EnvironmentThreadState>(); | ||
| 98 | const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE); | ||
| 99 | const retryCount = yield* Ref.make(0); | ||
| 100 | const subscriptionCount = yield* Ref.make(0); | ||
| 101 | const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThread>>([]); | ||
| 102 | const removedThreads = yield* Ref.make<ReadonlyArray<ThreadId>>([]); | ||
| 103 | const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>( | ||
| 104 | AVAILABLE_CONNECTION_STATE, | ||
| 105 | ); | ||
| 106 | const streamFrom = (queue: Queue.Queue<TestThreadInput>) => | ||
| 107 | Stream.fromQueue(queue).pipe( | ||
| 108 | Stream.mapEffect((input) => | ||
| 109 | input instanceof Error ? Effect.fail(input) : Effect.succeed(input), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | const client = { | ||
| 113 | [ORCHESTRATION_WS_METHODS.subscribeThread]: () => | ||
| 114 | Stream.unwrap( | ||
| 115 | Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( | ||
| 116 | Effect.map(() => streamFrom(inputs)), | ||
| 117 | ), | ||
| 118 | ), | ||
| 119 | } as unknown as WsRpcProtocolClient; | ||
| 120 | const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession>>( | ||
| 121 | Option.some(testSession(client)), | ||
| 122 | ); | ||
| 123 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none()); | ||
| 124 | const supervisor = EnvironmentSupervisor.of({ | ||
| 125 | target: TARGET, | ||
| 126 | state: supervisorState, | ||
| 127 | session: supervisorSession, | ||
| 128 | prepared, | ||
| 129 | connect: Effect.void, | ||
| 130 | disconnect: Effect.void, | ||
| 131 | retryNow: Ref.update(retryCount, (count) => count + 1), | ||
| 132 | } satisfies EnvironmentSupervisorService); | ||
| 133 | const cache = EnvironmentCacheStore.of({ | ||
| 134 | loadShell: () => Effect.succeed(Option.none()), | ||
| 135 | saveShell: () => Effect.void, | ||
| 136 | loadThread: (_environmentId, threadId) => | ||
| 137 | Effect.succeed( | ||
| 138 | threadId === THREAD_ID && options?.cached !== undefined | ||
| 139 | ? Option.some(options.cached) | ||
| 140 | : Option.none(), | ||
| 141 | ), | ||
| 142 | saveThread: (_environmentId, thread) => | ||
| 143 | Ref.update(savedThreads, (current) => [...current, thread]), | ||
| 144 | removeThread: (_environmentId, threadId) => | ||
| 145 | Ref.update(removedThreads, (current) => [...current, threadId]), | ||
| 146 | clear: () => Effect.void, | ||
| 147 | }); | ||
| 148 | const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( | ||
| 149 | Effect.provideService(EnvironmentSupervisor, supervisor), | ||
| 150 | Effect.provideService(EnvironmentCacheStore, cache), | ||
| 151 | ); | ||
| 152 | yield* SubscriptionRef.changes(threadState).pipe( | ||
| 153 | Stream.runForEach((state) => | ||
| 154 | Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))), | ||
| 155 | ), | ||
| 156 | Effect.forkScoped, | ||
| 157 | ); | ||
| 158 | |||
| 159 | return { | ||
| 160 | inputs, | ||
| 161 | observed, | ||
| 162 | latest, | ||
| 163 | retryCount, | ||
| 164 | subscriptionCount, | ||
| 165 | supervisorState, | ||
| 166 | supervisorSession, | ||
| 167 | savedThreads, | ||
| 168 | removedThreads, | ||
| 169 | replaceSession: SubscriptionRef.set(supervisorSession, Option.some(testSession(client))), | ||
| 170 | }; | ||
| 171 | }); | ||
| 172 | |||
| 173 | const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => ({ | ||
| 174 | kind: "snapshot", | ||
| 175 | snapshot: { | ||
| 176 | snapshotSequence: 1, | ||
| 177 | thread, | ||
| 178 | }, | ||
| 179 | }); | ||
| 180 | |||
| 181 | const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({ | ||
| 182 | kind: "event", | ||
| 183 | event: { | ||
| 184 | eventId: EventId.make("event-title"), | ||
| 185 | sequence, | ||
| 186 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 187 | commandId: null, | ||
| 188 | causationEventId: null, | ||
| 189 | correlationId: null, | ||
| 190 | metadata: {}, | ||
| 191 | aggregateKind: "thread", | ||
| 192 | aggregateId: THREAD_ID, | ||
| 193 | type: "thread.meta-updated", | ||
| 194 | payload: { | ||
| 195 | threadId: THREAD_ID, | ||
| 196 | title, | ||
| 197 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 198 | }, | ||
| 199 | }, | ||
| 200 | }); | ||
| 201 | |||
| 202 | const deleted = (): OrchestrationThreadStreamItem => ({ | ||
| 203 | kind: "event", | ||
| 204 | event: { | ||
| 205 | eventId: EventId.make("event-deleted"), | ||
| 206 | sequence: 3, | ||
| 207 | occurredAt: "2026-04-01T02:00:00.000Z", | ||
| 208 | commandId: null, | ||
| 209 | causationEventId: null, | ||
| 210 | correlationId: null, | ||
| 211 | metadata: {}, | ||
| 212 | aggregateKind: "thread", | ||
| 213 | aggregateId: THREAD_ID, | ||
| 214 | type: "thread.deleted", | ||
| 215 | payload: { | ||
| 216 | threadId: THREAD_ID, | ||
| 217 | deletedAt: "2026-04-01T02:00:00.000Z", | ||
| 218 | }, | ||
| 219 | }, | ||
| 220 | }); | ||
| 221 | |||
| 222 | describe("EnvironmentThreads", () => { | ||
| 223 | it.effect("publishes cached data before a live snapshot arrives", () => | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 226 | const state = yield* awaitThreadState( | ||
| 227 | harness.observed, | ||
| 228 | (value) => value.status === "cached" && Option.isSome(value.data), | ||
| 229 | ); | ||
| 230 | |||
| 231 | expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); | ||
| 232 | expect(Option.isNone(state.error)).toBe(true); | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | |||
| 236 | it.effect("reduces live events and persists the latest thread", () => | ||
| 237 | Effect.gen(function* () { | ||
| 238 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 239 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | ||
| 240 | yield* Queue.offer(harness.inputs, titleUpdated("Live title")); | ||
| 241 | |||
| 242 | const state = yield* awaitThreadState( | ||
| 243 | harness.observed, | ||
| 244 | (value) => | ||
| 245 | value.status === "live" && | ||
| 246 | Option.isSome(value.data) && | ||
| 247 | value.data.value.title === "Live title", | ||
| 248 | ); | ||
| 249 | yield* TestClock.adjust("500 millis"); | ||
| 250 | yield* Effect.yieldNow; | ||
| 251 | |||
| 252 | expect(Option.getOrThrow(state.data).title).toBe("Live title"); | ||
| 253 | expect((yield* Ref.get(harness.savedThreads)).at(-1)?.title).toBe("Live title"); | ||
| 254 | }), | ||
| 255 | ); | ||
| 256 | |||
| 257 | it.effect("ignores replayed thread events at or below the snapshot sequence", () => | ||
| 258 | Effect.gen(function* () { | ||
| 259 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 260 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | ||
| 261 | yield* Queue.offer(harness.inputs, titleUpdated("Replayed title", 1)); | ||
| 262 | yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2)); | ||
| 263 | |||
| 264 | const state = yield* awaitThreadState( | ||
| 265 | harness.observed, | ||
| 266 | (value) => | ||
| 267 | value.status === "live" && | ||
| 268 | Option.isSome(value.data) && | ||
| 269 | value.data.value.title === "Live title", | ||
| 270 | ); | ||
| 271 | |||
| 272 | expect(Option.getOrThrow(state.data).title).toBe("Live title"); | ||
| 273 | }), | ||
| 274 | ); | ||
| 275 | |||
| 276 | it.effect("removes cached data when the thread is deleted", () => | ||
| 277 | Effect.gen(function* () { | ||
| 278 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 279 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | ||
| 280 | yield* Queue.offer(harness.inputs, deleted()); | ||
| 281 | |||
| 282 | const state = yield* awaitThreadState( | ||
| 283 | harness.observed, | ||
| 284 | (value) => value.status === "deleted", | ||
| 285 | ); | ||
| 286 | |||
| 287 | expect(Option.isNone(state.data)).toBe(true); | ||
| 288 | expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); | ||
| 289 | }), | ||
| 290 | ); | ||
| 291 | |||
| 292 | it.effect("preserves data after a domain failure and resumes on a replacement session", () => | ||
| 293 | Effect.gen(function* () { | ||
| 294 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 295 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | ||
| 296 | yield* Queue.offer(harness.inputs, new Error("stream failed")); | ||
| 297 | |||
| 298 | const state = yield* awaitThreadState(harness.observed, (value) => | ||
| 299 | Option.isSome(value.error), | ||
| 300 | ); | ||
| 301 | |||
| 302 | expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); | ||
| 303 | expect(Option.getOrThrow(state.error)).toBe("stream failed"); | ||
| 304 | expect(yield* Ref.get(harness.retryCount)).toBe(0); | ||
| 305 | |||
| 306 | yield* harness.replaceSession; | ||
| 307 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 308 | if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { | ||
| 309 | break; | ||
| 310 | } | ||
| 311 | yield* Effect.yieldNow; | ||
| 312 | } | ||
| 313 | yield* Queue.offer( | ||
| 314 | harness.inputs, | ||
| 315 | snapshot({ | ||
| 316 | ...BASE_THREAD, | ||
| 317 | title: "Recovered thread", | ||
| 318 | }), | ||
| 319 | ); | ||
| 320 | const recovered = yield* awaitThreadState( | ||
| 321 | harness.observed, | ||
| 322 | (value) => | ||
| 323 | value.status === "live" && | ||
| 324 | Option.isSome(value.data) && | ||
| 325 | value.data.value.title === "Recovered thread", | ||
| 326 | ); | ||
| 327 | |||
| 328 | expect(Option.isNone(recovered.error)).toBe(true); | ||
| 329 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); | ||
| 330 | }), | ||
| 331 | ); | ||
| 332 | |||
| 333 | it.effect("recovers from a transient domain failure without replacing the session", () => | ||
| 334 | Effect.gen(function* () { | ||
| 335 | const harness = yield* makeHarness(); | ||
| 336 | yield* Queue.offer(harness.inputs, new Error("thread not found yet")); | ||
| 337 | |||
| 338 | const failed = yield* awaitThreadState(harness.observed, (value) => | ||
| 339 | Option.isSome(value.error), | ||
| 340 | ); | ||
| 341 | expect(Option.getOrThrow(failed.error)).toBe("thread not found yet"); | ||
| 342 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); | ||
| 343 | |||
| 344 | yield* TestClock.adjust("250 millis"); | ||
| 345 | for (let attempt = 0; attempt < 100; attempt += 1) { | ||
| 346 | if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { | ||
| 347 | break; | ||
| 348 | } | ||
| 349 | yield* Effect.yieldNow; | ||
| 350 | } | ||
| 351 | yield* Queue.offer( | ||
| 352 | harness.inputs, | ||
| 353 | snapshot({ | ||
| 354 | ...BASE_THREAD, | ||
| 355 | title: "Materialized thread", | ||
| 356 | }), | ||
| 357 | ); | ||
| 358 | |||
| 359 | const recovered = yield* awaitThreadState( | ||
| 360 | harness.observed, | ||
| 361 | (value) => | ||
| 362 | value.status === "live" && | ||
| 363 | Option.isSome(value.data) && | ||
| 364 | value.data.value.title === "Materialized thread", | ||
| 365 | ); | ||
| 366 | |||
| 367 | expect(Option.isNone(recovered.error)).toBe(true); | ||
| 368 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); | ||
| 369 | expect(yield* Ref.get(harness.retryCount)).toBe(0); | ||
| 370 | }), | ||
| 371 | ); | ||
| 372 | |||
| 373 | it.effect("does not overwrite a live snapshot when the supervisor becomes ready", () => | ||
| 374 | Effect.gen(function* () { | ||
| 375 | const harness = yield* makeHarness({ cached: BASE_THREAD }); | ||
| 376 | yield* SubscriptionRef.set(harness.supervisorState, { | ||
| 377 | desired: true, | ||
| 378 | network: "online", | ||
| 379 | phase: "connecting", | ||
| 380 | stage: "synchronizing", | ||
| 381 | attempt: 1, | ||
| 382 | generation: 0, | ||
| 383 | lastFailure: null, | ||
| 384 | retryAt: null, | ||
| 385 | }); | ||
| 386 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | ||
| 387 | yield* awaitThreadState(harness.observed, (value) => value.status === "live"); | ||
| 388 | |||
| 389 | yield* SubscriptionRef.set(harness.supervisorState, { | ||
| 390 | desired: true, | ||
| 391 | network: "online", | ||
| 392 | phase: "connected", | ||
| 393 | stage: null, | ||
| 394 | attempt: 1, | ||
| 395 | generation: 1, | ||
| 396 | lastFailure: null, | ||
| 397 | retryAt: null, | ||
| 398 | }); | ||
| 399 | for (let index = 0; index < 10; index += 1) { | ||
| 400 | yield* Effect.yieldNow; | ||
| 401 | } | ||
| 402 | |||
| 403 | expect((yield* Ref.get(harness.latest)).status).toBe("live"); | ||
| 404 | }), | ||
| 405 | ); | ||
| 406 | }); | ||
packages/client-runtime/src/state/threads.ts created+269| ... | @@ -0,0 +1,269 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | ORCHESTRATION_WS_METHODS, | ||
| 4 | ThreadId, | ||
| 5 | type EnvironmentId as EnvironmentIdType, | ||
| 6 | type OrchestrationThread, | ||
| 7 | type OrchestrationThreadStreamItem, | ||
| 8 | type ThreadId as ThreadIdType, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Cause from "effect/Cause"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Queue from "effect/Queue"; | ||
| 14 | import * as Stream from "effect/Stream"; | ||
| 15 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 16 | import { Atom } from "effect/unstable/reactivity"; | ||
| 17 | |||
| 18 | import { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 19 | import { connectionProjectionPhase } from "../connection/model.ts"; | ||
| 20 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | ||
| 21 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | ||
| 22 | import { subscribe } from "../rpc/client.ts"; | ||
| 23 | import { applyThreadDetailEvent } from "./threadReducer.ts"; | ||
| 24 | import { followStreamInEnvironment } from "./runtime.ts"; | ||
| 25 | |||
| 26 | export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; | ||
| 27 | |||
| 28 | export interface EnvironmentThreadState { | ||
| 29 | readonly data: Option.Option<OrchestrationThread>; | ||
| 30 | readonly status: EnvironmentThreadStatus; | ||
| 31 | readonly error: Option.Option<string>; | ||
| 32 | } | ||
| 33 | |||
| 34 | export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { | ||
| 35 | data: Option.none(), | ||
| 36 | status: "empty", | ||
| 37 | error: Option.none(), | ||
| 38 | }; | ||
| 39 | |||
| 40 | function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus { | ||
| 41 | return Option.isSome(data) ? "cached" : "empty"; | ||
| 42 | } | ||
| 43 | |||
| 44 | function formatThreadError(cause: Cause.Cause<unknown>): string { | ||
| 45 | const error = Cause.squash(cause); | ||
| 46 | return error instanceof Error && error.message.trim().length > 0 | ||
| 47 | ? error.message | ||
| 48 | : "Could not synchronize the thread."; | ||
| 49 | } | ||
| 50 | |||
| 51 | export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( | ||
| 52 | threadId: ThreadIdType, | ||
| 53 | ) { | ||
| 54 | const supervisor = yield* EnvironmentSupervisor; | ||
| 55 | const cache = yield* EnvironmentCacheStore; | ||
| 56 | const environmentId = supervisor.target.environmentId; | ||
| 57 | const cached = yield* cache.loadThread(environmentId, threadId).pipe( | ||
| 58 | Effect.catch((error) => | ||
| 59 | Effect.logWarning("Could not load cached thread.").pipe( | ||
| 60 | Effect.annotateLogs({ | ||
| 61 | environmentId, | ||
| 62 | threadId, | ||
| 63 | error: error.message, | ||
| 64 | }), | ||
| 65 | Effect.as(Option.none<OrchestrationThread>()), | ||
| 66 | ), | ||
| 67 | ), | ||
| 68 | ); | ||
| 69 | const state = yield* SubscriptionRef.make<EnvironmentThreadState>({ | ||
| 70 | data: cached, | ||
| 71 | status: statusWithoutLiveData(cached), | ||
| 72 | error: Option.none(), | ||
| 73 | }); | ||
| 74 | const lastSequence = yield* SubscriptionRef.make(0); | ||
| 75 | const persistence = yield* Queue.sliding<OrchestrationThread>(1); | ||
| 76 | |||
| 77 | const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( | ||
| 78 | thread: OrchestrationThread, | ||
| 79 | ) { | ||
| 80 | yield* cache.saveThread(environmentId, thread).pipe( | ||
| 81 | Effect.catch((error) => | ||
| 82 | Effect.logWarning("Could not persist the thread cache.").pipe( | ||
| 83 | Effect.annotateLogs({ | ||
| 84 | environmentId, | ||
| 85 | threadId, | ||
| 86 | error: error.message, | ||
| 87 | }), | ||
| 88 | ), | ||
| 89 | ), | ||
| 90 | ); | ||
| 91 | }); | ||
| 92 | |||
| 93 | yield* Stream.fromQueue(persistence).pipe( | ||
| 94 | Stream.debounce("500 millis"), | ||
| 95 | Stream.runForEach(persist), | ||
| 96 | Effect.forkScoped, | ||
| 97 | ); | ||
| 98 | |||
| 99 | const setSynchronizing = SubscriptionRef.update(state, (current) => ({ | ||
| 100 | ...current, | ||
| 101 | status: "synchronizing" as const, | ||
| 102 | error: Option.none(), | ||
| 103 | })); | ||
| 104 | const setReady = SubscriptionRef.update(state, (current) => | ||
| 105 | current.status === "live" || current.status === "deleted" | ||
| 106 | ? current | ||
| 107 | : { | ||
| 108 | ...current, | ||
| 109 | status: "synchronizing" as const, | ||
| 110 | error: Option.none(), | ||
| 111 | }, | ||
| 112 | ); | ||
| 113 | const setDisconnected = SubscriptionRef.update(state, (current) => ({ | ||
| 114 | ...current, | ||
| 115 | status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), | ||
| 116 | })); | ||
| 117 | const setStreamError = (cause: Cause.Cause<unknown>) => | ||
| 118 | SubscriptionRef.update(state, (current) => ({ | ||
| 119 | ...current, | ||
| 120 | status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), | ||
| 121 | error: Option.some(formatThreadError(cause)), | ||
| 122 | })); | ||
| 123 | |||
| 124 | const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( | ||
| 125 | thread: OrchestrationThread, | ||
| 126 | ) { | ||
| 127 | yield* SubscriptionRef.set(state, { | ||
| 128 | data: Option.some(thread), | ||
| 129 | status: "live", | ||
| 130 | error: Option.none(), | ||
| 131 | }); | ||
| 132 | yield* Queue.offer(persistence, thread); | ||
| 133 | }); | ||
| 134 | |||
| 135 | const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { | ||
| 136 | yield* SubscriptionRef.set(state, { | ||
| 137 | data: Option.none(), | ||
| 138 | status: "deleted", | ||
| 139 | error: Option.none(), | ||
| 140 | }); | ||
| 141 | yield* cache.removeThread(environmentId, threadId).pipe( | ||
| 142 | Effect.catch((error) => | ||
| 143 | Effect.logWarning("Could not remove the cached thread.").pipe( | ||
| 144 | Effect.annotateLogs({ | ||
| 145 | environmentId, | ||
| 146 | threadId, | ||
| 147 | error: error.message, | ||
| 148 | }), | ||
| 149 | ), | ||
| 150 | ), | ||
| 151 | ); | ||
| 152 | }); | ||
| 153 | |||
| 154 | const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( | ||
| 155 | item: OrchestrationThreadStreamItem, | ||
| 156 | ) { | ||
| 157 | if (item.kind === "snapshot") { | ||
| 158 | yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); | ||
| 159 | yield* setThread(item.snapshot.thread); | ||
| 160 | return; | ||
| 161 | } | ||
| 162 | |||
| 163 | const sequence = yield* SubscriptionRef.get(lastSequence); | ||
| 164 | if (item.event.sequence <= sequence) { | ||
| 165 | return; | ||
| 166 | } | ||
| 167 | yield* SubscriptionRef.set(lastSequence, item.event.sequence); | ||
| 168 | |||
| 169 | const current = yield* SubscriptionRef.get(state); | ||
| 170 | if (Option.isNone(current.data)) { | ||
| 171 | if (item.event.type === "thread.deleted") { | ||
| 172 | yield* setDeleted(); | ||
| 173 | } | ||
| 174 | return; | ||
| 175 | } | ||
| 176 | const result = applyThreadDetailEvent(current.data.value, item.event); | ||
| 177 | if (result.kind === "updated") { | ||
| 178 | yield* setThread(result.thread); | ||
| 179 | } else if (result.kind === "deleted") { | ||
| 180 | yield* setDeleted(); | ||
| 181 | } | ||
| 182 | }); | ||
| 183 | |||
| 184 | yield* SubscriptionRef.changes(supervisor.state).pipe( | ||
| 185 | Stream.runForEach((connectionState) => { | ||
| 186 | switch (connectionProjectionPhase(connectionState)) { | ||
| 187 | case "synchronizing": | ||
| 188 | return setSynchronizing; | ||
| 189 | case "disconnected": | ||
| 190 | return setDisconnected; | ||
| 191 | case "ready": | ||
| 192 | return setReady; | ||
| 193 | } | ||
| 194 | }), | ||
| 195 | Effect.forkScoped, | ||
| 196 | ); | ||
| 197 | |||
| 198 | yield* setSynchronizing; | ||
| 199 | yield* subscribe( | ||
| 200 | ORCHESTRATION_WS_METHODS.subscribeThread, | ||
| 201 | { threadId }, | ||
| 202 | { | ||
| 203 | onExpectedFailure: setStreamError, | ||
| 204 | retryExpectedFailureAfter: "250 millis", | ||
| 205 | }, | ||
| 206 | ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); | ||
| 207 | |||
| 208 | yield* Effect.addFinalizer(() => | ||
| 209 | SubscriptionRef.get(state).pipe( | ||
| 210 | Effect.flatMap((current) => | ||
| 211 | Option.match(current.data, { | ||
| 212 | onNone: () => Effect.void, | ||
| 213 | onSome: persist, | ||
| 214 | }), | ||
| 215 | ), | ||
| 216 | ), | ||
| 217 | ); | ||
| 218 | |||
| 219 | return state; | ||
| 220 | }); | ||
| 221 | |||
| 222 | export function threadStateChanges(environmentId: EnvironmentIdType, threadId: ThreadIdType) { | ||
| 223 | return followStreamInEnvironment( | ||
| 224 | environmentId, | ||
| 225 | Stream.unwrap(makeEnvironmentThreadState(threadId).pipe(Effect.map(SubscriptionRef.changes))), | ||
| 226 | ); | ||
| 227 | } | ||
| 228 | |||
| 229 | function threadAtomKey(environmentId: EnvironmentIdType, threadId: ThreadIdType): string { | ||
| 230 | return `${environmentId}\u0000${threadId}`; | ||
| 231 | } | ||
| 232 | |||
| 233 | function parseThreadAtomKey(key: string): { | ||
| 234 | readonly environmentId: EnvironmentIdType; | ||
| 235 | readonly threadId: ThreadIdType; | ||
| 236 | } { | ||
| 237 | const separator = key.indexOf("\u0000"); | ||
| 238 | if (separator < 0) { | ||
| 239 | throw new Error("Invalid environment thread atom key."); | ||
| 240 | } | ||
| 241 | return { | ||
| 242 | environmentId: EnvironmentId.make(key.slice(0, separator)), | ||
| 243 | threadId: ThreadId.make(key.slice(separator + 1)), | ||
| 244 | }; | ||
| 245 | } | ||
| 246 | |||
| 247 | export function createEnvironmentThreadStateAtoms<R, E>( | ||
| 248 | runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>, | ||
| 249 | ) { | ||
| 250 | const family = Atom.family((key: string) => { | ||
| 251 | const { environmentId, threadId } = parseThreadAtomKey(key); | ||
| 252 | return runtime.atom(threadStateChanges(environmentId, threadId), { | ||
| 253 | initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, | ||
| 254 | }); | ||
| 255 | }); | ||
| 256 | |||
| 257 | return { | ||
| 258 | stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => | ||
| 259 | family(threadAtomKey(environmentId, threadId)), | ||
| 260 | }; | ||
| 261 | } | ||
| 262 | |||
| 263 | export * from "./archivedThreads.ts"; | ||
| 264 | export * from "./checkpointDiff.ts"; | ||
| 265 | export * from "./composerPathSearch.ts"; | ||
| 266 | export * from "./threadCommands.ts"; | ||
| 267 | export * from "./threadDetail.ts"; | ||
| 268 | export * from "./threadReducer.ts"; | ||
| 269 | export * from "./threadShell.ts"; | ||
packages/client-runtime/src/state/vcs.ts created+85| ... | @@ -0,0 +1,85 @@ | ||
| 1 | import { type VcsStatusResult, WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; | ||
| 3 | import * as Stream from "effect/Stream"; | ||
| 4 | import { Atom } from "effect/unstable/reactivity"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | createEnvironmentRpcCommand, | ||
| 8 | createEnvironmentRpcQueryAtomFamily, | ||
| 9 | createEnvironmentSubscriptionAtomFamily, | ||
| 10 | } from "./runtime.ts"; | ||
| 11 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 12 | import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; | ||
| 13 | import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; | ||
| 14 | |||
| 15 | export function createVcsEnvironmentAtoms<R, E>( | ||
| 16 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 17 | ) { | ||
| 18 | return { | ||
| 19 | listRefs: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 20 | label: "environment-data:vcs:list-refs", | ||
| 21 | tag: WS_METHODS.vcsListRefs, | ||
| 22 | staleTimeMs: 5_000, | ||
| 23 | }), | ||
| 24 | status: createEnvironmentSubscriptionAtomFamily(runtime, { | ||
| 25 | label: "environment-data:vcs:status", | ||
| 26 | subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) => | ||
| 27 | subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( | ||
| 28 | Stream.mapAccum( | ||
| 29 | () => null as VcsStatusResult | null, | ||
| 30 | (current, event) => { | ||
| 31 | const next = applyGitStatusStreamEvent(current, event); | ||
| 32 | return [next, [next]] as const; | ||
| 33 | }, | ||
| 34 | ), | ||
| 35 | ), | ||
| 36 | }), | ||
| 37 | pull: createEnvironmentRpcCommand(runtime, { | ||
| 38 | label: "environment-data:vcs:pull", | ||
| 39 | tag: WS_METHODS.vcsPull, | ||
| 40 | scheduler: vcsCommandScheduler, | ||
| 41 | concurrency: vcsCommandConcurrency, | ||
| 42 | }), | ||
| 43 | refreshStatus: createEnvironmentRpcCommand(runtime, { | ||
| 44 | label: "environment-data:vcs:refresh-status", | ||
| 45 | tag: WS_METHODS.vcsRefreshStatus, | ||
| 46 | scheduler: vcsCommandScheduler, | ||
| 47 | concurrency: vcsCommandConcurrency, | ||
| 48 | }), | ||
| 49 | createWorktree: createEnvironmentRpcCommand(runtime, { | ||
| 50 | label: "environment-data:vcs:create-worktree", | ||
| 51 | tag: WS_METHODS.vcsCreateWorktree, | ||
| 52 | scheduler: vcsCommandScheduler, | ||
| 53 | concurrency: vcsCommandConcurrency, | ||
| 54 | }), | ||
| 55 | removeWorktree: createEnvironmentRpcCommand(runtime, { | ||
| 56 | label: "environment-data:vcs:remove-worktree", | ||
| 57 | tag: WS_METHODS.vcsRemoveWorktree, | ||
| 58 | scheduler: vcsCommandScheduler, | ||
| 59 | concurrency: vcsCommandConcurrency, | ||
| 60 | }), | ||
| 61 | createRef: createEnvironmentRpcCommand(runtime, { | ||
| 62 | label: "environment-data:vcs:create-ref", | ||
| 63 | tag: WS_METHODS.vcsCreateRef, | ||
| 64 | scheduler: vcsCommandScheduler, | ||
| 65 | concurrency: vcsCommandConcurrency, | ||
| 66 | }), | ||
| 67 | switchRef: createEnvironmentRpcCommand(runtime, { | ||
| 68 | label: "environment-data:vcs:switch-ref", | ||
| 69 | tag: WS_METHODS.vcsSwitchRef, | ||
| 70 | scheduler: vcsCommandScheduler, | ||
| 71 | concurrency: vcsCommandConcurrency, | ||
| 72 | }), | ||
| 73 | init: createEnvironmentRpcCommand(runtime, { | ||
| 74 | label: "environment-data:vcs:init", | ||
| 75 | tag: WS_METHODS.vcsInit, | ||
| 76 | scheduler: vcsCommandScheduler, | ||
| 77 | concurrency: vcsCommandConcurrency, | ||
| 78 | }), | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | export * from "./gitActions.ts"; | ||
| 83 | export * from "./vcsAction.ts"; | ||
| 84 | export * from "./vcsRef.ts"; | ||
| 85 | export * from "./vcsStatus.ts"; | ||
packages/client-runtime/src/state/vcsAction.test.ts created+342| ... | @@ -0,0 +1,342 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | type GitActionProgressEvent, | ||
| 4 | type GitRunStackedActionResult, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Cause from "effect/Cause"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 12 | |||
| 13 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 14 | import type { AtomCommandResult } from "./runtime.ts"; | ||
| 15 | import { | ||
| 16 | applyVcsActionProgressEvent, | ||
| 17 | beginVcsActionState, | ||
| 18 | consumeVcsActionProgress, | ||
| 19 | createVcsActionManager, | ||
| 20 | createVcsActionTransportId, | ||
| 21 | EMPTY_VCS_ACTION_STATE, | ||
| 22 | getVcsActionTargetKey, | ||
| 23 | normalizeVcsActionProgressEvent, | ||
| 24 | } from "./vcsAction.ts"; | ||
| 25 | |||
| 26 | const actionId = "action-123"; | ||
| 27 | const action = "commit_push" as const; | ||
| 28 | const cwd = "/repo"; | ||
| 29 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 30 | const result: GitRunStackedActionResult = { | ||
| 31 | action, | ||
| 32 | branch: { | ||
| 33 | status: "skipped_not_requested", | ||
| 34 | }, | ||
| 35 | commit: { | ||
| 36 | status: "created", | ||
| 37 | commitSha: "abc123", | ||
| 38 | subject: "Test commit", | ||
| 39 | }, | ||
| 40 | push: { | ||
| 41 | status: "pushed", | ||
| 42 | branch: "feature", | ||
| 43 | }, | ||
| 44 | pr: { | ||
| 45 | status: "skipped_not_requested", | ||
| 46 | }, | ||
| 47 | toast: { | ||
| 48 | title: "Changes pushed", | ||
| 49 | cta: { | ||
| 50 | kind: "none", | ||
| 51 | }, | ||
| 52 | }, | ||
| 53 | }; | ||
| 54 | |||
| 55 | function progress<T extends GitActionProgressEvent>(event: T): T { | ||
| 56 | return event; | ||
| 57 | } | ||
| 58 | |||
| 59 | describe("vcsActionState", () => { | ||
| 60 | it("projects phase and hook progress without owning the async operation", () => { | ||
| 61 | const initial = beginVcsActionState({ | ||
| 62 | operation: "run_change_request", | ||
| 63 | label: "Running source control action", | ||
| 64 | actionId, | ||
| 65 | }); | ||
| 66 | const phase = applyVcsActionProgressEvent( | ||
| 67 | initial, | ||
| 68 | progress({ | ||
| 69 | actionId, | ||
| 70 | action, | ||
| 71 | cwd, | ||
| 72 | kind: "phase_started", | ||
| 73 | phase: "commit", | ||
| 74 | label: "Committing...", | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | const hook = applyVcsActionProgressEvent( | ||
| 78 | phase, | ||
| 79 | progress({ | ||
| 80 | actionId, | ||
| 81 | action, | ||
| 82 | cwd, | ||
| 83 | kind: "hook_started", | ||
| 84 | hookName: "post-commit", | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | const output = applyVcsActionProgressEvent( | ||
| 88 | hook, | ||
| 89 | progress({ | ||
| 90 | actionId, | ||
| 91 | action, | ||
| 92 | cwd, | ||
| 93 | kind: "hook_output", | ||
| 94 | hookName: "post-commit", | ||
| 95 | stream: "stdout", | ||
| 96 | text: "hook output", | ||
| 97 | }), | ||
| 98 | ); | ||
| 99 | const finished = applyVcsActionProgressEvent( | ||
| 100 | output, | ||
| 101 | progress({ | ||
| 102 | actionId, | ||
| 103 | action, | ||
| 104 | cwd, | ||
| 105 | kind: "hook_finished", | ||
| 106 | hookName: "post-commit", | ||
| 107 | exitCode: 0, | ||
| 108 | durationMs: 12, | ||
| 109 | }), | ||
| 110 | ); | ||
| 111 | |||
| 112 | expect(phase).toMatchObject({ | ||
| 113 | isRunning: true, | ||
| 114 | currentLabel: "Committing...", | ||
| 115 | currentPhaseLabel: "Committing...", | ||
| 116 | }); | ||
| 117 | expect(output).toMatchObject({ | ||
| 118 | currentLabel: "Running post-commit...", | ||
| 119 | hookName: "post-commit", | ||
| 120 | lastOutputLine: "hook output", | ||
| 121 | }); | ||
| 122 | expect(finished).toMatchObject({ | ||
| 123 | currentLabel: "Committing...", | ||
| 124 | hookName: null, | ||
| 125 | lastOutputLine: null, | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("retains a terminal action error for presentation", () => { | ||
| 130 | const initial = beginVcsActionState({ | ||
| 131 | operation: "run_change_request", | ||
| 132 | label: "Running source control action", | ||
| 133 | actionId, | ||
| 134 | }); | ||
| 135 | const failed = applyVcsActionProgressEvent( | ||
| 136 | initial, | ||
| 137 | progress({ | ||
| 138 | actionId, | ||
| 139 | action, | ||
| 140 | cwd, | ||
| 141 | kind: "action_failed", | ||
| 142 | phase: null, | ||
| 143 | message: "Push failed.", | ||
| 144 | }), | ||
| 145 | ); | ||
| 146 | |||
| 147 | expect(failed).toMatchObject({ | ||
| 148 | isRunning: false, | ||
| 149 | operation: "run_change_request", | ||
| 150 | actionId, | ||
| 151 | action, | ||
| 152 | error: "Push failed.", | ||
| 153 | }); | ||
| 154 | }); | ||
| 155 | |||
| 156 | it("ignores progress after a newer action owns the target", () => { | ||
| 157 | const current = beginVcsActionState({ | ||
| 158 | operation: "pull", | ||
| 159 | label: "Pulling latest changes", | ||
| 160 | actionId: "newer-action", | ||
| 161 | }); | ||
| 162 | |||
| 163 | expect( | ||
| 164 | applyVcsActionProgressEvent( | ||
| 165 | current, | ||
| 166 | progress({ | ||
| 167 | actionId, | ||
| 168 | action, | ||
| 169 | cwd, | ||
| 170 | kind: "phase_started", | ||
| 171 | phase: "push", | ||
| 172 | label: "Pushing...", | ||
| 173 | }), | ||
| 174 | ), | ||
| 175 | ).toBe(current); | ||
| 176 | }); | ||
| 177 | |||
| 178 | it("keys presentation state only when the environment and repository are known", () => { | ||
| 179 | expect( | ||
| 180 | getVcsActionTargetKey({ | ||
| 181 | environmentId, | ||
| 182 | cwd, | ||
| 183 | }), | ||
| 184 | ).toBe(JSON.stringify([environmentId, cwd])); | ||
| 185 | expect(getVcsActionTargetKey({ environmentId: null, cwd })).toBeNull(); | ||
| 186 | expect( | ||
| 187 | getVcsActionTargetKey({ | ||
| 188 | environmentId, | ||
| 189 | cwd: null, | ||
| 190 | }), | ||
| 191 | ).toBeNull(); | ||
| 192 | }); | ||
| 193 | |||
| 194 | it("normalizes progress only for the matching environment-scoped action", () => { | ||
| 195 | const target = { environmentId, cwd }; | ||
| 196 | const otherTarget = { | ||
| 197 | environmentId: EnvironmentId.make("environment-2"), | ||
| 198 | cwd, | ||
| 199 | }; | ||
| 200 | const transportActionId = createVcsActionTransportId(target, actionId); | ||
| 201 | const event = progress({ | ||
| 202 | actionId: createVcsActionTransportId(otherTarget, actionId), | ||
| 203 | action, | ||
| 204 | cwd, | ||
| 205 | kind: "phase_started", | ||
| 206 | phase: "push", | ||
| 207 | label: "Pushing...", | ||
| 208 | }); | ||
| 209 | |||
| 210 | expect(normalizeVcsActionProgressEvent(target, transportActionId, actionId, event)).toBeNull(); | ||
| 211 | expect( | ||
| 212 | normalizeVcsActionProgressEvent(target, transportActionId, actionId, { | ||
| 213 | ...event, | ||
| 214 | actionId: transportActionId, | ||
| 215 | }), | ||
| 216 | ).toEqual({ | ||
| 217 | ...event, | ||
| 218 | actionId, | ||
| 219 | }); | ||
| 220 | }); | ||
| 221 | |||
| 222 | it.effect("consumes progress through the terminal event and returns its result", () => | ||
| 223 | Effect.gen(function* () { | ||
| 224 | const target = { environmentId, cwd }; | ||
| 225 | const transportActionId = createVcsActionTransportId(target, actionId); | ||
| 226 | const observed: GitActionProgressEvent[] = []; | ||
| 227 | const events: GitActionProgressEvent[] = [ | ||
| 228 | { | ||
| 229 | actionId: "unrelated-action", | ||
| 230 | action, | ||
| 231 | cwd, | ||
| 232 | kind: "phase_started", | ||
| 233 | phase: "commit", | ||
| 234 | label: "Ignored", | ||
| 235 | }, | ||
| 236 | { | ||
| 237 | actionId: transportActionId, | ||
| 238 | action, | ||
| 239 | cwd, | ||
| 240 | kind: "phase_started", | ||
| 241 | phase: "push", | ||
| 242 | label: "Pushing...", | ||
| 243 | }, | ||
| 244 | { | ||
| 245 | actionId: transportActionId, | ||
| 246 | action, | ||
| 247 | cwd, | ||
| 248 | kind: "action_finished", | ||
| 249 | result, | ||
| 250 | }, | ||
| 251 | ]; | ||
| 252 | |||
| 253 | const actual = yield* consumeVcsActionProgress(Stream.fromIterable(events), { | ||
| 254 | target, | ||
| 255 | transportActionId, | ||
| 256 | actionId, | ||
| 257 | onProgress: (event) => | ||
| 258 | Effect.sync(() => { | ||
| 259 | observed.push(event); | ||
| 260 | }), | ||
| 261 | }); | ||
| 262 | |||
| 263 | expect(actual).toEqual(result); | ||
| 264 | expect(observed.map((event) => event.actionId)).toEqual([actionId, actionId]); | ||
| 265 | expect(observed.map((event) => event.kind)).toEqual(["phase_started", "action_finished"]); | ||
| 266 | }), | ||
| 267 | ); | ||
| 268 | |||
| 269 | it("keys mutation ownership by environment and cwd", () => { | ||
| 270 | const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< | ||
| 271 | EnvironmentRegistry, | ||
| 272 | never | ||
| 273 | >; | ||
| 274 | const manager = createVcsActionManager(runtime); | ||
| 275 | const registry = AtomRegistry.make(); | ||
| 276 | const target = { environmentId, cwd }; | ||
| 277 | const otherTarget = { | ||
| 278 | environmentId: EnvironmentId.make("environment-2"), | ||
| 279 | cwd, | ||
| 280 | }; | ||
| 281 | |||
| 282 | expect(manager.runStackedAction(target)).toBe(manager.runStackedAction({ ...target })); | ||
| 283 | expect(manager.runStackedAction(target)).not.toBe(manager.runStackedAction(otherTarget)); | ||
| 284 | expect(registry.get(manager.stateAtom(target))).toEqual(EMPTY_VCS_ACTION_STATE); | ||
| 285 | |||
| 286 | registry.dispose(); | ||
| 287 | }); | ||
| 288 | |||
| 289 | it("tracks finite mutations without letting an older completion clear newer state", async () => { | ||
| 290 | const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< | ||
| 291 | EnvironmentRegistry, | ||
| 292 | never | ||
| 293 | >; | ||
| 294 | const manager = createVcsActionManager(runtime); | ||
| 295 | const registry = AtomRegistry.make(); | ||
| 296 | const target = { environmentId, cwd }; | ||
| 297 | let finishFirst!: () => void; | ||
| 298 | let failSecond!: (error: Error) => void; | ||
| 299 | const firstAction = new Promise<AtomCommandResult<void, never>>((resolve) => { | ||
| 300 | finishFirst = () => resolve(AsyncResult.success(undefined)); | ||
| 301 | }); | ||
| 302 | const secondAction = new Promise<AtomCommandResult<void, Error>>((resolve) => { | ||
| 303 | failSecond = (error) => resolve(AsyncResult.failure(Cause.fail(error))); | ||
| 304 | }); | ||
| 305 | |||
| 306 | const first = manager.track( | ||
| 307 | registry, | ||
| 308 | target, | ||
| 309 | { operation: "pull", label: "Pulling latest changes" }, | ||
| 310 | () => firstAction, | ||
| 311 | ); | ||
| 312 | const firstActionId = registry.get(manager.stateAtom(target)).actionId; | ||
| 313 | const second = manager.track( | ||
| 314 | registry, | ||
| 315 | target, | ||
| 316 | { operation: "switch_ref", label: "Switching branch" }, | ||
| 317 | () => secondAction, | ||
| 318 | ); | ||
| 319 | const secondActionId = registry.get(manager.stateAtom(target)).actionId; | ||
| 320 | |||
| 321 | finishFirst(); | ||
| 322 | await first; | ||
| 323 | expect(registry.get(manager.stateAtom(target))).toMatchObject({ | ||
| 324 | actionId: secondActionId, | ||
| 325 | isRunning: true, | ||
| 326 | operation: "switch_ref", | ||
| 327 | }); | ||
| 328 | expect(secondActionId).not.toBe(firstActionId); | ||
| 329 | |||
| 330 | failSecond(new Error("switch failed")); | ||
| 331 | const secondFailure = await second; | ||
| 332 | expect(AsyncResult.isFailure(secondFailure)).toBe(true); | ||
| 333 | expect(registry.get(manager.stateAtom(target))).toMatchObject({ | ||
| 334 | actionId: secondActionId, | ||
| 335 | error: "switch failed", | ||
| 336 | isRunning: false, | ||
| 337 | operation: "switch_ref", | ||
| 338 | }); | ||
| 339 | |||
| 340 | registry.dispose(); | ||
| 341 | }); | ||
| 342 | }); | ||
packages/client-runtime/src/state/vcsAction.ts created+499| ... | @@ -0,0 +1,499 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | type EnvironmentId as EnvironmentIdType, | ||
| 4 | type GitActionProgressEvent, | ||
| 5 | type GitRunStackedActionInput, | ||
| 6 | type GitRunStackedActionResult, | ||
| 7 | type GitStackedAction, | ||
| 8 | WS_METHODS, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Cause from "effect/Cause"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Schema from "effect/Schema"; | ||
| 14 | import * as Stream from "effect/Stream"; | ||
| 15 | import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 16 | |||
| 17 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 18 | import { runStream } from "../rpc/client.ts"; | ||
| 19 | import { | ||
| 20 | createRuntimeCommand, | ||
| 21 | runStreamInEnvironment, | ||
| 22 | type AtomCommand, | ||
| 23 | type AtomCommandResult, | ||
| 24 | } from "./runtime.ts"; | ||
| 25 | import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; | ||
| 26 | |||
| 27 | export type VcsActionOperation = | ||
| 28 | | "refresh_status" | ||
| 29 | | "run_change_request" | ||
| 30 | | "pull" | ||
| 31 | | "switch_ref" | ||
| 32 | | "create_ref" | ||
| 33 | | "create_worktree" | ||
| 34 | | "init" | ||
| 35 | | "publish_repository" | ||
| 36 | | "prepare_pull_request_thread"; | ||
| 37 | |||
| 38 | export interface VcsActionState { | ||
| 39 | readonly isRunning: boolean; | ||
| 40 | readonly operation: VcsActionOperation | null; | ||
| 41 | readonly actionId: string | null; | ||
| 42 | readonly action: GitStackedAction | null; | ||
| 43 | readonly currentLabel: string | null; | ||
| 44 | readonly currentPhaseLabel: string | null; | ||
| 45 | readonly hookName: string | null; | ||
| 46 | readonly lastOutputLine: string | null; | ||
| 47 | readonly phaseStartedAtMs: number | null; | ||
| 48 | readonly hookStartedAtMs: number | null; | ||
| 49 | readonly error: string | null; | ||
| 50 | } | ||
| 51 | |||
| 52 | export interface VcsActionTarget { | ||
| 53 | readonly environmentId: EnvironmentIdType | null; | ||
| 54 | readonly cwd: string | null; | ||
| 55 | } | ||
| 56 | |||
| 57 | export interface ResolvedVcsActionTarget { | ||
| 58 | readonly environmentId: EnvironmentIdType; | ||
| 59 | readonly cwd: string; | ||
| 60 | } | ||
| 61 | |||
| 62 | export interface BeginVcsActionInput { | ||
| 63 | readonly operation: VcsActionOperation; | ||
| 64 | readonly label: string; | ||
| 65 | readonly actionId?: string; | ||
| 66 | } | ||
| 67 | |||
| 68 | export interface RunVcsStackedActionInput { | ||
| 69 | readonly actionId: string; | ||
| 70 | readonly action: GitStackedAction; | ||
| 71 | readonly commitMessage?: string; | ||
| 72 | readonly featureBranch?: boolean; | ||
| 73 | readonly filePaths?: ReadonlyArray<string>; | ||
| 74 | readonly onProgress?: (event: GitActionProgressEvent) => void; | ||
| 75 | } | ||
| 76 | |||
| 77 | export class VcsActionUnavailableError extends Schema.TaggedErrorClass<VcsActionUnavailableError>()( | ||
| 78 | "VcsActionUnavailableError", | ||
| 79 | { | ||
| 80 | message: Schema.String, | ||
| 81 | }, | ||
| 82 | ) {} | ||
| 83 | |||
| 84 | export class VcsActionExecutionError extends Schema.TaggedErrorClass<VcsActionExecutionError>()( | ||
| 85 | "VcsActionExecutionError", | ||
| 86 | { | ||
| 87 | message: Schema.String, | ||
| 88 | }, | ||
| 89 | ) {} | ||
| 90 | |||
| 91 | export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({ | ||
| 92 | isRunning: false, | ||
| 93 | operation: null, | ||
| 94 | actionId: null, | ||
| 95 | action: null, | ||
| 96 | currentLabel: null, | ||
| 97 | currentPhaseLabel: null, | ||
| 98 | hookName: null, | ||
| 99 | lastOutputLine: null, | ||
| 100 | phaseStartedAtMs: null, | ||
| 101 | hookStartedAtMs: null, | ||
| 102 | error: null, | ||
| 103 | }); | ||
| 104 | |||
| 105 | const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe()); | ||
| 106 | let nextLocalActionId = 0; | ||
| 107 | |||
| 108 | export const vcsActionStateAtom = Atom.family((key: string) => { | ||
| 109 | return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( | ||
| 110 | Atom.keepAlive, | ||
| 111 | Atom.withLabel(`vcs-action:${key}`), | ||
| 112 | ); | ||
| 113 | }); | ||
| 114 | |||
| 115 | export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( | ||
| 116 | Atom.keepAlive, | ||
| 117 | Atom.withLabel("vcs-action:null"), | ||
| 118 | ); | ||
| 119 | |||
| 120 | export function getVcsActionTargetKey(target: VcsActionTarget): string | null { | ||
| 121 | if (target.environmentId === null || target.cwd === null) { | ||
| 122 | return null; | ||
| 123 | } | ||
| 124 | return JSON.stringify([target.environmentId, target.cwd]); | ||
| 125 | } | ||
| 126 | |||
| 127 | function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { | ||
| 128 | const [environmentId, cwd] = JSON.parse(key) as [string, string]; | ||
| 129 | return { | ||
| 130 | environmentId: EnvironmentId.make(environmentId), | ||
| 131 | cwd, | ||
| 132 | }; | ||
| 133 | } | ||
| 134 | |||
| 135 | export function getVcsActionStateAtom(target: VcsActionTarget) { | ||
| 136 | const key = getVcsActionTargetKey(target); | ||
| 137 | return key === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key); | ||
| 138 | } | ||
| 139 | |||
| 140 | function createLocalActionId(): string { | ||
| 141 | nextLocalActionId += 1; | ||
| 142 | return `local-vcs-action:${nextLocalActionId}`; | ||
| 143 | } | ||
| 144 | |||
| 145 | export function beginVcsActionState( | ||
| 146 | input: BeginVcsActionInput, | ||
| 147 | ): VcsActionState & { readonly actionId: string } { | ||
| 148 | const actionId = input.actionId ?? createLocalActionId(); | ||
| 149 | const startedAt = nowMs(); | ||
| 150 | return { | ||
| 151 | ...EMPTY_VCS_ACTION_STATE, | ||
| 152 | isRunning: true, | ||
| 153 | operation: input.operation, | ||
| 154 | actionId, | ||
| 155 | currentLabel: input.label, | ||
| 156 | currentPhaseLabel: input.label, | ||
| 157 | phaseStartedAtMs: startedAt, | ||
| 158 | }; | ||
| 159 | } | ||
| 160 | |||
| 161 | export function failVcsActionState( | ||
| 162 | operation: VcsActionOperation, | ||
| 163 | actionId: string, | ||
| 164 | error: unknown, | ||
| 165 | ): VcsActionState { | ||
| 166 | return { | ||
| 167 | ...EMPTY_VCS_ACTION_STATE, | ||
| 168 | operation, | ||
| 169 | actionId, | ||
| 170 | error: error instanceof Error ? error.message : "Source control action failed.", | ||
| 171 | }; | ||
| 172 | } | ||
| 173 | |||
| 174 | export function createVcsActionTransportId( | ||
| 175 | target: ResolvedVcsActionTarget, | ||
| 176 | actionId: string, | ||
| 177 | ): string { | ||
| 178 | const targetKey = JSON.stringify([target.environmentId, target.cwd]); | ||
| 179 | return `${targetKey.length}:${targetKey}${actionId}`; | ||
| 180 | } | ||
| 181 | |||
| 182 | export function normalizeVcsActionProgressEvent( | ||
| 183 | target: ResolvedVcsActionTarget, | ||
| 184 | transportActionId: string, | ||
| 185 | actionId: string, | ||
| 186 | event: GitActionProgressEvent, | ||
| 187 | ): GitActionProgressEvent | null { | ||
| 188 | if (event.actionId !== transportActionId || event.cwd !== target.cwd) { | ||
| 189 | return null; | ||
| 190 | } | ||
| 191 | return { | ||
| 192 | ...event, | ||
| 193 | actionId, | ||
| 194 | }; | ||
| 195 | } | ||
| 196 | |||
| 197 | export function consumeVcsActionProgress<E, R>( | ||
| 198 | stream: Stream.Stream<GitActionProgressEvent, E, R>, | ||
| 199 | input: { | ||
| 200 | readonly target: ResolvedVcsActionTarget; | ||
| 201 | readonly transportActionId: string; | ||
| 202 | readonly actionId: string; | ||
| 203 | readonly onProgress: (event: GitActionProgressEvent) => Effect.Effect<void>; | ||
| 204 | }, | ||
| 205 | ): Effect.Effect<GitRunStackedActionResult, E | VcsActionExecutionError, R> { | ||
| 206 | return Effect.suspend(() => { | ||
| 207 | let terminalEvent: GitActionProgressEvent | null = null; | ||
| 208 | return stream.pipe( | ||
| 209 | Stream.runForEach((event) => { | ||
| 210 | const normalized = normalizeVcsActionProgressEvent( | ||
| 211 | input.target, | ||
| 212 | input.transportActionId, | ||
| 213 | input.actionId, | ||
| 214 | event, | ||
| 215 | ); | ||
| 216 | if (normalized === null) { | ||
| 217 | return Effect.void; | ||
| 218 | } | ||
| 219 | if (normalized.kind === "action_finished" || normalized.kind === "action_failed") { | ||
| 220 | terminalEvent = normalized; | ||
| 221 | } | ||
| 222 | return input.onProgress(normalized); | ||
| 223 | }), | ||
| 224 | Effect.flatMap(() => { | ||
| 225 | if (terminalEvent?.kind === "action_finished") { | ||
| 226 | return Effect.succeed(terminalEvent.result); | ||
| 227 | } | ||
| 228 | if (terminalEvent?.kind === "action_failed") { | ||
| 229 | return Effect.fail( | ||
| 230 | new VcsActionExecutionError({ | ||
| 231 | message: terminalEvent.message, | ||
| 232 | }), | ||
| 233 | ); | ||
| 234 | } | ||
| 235 | return Effect.fail( | ||
| 236 | new VcsActionExecutionError({ | ||
| 237 | message: "Source control action ended without a result.", | ||
| 238 | }), | ||
| 239 | ); | ||
| 240 | }), | ||
| 241 | ); | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | |||
| 245 | export function applyVcsActionProgressEvent( | ||
| 246 | current: VcsActionState, | ||
| 247 | event: GitActionProgressEvent, | ||
| 248 | ): VcsActionState { | ||
| 249 | if (current.actionId !== event.actionId) { | ||
| 250 | return current; | ||
| 251 | } | ||
| 252 | const now = nowMs(); | ||
| 253 | |||
| 254 | switch (event.kind) { | ||
| 255 | case "action_started": | ||
| 256 | return { | ||
| 257 | ...current, | ||
| 258 | isRunning: true, | ||
| 259 | actionId: event.actionId, | ||
| 260 | action: event.action, | ||
| 261 | operation: "run_change_request", | ||
| 262 | phaseStartedAtMs: now, | ||
| 263 | hookStartedAtMs: null, | ||
| 264 | hookName: null, | ||
| 265 | lastOutputLine: null, | ||
| 266 | error: null, | ||
| 267 | }; | ||
| 268 | case "phase_started": | ||
| 269 | return { | ||
| 270 | ...current, | ||
| 271 | isRunning: true, | ||
| 272 | actionId: event.actionId, | ||
| 273 | action: event.action, | ||
| 274 | operation: "run_change_request", | ||
| 275 | currentLabel: event.label, | ||
| 276 | currentPhaseLabel: event.label, | ||
| 277 | phaseStartedAtMs: now, | ||
| 278 | hookStartedAtMs: null, | ||
| 279 | hookName: null, | ||
| 280 | lastOutputLine: null, | ||
| 281 | error: null, | ||
| 282 | }; | ||
| 283 | case "hook_started": | ||
| 284 | return { | ||
| 285 | ...current, | ||
| 286 | isRunning: true, | ||
| 287 | actionId: event.actionId, | ||
| 288 | action: event.action, | ||
| 289 | operation: "run_change_request", | ||
| 290 | currentLabel: `Running ${event.hookName}...`, | ||
| 291 | hookName: event.hookName, | ||
| 292 | hookStartedAtMs: now, | ||
| 293 | lastOutputLine: null, | ||
| 294 | error: null, | ||
| 295 | }; | ||
| 296 | case "hook_output": | ||
| 297 | return { | ||
| 298 | ...current, | ||
| 299 | isRunning: true, | ||
| 300 | actionId: event.actionId, | ||
| 301 | action: event.action, | ||
| 302 | operation: "run_change_request", | ||
| 303 | lastOutputLine: event.text, | ||
| 304 | error: null, | ||
| 305 | }; | ||
| 306 | case "hook_finished": | ||
| 307 | return { | ||
| 308 | ...current, | ||
| 309 | isRunning: true, | ||
| 310 | actionId: event.actionId, | ||
| 311 | action: event.action, | ||
| 312 | operation: "run_change_request", | ||
| 313 | currentLabel: current.currentPhaseLabel, | ||
| 314 | hookName: null, | ||
| 315 | hookStartedAtMs: null, | ||
| 316 | lastOutputLine: null, | ||
| 317 | error: null, | ||
| 318 | }; | ||
| 319 | case "action_finished": | ||
| 320 | return { | ||
| 321 | ...current, | ||
| 322 | isRunning: false, | ||
| 323 | actionId: event.actionId, | ||
| 324 | action: event.action, | ||
| 325 | operation: "run_change_request", | ||
| 326 | error: null, | ||
| 327 | }; | ||
| 328 | case "action_failed": | ||
| 329 | return { | ||
| 330 | ...EMPTY_VCS_ACTION_STATE, | ||
| 331 | actionId: event.actionId, | ||
| 332 | action: event.action, | ||
| 333 | operation: "run_change_request", | ||
| 334 | error: event.message, | ||
| 335 | }; | ||
| 336 | } | ||
| 337 | } | ||
| 338 | |||
| 339 | export function createVcsActionManager<R, E>( | ||
| 340 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 341 | ) { | ||
| 342 | const unavailableTargetKey = "vcs-action-target:unavailable"; | ||
| 343 | const runStackedActionCommands = new Map< | ||
| 344 | string, | ||
| 345 | AtomCommand<RunVcsStackedActionInput, GitRunStackedActionResult, unknown> | ||
| 346 | >(); | ||
| 347 | const getRunStackedActionCommand = (key: string) => { | ||
| 348 | const existing = runStackedActionCommands.get(key); | ||
| 349 | if (existing !== undefined) { | ||
| 350 | return existing; | ||
| 351 | } | ||
| 352 | const target = key === unavailableTargetKey ? null : parseVcsActionTargetKey(key); | ||
| 353 | const stateAtom = target === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key); | ||
| 354 | const command = createRuntimeCommand< | ||
| 355 | EnvironmentRegistry | R, | ||
| 356 | E, | ||
| 357 | RunVcsStackedActionInput, | ||
| 358 | GitRunStackedActionResult, | ||
| 359 | unknown | ||
| 360 | >(runtime, { | ||
| 361 | label: `vcs-action:run-stacked:${key}`, | ||
| 362 | scheduler: vcsCommandScheduler, | ||
| 363 | concurrency: { mode: "serial", key: () => key }, | ||
| 364 | execute: (input: RunVcsStackedActionInput, registry) => { | ||
| 365 | if (target === null) { | ||
| 366 | return Effect.fail( | ||
| 367 | new VcsActionUnavailableError({ | ||
| 368 | message: "Source control action is unavailable.", | ||
| 369 | }), | ||
| 370 | ); | ||
| 371 | } | ||
| 372 | const transportActionId = createVcsActionTransportId(target, input.actionId); | ||
| 373 | registry.set( | ||
| 374 | stateAtom, | ||
| 375 | beginVcsActionState({ | ||
| 376 | operation: "run_change_request", | ||
| 377 | label: "Running source control action", | ||
| 378 | actionId: input.actionId, | ||
| 379 | }), | ||
| 380 | ); | ||
| 381 | |||
| 382 | const rpcInput: GitRunStackedActionInput = { | ||
| 383 | actionId: transportActionId, | ||
| 384 | cwd: target.cwd, | ||
| 385 | action: input.action, | ||
| 386 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | ||
| 387 | ...(input.featureBranch ? { featureBranch: true } : {}), | ||
| 388 | ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), | ||
| 389 | }; | ||
| 390 | return consumeVcsActionProgress( | ||
| 391 | runStreamInEnvironment( | ||
| 392 | target.environmentId, | ||
| 393 | runStream(WS_METHODS.gitRunStackedAction, rpcInput), | ||
| 394 | ), | ||
| 395 | { | ||
| 396 | target, | ||
| 397 | transportActionId, | ||
| 398 | actionId: input.actionId, | ||
| 399 | onProgress: (event) => | ||
| 400 | Effect.sync(() => { | ||
| 401 | const current = registry.get(stateAtom); | ||
| 402 | if (current.actionId !== input.actionId) { | ||
| 403 | return; | ||
| 404 | } | ||
| 405 | registry.set(stateAtom, applyVcsActionProgressEvent(current, event)); | ||
| 406 | if (input.onProgress !== undefined) { | ||
| 407 | try { | ||
| 408 | input.onProgress(event); | ||
| 409 | } catch { | ||
| 410 | // Presentation callbacks must not fail the source-control operation. | ||
| 411 | } | ||
| 412 | } | ||
| 413 | }), | ||
| 414 | }, | ||
| 415 | ).pipe( | ||
| 416 | Effect.tapError((error) => | ||
| 417 | Effect.sync(() => { | ||
| 418 | const current = registry.get(stateAtom); | ||
| 419 | if (current.actionId === input.actionId && current.isRunning) { | ||
| 420 | registry.set( | ||
| 421 | stateAtom, | ||
| 422 | failVcsActionState("run_change_request", input.actionId, error), | ||
| 423 | ); | ||
| 424 | } | ||
| 425 | }), | ||
| 426 | ), | ||
| 427 | ); | ||
| 428 | }, | ||
| 429 | }); | ||
| 430 | runStackedActionCommands.set(key, command); | ||
| 431 | return command; | ||
| 432 | }; | ||
| 433 | |||
| 434 | const setState = ( | ||
| 435 | registry: AtomRegistry.AtomRegistry, | ||
| 436 | target: VcsActionTarget, | ||
| 437 | update: (current: VcsActionState) => VcsActionState, | ||
| 438 | ): void => { | ||
| 439 | const key = getVcsActionTargetKey(target); | ||
| 440 | if (key === null) { | ||
| 441 | return; | ||
| 442 | } | ||
| 443 | const stateAtom = vcsActionStateAtom(key); | ||
| 444 | registry.set(stateAtom, update(registry.get(stateAtom))); | ||
| 445 | }; | ||
| 446 | |||
| 447 | return { | ||
| 448 | stateAtom: getVcsActionStateAtom, | ||
| 449 | runStackedAction: (target: VcsActionTarget) => { | ||
| 450 | const key = getVcsActionTargetKey(target); | ||
| 451 | return getRunStackedActionCommand(key ?? unavailableTargetKey); | ||
| 452 | }, | ||
| 453 | track: async <A, E>( | ||
| 454 | registry: AtomRegistry.AtomRegistry, | ||
| 455 | target: VcsActionTarget, | ||
| 456 | input: BeginVcsActionInput, | ||
| 457 | action: () => Promise<AtomCommandResult<A, E>>, | ||
| 458 | ): Promise<AtomCommandResult<A, E | VcsActionUnavailableError>> => { | ||
| 459 | const key = getVcsActionTargetKey(target); | ||
| 460 | if (key === null) { | ||
| 461 | return AsyncResult.failure<never, VcsActionUnavailableError>( | ||
| 462 | Cause.fail( | ||
| 463 | new VcsActionUnavailableError({ | ||
| 464 | message: "Source control action is unavailable.", | ||
| 465 | }), | ||
| 466 | ), | ||
| 467 | ); | ||
| 468 | } | ||
| 469 | const stateAtom = vcsActionStateAtom(key); | ||
| 470 | const next = beginVcsActionState(input); | ||
| 471 | registry.set(stateAtom, next); | ||
| 472 | const result = await action(); | ||
| 473 | const current = registry.get(stateAtom); | ||
| 474 | if (current.actionId !== next.actionId) { | ||
| 475 | return result; | ||
| 476 | } | ||
| 477 | if (AsyncResult.isSuccess(result) || Cause.hasInterruptsOnly(result.cause)) { | ||
| 478 | registry.set(stateAtom, EMPTY_VCS_ACTION_STATE); | ||
| 479 | } else { | ||
| 480 | if (registry.get(stateAtom).actionId === next.actionId) { | ||
| 481 | registry.set( | ||
| 482 | stateAtom, | ||
| 483 | failVcsActionState(input.operation, next.actionId, Cause.squash(result.cause)), | ||
| 484 | ); | ||
| 485 | } | ||
| 486 | } | ||
| 487 | return result; | ||
| 488 | }, | ||
| 489 | resetError: ( | ||
| 490 | registry: AtomRegistry.AtomRegistry, | ||
| 491 | target: VcsActionTarget, | ||
| 492 | operation: VcsActionOperation, | ||
| 493 | ): void => { | ||
| 494 | setState(registry, target, (current) => | ||
| 495 | !current.isRunning && current.operation === operation ? EMPTY_VCS_ACTION_STATE : current, | ||
| 496 | ); | ||
| 497 | }, | ||
| 498 | }; | ||
| 499 | } | ||
packages/client-runtime/src/state/vcsCommandScheduler.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { createAtomCommandScheduler, type AtomCommandConcurrency } from "./runtime.ts"; | ||
| 4 | |||
| 5 | export const vcsCommandScheduler = createAtomCommandScheduler(); | ||
| 6 | |||
| 7 | export const vcsCommandConcurrency: AtomCommandConcurrency<{ | ||
| 8 | readonly environmentId: EnvironmentId; | ||
| 9 | readonly input: { readonly cwd: string }; | ||
| 10 | }> = { | ||
| 11 | mode: "serial", | ||
| 12 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), | ||
| 13 | }; | ||
packages/client-runtime/src/state/vcsRef.ts created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | import type { EnvironmentId, VcsRef as ContractVcsRef } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface VcsRefTarget { | ||
| 4 | readonly environmentId: EnvironmentId | null; | ||
| 5 | readonly cwd: string | null; | ||
| 6 | readonly query?: string | null; | ||
| 7 | } | ||
| 8 | |||
| 9 | export type VcsRef = ContractVcsRef; | ||
packages/client-runtime/src/state/vcsStatus.ts created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface VcsStatusTarget { | ||
| 4 | readonly environmentId: EnvironmentId | null; | ||
| 5 | readonly cwd: string | null; | ||
| 6 | } | ||
packages/client-runtime/src/terminalSessionState.test.ts deleted-558| ... | @@ -1,558 +0,0 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import { afterEach, describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | EnvironmentId, | ||
| 6 | TerminalAttachStreamEvent, | ||
| 7 | TerminalMetadataStreamEvent, | ||
| 8 | TerminalSessionSnapshot, | ||
| 9 | ThreadId, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | createTerminalSessionManager, | ||
| 14 | getKnownTerminalSessionListFilter, | ||
| 15 | knownTerminalSessionsAtom, | ||
| 16 | runningTerminalIdsAtom, | ||
| 17 | terminalSessionStateAtom, | ||
| 18 | type KnownTerminalSessionTarget, | ||
| 19 | } from "./terminalSessionState.ts"; | ||
| 20 | |||
| 21 | let atomRegistry = AtomRegistry.make(); | ||
| 22 | |||
| 23 | function resetAtomRegistry() { | ||
| 24 | atomRegistry.dispose(); | ||
| 25 | atomRegistry = AtomRegistry.make(); | ||
| 26 | } | ||
| 27 | |||
| 28 | const TARGET = { | ||
| 29 | environmentId: EnvironmentId.make("env-local"), | ||
| 30 | threadId: ThreadId.make("thread-1"), | ||
| 31 | terminalId: "term-1", | ||
| 32 | } as const; | ||
| 33 | |||
| 34 | const BASE_SNAPSHOT: TerminalSessionSnapshot = { | ||
| 35 | threadId: TARGET.threadId, | ||
| 36 | terminalId: TARGET.terminalId, | ||
| 37 | cwd: "/repo", | ||
| 38 | worktreePath: null, | ||
| 39 | status: "running", | ||
| 40 | pid: 123, | ||
| 41 | history: "hello", | ||
| 42 | exitCode: null, | ||
| 43 | exitSignal: null, | ||
| 44 | label: "Terminal 1", | ||
| 45 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 46 | }; | ||
| 47 | |||
| 48 | type TerminalSessionManager = ReturnType<typeof createTerminalSessionManager>; | ||
| 49 | |||
| 50 | function applyAttachEvents( | ||
| 51 | manager: TerminalSessionManager, | ||
| 52 | target: KnownTerminalSessionTarget, | ||
| 53 | events: ReadonlyArray<TerminalAttachStreamEvent>, | ||
| 54 | ): void { | ||
| 55 | manager.attach({ | ||
| 56 | environmentId: target.environmentId, | ||
| 57 | terminal: { | ||
| 58 | threadId: target.threadId, | ||
| 59 | terminalId: target.terminalId, | ||
| 60 | }, | ||
| 61 | client: { | ||
| 62 | terminal: { | ||
| 63 | attach: (_input, listener) => { | ||
| 64 | events.forEach(listener); | ||
| 65 | return () => undefined; | ||
| 66 | }, | ||
| 67 | }, | ||
| 68 | }, | ||
| 69 | })(); | ||
| 70 | } | ||
| 71 | |||
| 72 | function applyMetadataEvents( | ||
| 73 | manager: TerminalSessionManager, | ||
| 74 | environmentId: EnvironmentId, | ||
| 75 | events: ReadonlyArray<TerminalMetadataStreamEvent>, | ||
| 76 | ): void { | ||
| 77 | manager.subscribeMetadata({ | ||
| 78 | environmentId, | ||
| 79 | client: { | ||
| 80 | terminal: { | ||
| 81 | onMetadata: (listener) => { | ||
| 82 | events.forEach(listener); | ||
| 83 | return () => undefined; | ||
| 84 | }, | ||
| 85 | }, | ||
| 86 | }, | ||
| 87 | })(); | ||
| 88 | } | ||
| 89 | |||
| 90 | describe("createTerminalSessionManager", () => { | ||
| 91 | afterEach(() => { | ||
| 92 | resetAtomRegistry(); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("hydrates from started snapshots and appends output events", () => { | ||
| 96 | const manager = createTerminalSessionManager({ | ||
| 97 | getRegistry: () => atomRegistry, | ||
| 98 | }); | ||
| 99 | |||
| 100 | applyAttachEvents(manager, TARGET, [ | ||
| 101 | { | ||
| 102 | type: "snapshot", | ||
| 103 | snapshot: BASE_SNAPSHOT, | ||
| 104 | }, | ||
| 105 | { | ||
| 106 | type: "output", | ||
| 107 | threadId: TARGET.threadId, | ||
| 108 | terminalId: TARGET.terminalId, | ||
| 109 | data: " world", | ||
| 110 | }, | ||
| 111 | ]); | ||
| 112 | |||
| 113 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 114 | summary: null, | ||
| 115 | buffer: "hello world", | ||
| 116 | status: "running", | ||
| 117 | error: null, | ||
| 118 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("caps retained output", () => { | ||
| 123 | const manager = createTerminalSessionManager({ | ||
| 124 | getRegistry: () => atomRegistry, | ||
| 125 | maxBufferBytes: 5, | ||
| 126 | }); | ||
| 127 | |||
| 128 | applyAttachEvents(manager, TARGET, [ | ||
| 129 | { | ||
| 130 | type: "output", | ||
| 131 | threadId: TARGET.threadId, | ||
| 132 | terminalId: TARGET.terminalId, | ||
| 133 | data: "abcdef", | ||
| 134 | }, | ||
| 135 | ]); | ||
| 136 | |||
| 137 | expect(manager.getSnapshot(TARGET).buffer).toBe("bcdef"); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("caps retained output by utf-8 byte length", () => { | ||
| 141 | const manager = createTerminalSessionManager({ | ||
| 142 | getRegistry: () => atomRegistry, | ||
| 143 | maxBufferBytes: 4, | ||
| 144 | }); | ||
| 145 | |||
| 146 | applyAttachEvents(manager, TARGET, [ | ||
| 147 | { | ||
| 148 | type: "output", | ||
| 149 | threadId: TARGET.threadId, | ||
| 150 | terminalId: TARGET.terminalId, | ||
| 151 | data: "🙂🙂", | ||
| 152 | }, | ||
| 153 | ]); | ||
| 154 | |||
| 155 | expect(manager.getSnapshot(TARGET).buffer).toBe("🙂"); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it("invalidates one environment without clearing others", () => { | ||
| 159 | const manager = createTerminalSessionManager({ | ||
| 160 | getRegistry: () => atomRegistry, | ||
| 161 | }); | ||
| 162 | const otherTarget = { | ||
| 163 | environmentId: EnvironmentId.make("env-remote"), | ||
| 164 | threadId: ThreadId.make("thread-1"), | ||
| 165 | terminalId: "term-1", | ||
| 166 | } as const; | ||
| 167 | |||
| 168 | for (const target of [TARGET, otherTarget]) { | ||
| 169 | applyAttachEvents(manager, target, [ | ||
| 170 | { | ||
| 171 | type: "output", | ||
| 172 | threadId: target.threadId, | ||
| 173 | terminalId: target.terminalId, | ||
| 174 | data: target.environmentId, | ||
| 175 | }, | ||
| 176 | ]); | ||
| 177 | } | ||
| 178 | |||
| 179 | manager.invalidateEnvironment(TARGET.environmentId); | ||
| 180 | |||
| 181 | expect(manager.getSnapshot(TARGET).buffer).toBe(""); | ||
| 182 | expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote"); | ||
| 183 | }); | ||
| 184 | |||
| 185 | it("lists known sessions for a thread ordered by terminal id (numeric-aware)", () => { | ||
| 186 | const manager = createTerminalSessionManager({ | ||
| 187 | getRegistry: () => atomRegistry, | ||
| 188 | }); | ||
| 189 | |||
| 190 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 191 | { | ||
| 192 | type: "snapshot", | ||
| 193 | terminals: [ | ||
| 194 | { | ||
| 195 | threadId: TARGET.threadId, | ||
| 196 | terminalId: "term-10", | ||
| 197 | cwd: "/repo", | ||
| 198 | worktreePath: null, | ||
| 199 | status: "running", | ||
| 200 | pid: 125, | ||
| 201 | exitCode: null, | ||
| 202 | exitSignal: null, | ||
| 203 | updatedAt: "2026-04-01T00:00:05.000Z", | ||
| 204 | hasRunningSubprocess: false, | ||
| 205 | label: "Terminal 10", | ||
| 206 | }, | ||
| 207 | { | ||
| 208 | threadId: TARGET.threadId, | ||
| 209 | terminalId: TARGET.terminalId, | ||
| 210 | cwd: "/repo", | ||
| 211 | worktreePath: null, | ||
| 212 | status: "running", | ||
| 213 | pid: 123, | ||
| 214 | exitCode: null, | ||
| 215 | exitSignal: null, | ||
| 216 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 217 | hasRunningSubprocess: false, | ||
| 218 | label: "Terminal 1", | ||
| 219 | }, | ||
| 220 | { | ||
| 221 | threadId: TARGET.threadId, | ||
| 222 | terminalId: "term-2", | ||
| 223 | cwd: "/repo", | ||
| 224 | worktreePath: null, | ||
| 225 | status: "running", | ||
| 226 | pid: 124, | ||
| 227 | exitCode: null, | ||
| 228 | exitSignal: null, | ||
| 229 | updatedAt: "2026-04-01T00:00:02.000Z", | ||
| 230 | hasRunningSubprocess: false, | ||
| 231 | label: "Terminal 2", | ||
| 232 | }, | ||
| 233 | ], | ||
| 234 | }, | ||
| 235 | ]); | ||
| 236 | |||
| 237 | expect( | ||
| 238 | manager | ||
| 239 | .listSessions({ | ||
| 240 | environmentId: TARGET.environmentId, | ||
| 241 | threadId: TARGET.threadId, | ||
| 242 | }) | ||
| 243 | .map((session) => session.target.terminalId), | ||
| 244 | ).toEqual(["term-1", "term-2", "term-10"]); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it("drops known sessions when an environment is invalidated", () => { | ||
| 248 | const manager = createTerminalSessionManager({ | ||
| 249 | getRegistry: () => atomRegistry, | ||
| 250 | }); | ||
| 251 | |||
| 252 | applyAttachEvents(manager, TARGET, [ | ||
| 253 | { | ||
| 254 | type: "output", | ||
| 255 | threadId: TARGET.threadId, | ||
| 256 | terminalId: TARGET.terminalId, | ||
| 257 | data: "hello", | ||
| 258 | }, | ||
| 259 | ]); | ||
| 260 | |||
| 261 | manager.invalidateEnvironment(TARGET.environmentId); | ||
| 262 | |||
| 263 | expect( | ||
| 264 | manager.listSessions({ | ||
| 265 | environmentId: TARGET.environmentId, | ||
| 266 | threadId: TARGET.threadId, | ||
| 267 | }), | ||
| 268 | ).toEqual([]); | ||
| 269 | }); | ||
| 270 | |||
| 271 | it("removes closed sessions from the known-session index while keeping local closed state", () => { | ||
| 272 | const manager = createTerminalSessionManager({ | ||
| 273 | getRegistry: () => atomRegistry, | ||
| 274 | }); | ||
| 275 | |||
| 276 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 277 | { | ||
| 278 | type: "upsert", | ||
| 279 | terminal: { | ||
| 280 | threadId: TARGET.threadId, | ||
| 281 | terminalId: TARGET.terminalId, | ||
| 282 | cwd: "/repo", | ||
| 283 | worktreePath: null, | ||
| 284 | status: "running", | ||
| 285 | pid: 123, | ||
| 286 | exitCode: null, | ||
| 287 | exitSignal: null, | ||
| 288 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 289 | hasRunningSubprocess: false, | ||
| 290 | label: "Terminal 1", | ||
| 291 | }, | ||
| 292 | }, | ||
| 293 | ]); | ||
| 294 | applyAttachEvents(manager, TARGET, [ | ||
| 295 | { | ||
| 296 | type: "snapshot", | ||
| 297 | snapshot: BASE_SNAPSHOT, | ||
| 298 | }, | ||
| 299 | { | ||
| 300 | type: "closed", | ||
| 301 | threadId: TARGET.threadId, | ||
| 302 | terminalId: TARGET.terminalId, | ||
| 303 | }, | ||
| 304 | ]); | ||
| 305 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 306 | { | ||
| 307 | type: "remove", | ||
| 308 | threadId: TARGET.threadId, | ||
| 309 | terminalId: TARGET.terminalId, | ||
| 310 | }, | ||
| 311 | ]); | ||
| 312 | |||
| 313 | expect( | ||
| 314 | manager.listSessions({ | ||
| 315 | environmentId: TARGET.environmentId, | ||
| 316 | threadId: TARGET.threadId, | ||
| 317 | }), | ||
| 318 | ).toEqual([]); | ||
| 319 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 320 | buffer: "hello", | ||
| 321 | status: "closed", | ||
| 322 | summary: null, | ||
| 323 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 324 | }); | ||
| 325 | }); | ||
| 326 | |||
| 327 | it("clears locally retained closed state on reset", () => { | ||
| 328 | const manager = createTerminalSessionManager({ | ||
| 329 | getRegistry: () => atomRegistry, | ||
| 330 | }); | ||
| 331 | |||
| 332 | applyAttachEvents(manager, TARGET, [ | ||
| 333 | { | ||
| 334 | type: "snapshot", | ||
| 335 | snapshot: BASE_SNAPSHOT, | ||
| 336 | }, | ||
| 337 | { | ||
| 338 | type: "closed", | ||
| 339 | threadId: TARGET.threadId, | ||
| 340 | terminalId: TARGET.terminalId, | ||
| 341 | }, | ||
| 342 | ]); | ||
| 343 | |||
| 344 | manager.reset(); | ||
| 345 | |||
| 346 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 347 | summary: null, | ||
| 348 | buffer: "", | ||
| 349 | status: "closed", | ||
| 350 | error: null, | ||
| 351 | hasRunningSubprocess: false, | ||
| 352 | updatedAt: null, | ||
| 353 | version: 0, | ||
| 354 | }); | ||
| 355 | }); | ||
| 356 | |||
| 357 | it("syncs snapshots returned from open calls immediately", () => { | ||
| 358 | const manager = createTerminalSessionManager({ | ||
| 359 | getRegistry: () => atomRegistry, | ||
| 360 | }); | ||
| 361 | |||
| 362 | applyAttachEvents(manager, TARGET, [ | ||
| 363 | { | ||
| 364 | type: "snapshot", | ||
| 365 | snapshot: { | ||
| 366 | ...BASE_SNAPSHOT, | ||
| 367 | history: "prompt$ ", | ||
| 368 | updatedAt: "2026-04-01T00:00:03.000Z", | ||
| 369 | }, | ||
| 370 | }, | ||
| 371 | ]); | ||
| 372 | |||
| 373 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 374 | buffer: "prompt$ ", | ||
| 375 | status: "running", | ||
| 376 | updatedAt: "2026-04-01T00:00:03.000Z", | ||
| 377 | }); | ||
| 378 | }); | ||
| 379 | |||
| 380 | it("syncs authoritative metadata snapshots and removes missing environment terminals", () => { | ||
| 381 | const manager = createTerminalSessionManager({ | ||
| 382 | getRegistry: () => atomRegistry, | ||
| 383 | }); | ||
| 384 | |||
| 385 | applyAttachEvents(manager, TARGET, [ | ||
| 386 | { | ||
| 387 | type: "snapshot", | ||
| 388 | snapshot: BASE_SNAPSHOT, | ||
| 389 | }, | ||
| 390 | ]); | ||
| 391 | applyAttachEvents( | ||
| 392 | manager, | ||
| 393 | { | ||
| 394 | environmentId: TARGET.environmentId, | ||
| 395 | threadId: TARGET.threadId, | ||
| 396 | terminalId: "term-2", | ||
| 397 | }, | ||
| 398 | [ | ||
| 399 | { | ||
| 400 | type: "snapshot", | ||
| 401 | snapshot: { | ||
| 402 | ...BASE_SNAPSHOT, | ||
| 403 | terminalId: "term-2", | ||
| 404 | label: "Terminal 2", | ||
| 405 | updatedAt: "2026-04-01T00:00:02.000Z", | ||
| 406 | }, | ||
| 407 | }, | ||
| 408 | ], | ||
| 409 | ); | ||
| 410 | |||
| 411 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 412 | { | ||
| 413 | type: "snapshot", | ||
| 414 | terminals: [ | ||
| 415 | { | ||
| 416 | threadId: TARGET.threadId, | ||
| 417 | terminalId: "term-2", | ||
| 418 | cwd: "/repo", | ||
| 419 | worktreePath: null, | ||
| 420 | status: "running", | ||
| 421 | pid: 123, | ||
| 422 | exitCode: null, | ||
| 423 | exitSignal: null, | ||
| 424 | updatedAt: "2026-04-01T00:00:05.000Z", | ||
| 425 | hasRunningSubprocess: true, | ||
| 426 | label: "Terminal 2", | ||
| 427 | }, | ||
| 428 | ], | ||
| 429 | }, | ||
| 430 | ]); | ||
| 431 | |||
| 432 | expect( | ||
| 433 | manager.listSessions({ | ||
| 434 | environmentId: TARGET.environmentId, | ||
| 435 | threadId: TARGET.threadId, | ||
| 436 | }), | ||
| 437 | ).toMatchObject([ | ||
| 438 | { | ||
| 439 | target: { | ||
| 440 | environmentId: TARGET.environmentId, | ||
| 441 | threadId: TARGET.threadId, | ||
| 442 | terminalId: "term-2", | ||
| 443 | }, | ||
| 444 | state: { | ||
| 445 | summary: { | ||
| 446 | terminalId: "term-2", | ||
| 447 | cwd: "/repo", | ||
| 448 | }, | ||
| 449 | hasRunningSubprocess: true, | ||
| 450 | }, | ||
| 451 | }, | ||
| 452 | ]); | ||
| 453 | }); | ||
| 454 | |||
| 455 | it("updates listed session metadata when existing session activity changes", () => { | ||
| 456 | const manager = createTerminalSessionManager({ | ||
| 457 | getRegistry: () => atomRegistry, | ||
| 458 | }); | ||
| 459 | |||
| 460 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 461 | { | ||
| 462 | type: "upsert", | ||
| 463 | terminal: { | ||
| 464 | threadId: TARGET.threadId, | ||
| 465 | terminalId: TARGET.terminalId, | ||
| 466 | cwd: "/repo", | ||
| 467 | worktreePath: null, | ||
| 468 | status: "running", | ||
| 469 | pid: 123, | ||
| 470 | exitCode: null, | ||
| 471 | exitSignal: null, | ||
| 472 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 473 | hasRunningSubprocess: false, | ||
| 474 | label: "Terminal 1", | ||
| 475 | }, | ||
| 476 | }, | ||
| 477 | ]); | ||
| 478 | |||
| 479 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 480 | { | ||
| 481 | type: "upsert", | ||
| 482 | terminal: { | ||
| 483 | threadId: TARGET.threadId, | ||
| 484 | terminalId: TARGET.terminalId, | ||
| 485 | cwd: "/repo", | ||
| 486 | worktreePath: null, | ||
| 487 | status: "running", | ||
| 488 | pid: 123, | ||
| 489 | exitCode: null, | ||
| 490 | exitSignal: null, | ||
| 491 | updatedAt: "2026-04-01T00:00:05.000Z", | ||
| 492 | hasRunningSubprocess: true, | ||
| 493 | label: "Terminal 1", | ||
| 494 | }, | ||
| 495 | }, | ||
| 496 | ]); | ||
| 497 | |||
| 498 | expect( | ||
| 499 | manager.listSessions({ environmentId: TARGET.environmentId, threadId: TARGET.threadId }), | ||
| 500 | ).toMatchObject([ | ||
| 501 | { | ||
| 502 | state: { | ||
| 503 | hasRunningSubprocess: true, | ||
| 504 | }, | ||
| 505 | }, | ||
| 506 | ]); | ||
| 507 | }); | ||
| 508 | |||
| 509 | it("derives session atoms from structurally equal target objects", () => { | ||
| 510 | const manager = createTerminalSessionManager({ | ||
| 511 | getRegistry: () => atomRegistry, | ||
| 512 | }); | ||
| 513 | |||
| 514 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 515 | { | ||
| 516 | type: "upsert", | ||
| 517 | terminal: { | ||
| 518 | threadId: TARGET.threadId, | ||
| 519 | terminalId: TARGET.terminalId, | ||
| 520 | cwd: "/repo", | ||
| 521 | worktreePath: null, | ||
| 522 | status: "running", | ||
| 523 | pid: 123, | ||
| 524 | exitCode: null, | ||
| 525 | exitSignal: null, | ||
| 526 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 527 | hasRunningSubprocess: true, | ||
| 528 | label: "Terminal 1", | ||
| 529 | }, | ||
| 530 | }, | ||
| 531 | ]); | ||
| 532 | applyAttachEvents(manager, TARGET, [ | ||
| 533 | { | ||
| 534 | type: "snapshot", | ||
| 535 | snapshot: BASE_SNAPSHOT, | ||
| 536 | }, | ||
| 537 | ]); | ||
| 538 | |||
| 539 | const equalTarget = { ...TARGET }; | ||
| 540 | const filter = getKnownTerminalSessionListFilter({ | ||
| 541 | environmentId: TARGET.environmentId, | ||
| 542 | threadId: TARGET.threadId, | ||
| 543 | }); | ||
| 544 | expect(filter).not.toBeNull(); | ||
| 545 | if (filter === null) { | ||
| 546 | return; | ||
| 547 | } | ||
| 548 | |||
| 549 | expect(atomRegistry.get(terminalSessionStateAtom(equalTarget))).toMatchObject({ | ||
| 550 | buffer: BASE_SNAPSHOT.history, | ||
| 551 | hasRunningSubprocess: true, | ||
| 552 | }); | ||
| 553 | expect( | ||
| 554 | atomRegistry.get(knownTerminalSessionsAtom({ ...filter })).map((session) => session.target), | ||
| 555 | ).toEqual([TARGET]); | ||
| 556 | expect(atomRegistry.get(runningTerminalIdsAtom({ ...filter }))).toEqual([TARGET.terminalId]); | ||
| 557 | }); | ||
| 558 | }); | ||
packages/client-runtime/src/terminalSessionState.ts deleted-605| ... | @@ -1,605 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | TerminalAttachStreamEvent, | ||
| 3 | TerminalMetadataStreamEvent, | ||
| 4 | TerminalSessionSnapshot, | ||
| 5 | TerminalSummary, | ||
| 6 | EnvironmentId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts"; | ||
| 9 | import * as Arr from "effect/Array"; | ||
| 10 | import { pipe } from "effect/Function"; | ||
| 11 | import * as Order from "effect/Order"; | ||
| 12 | import * as Result from "effect/Result"; | ||
| 13 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 14 | |||
| 15 | export interface TerminalSessionState { | ||
| 16 | readonly summary: TerminalSummary | null; | ||
| 17 | readonly buffer: string; | ||
| 18 | readonly status: TerminalSessionSnapshot["status"] | "closed"; | ||
| 19 | readonly error: string | null; | ||
| 20 | readonly hasRunningSubprocess: boolean; | ||
| 21 | readonly updatedAt: string | null; | ||
| 22 | readonly version: number; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface TerminalBufferState { | ||
| 26 | readonly buffer: string; | ||
| 27 | readonly status: TerminalSessionSnapshot["status"] | "closed"; | ||
| 28 | readonly error: string | null; | ||
| 29 | readonly updatedAt: string | null; | ||
| 30 | readonly version: number; | ||
| 31 | } | ||
| 32 | |||
| 33 | export interface TerminalSessionTarget { | ||
| 34 | readonly environmentId: EnvironmentId | null; | ||
| 35 | readonly threadId: ThreadId | null; | ||
| 36 | readonly terminalId: string | null; | ||
| 37 | } | ||
| 38 | |||
| 39 | export interface KnownTerminalSessionTarget { | ||
| 40 | readonly environmentId: EnvironmentId; | ||
| 41 | readonly threadId: ThreadId; | ||
| 42 | readonly terminalId: string; | ||
| 43 | } | ||
| 44 | |||
| 45 | export interface KnownTerminalSession { | ||
| 46 | readonly target: KnownTerminalSessionTarget; | ||
| 47 | readonly state: TerminalSessionState; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface KnownTerminalMetadata { | ||
| 51 | readonly target: KnownTerminalSessionTarget; | ||
| 52 | readonly summary: TerminalSummary; | ||
| 53 | } | ||
| 54 | |||
| 55 | export interface TerminalSessionListFilter { | ||
| 56 | readonly environmentId: EnvironmentId | null; | ||
| 57 | readonly threadId?: ThreadId | null; | ||
| 58 | readonly terminalId?: string | null; | ||
| 59 | } | ||
| 60 | |||
| 61 | export interface KnownTerminalSessionListFilter { | ||
| 62 | readonly environmentId: EnvironmentId; | ||
| 63 | readonly threadId: ThreadId | null; | ||
| 64 | readonly terminalId: string | null; | ||
| 65 | } | ||
| 66 | |||
| 67 | export interface TerminalSessionManagerConfig { | ||
| 68 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 69 | readonly maxBufferBytes?: number; | ||
| 70 | } | ||
| 71 | |||
| 72 | export interface TerminalMetadataClient { | ||
| 73 | readonly terminal: { | ||
| 74 | readonly onMetadata: ( | ||
| 75 | listener: (event: TerminalMetadataStreamEvent) => void, | ||
| 76 | options?: { readonly onResubscribe?: () => void }, | ||
| 77 | ) => () => void; | ||
| 78 | }; | ||
| 79 | } | ||
| 80 | |||
| 81 | export interface TerminalAttachClient { | ||
| 82 | readonly terminal: { | ||
| 83 | readonly attach: ( | ||
| 84 | input: TerminalAttachInput, | ||
| 85 | listener: (event: TerminalAttachStreamEvent) => void, | ||
| 86 | options?: { readonly onResubscribe?: () => void }, | ||
| 87 | ) => () => void; | ||
| 88 | }; | ||
| 89 | } | ||
| 90 | |||
| 91 | export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ | ||
| 92 | buffer: "", | ||
| 93 | status: "closed", | ||
| 94 | error: null, | ||
| 95 | updatedAt: null, | ||
| 96 | version: 0, | ||
| 97 | }); | ||
| 98 | |||
| 99 | export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({ | ||
| 100 | summary: null, | ||
| 101 | buffer: "", | ||
| 102 | status: "closed", | ||
| 103 | error: null, | ||
| 104 | hasRunningSubprocess: false, | ||
| 105 | updatedAt: null, | ||
| 106 | version: 0, | ||
| 107 | }); | ||
| 108 | |||
| 109 | const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<Array<KnownTerminalSession>>([]); | ||
| 110 | const EMPTY_TERMINAL_ID_LIST = Object.freeze<Array<string>>([]); | ||
| 111 | const DEFAULT_MAX_BUFFER_BYTES = 512 * 1024; | ||
| 112 | const knownTerminalMetadataEnvironmentIds = new Set<EnvironmentId>(); | ||
| 113 | const knownTerminalBufferTargets = new Map<string, KnownTerminalSessionTarget>(); | ||
| 114 | const textEncoder = new TextEncoder(); | ||
| 115 | const textDecoder = new TextDecoder(); | ||
| 116 | const terminalIdOrder = Order.make<string>( | ||
| 117 | (left, right) => left.localeCompare(right, undefined, { numeric: true }) as -1 | 0 | 1, | ||
| 118 | ); | ||
| 119 | const knownTerminalSessionOrder = Order.mapInput( | ||
| 120 | terminalIdOrder, | ||
| 121 | (session: KnownTerminalSession) => session.target.terminalId, | ||
| 122 | ); | ||
| 123 | |||
| 124 | export const terminalSessionMetadataAtom = Atom.family((environmentId: EnvironmentId) => { | ||
| 125 | knownTerminalMetadataEnvironmentIds.add(environmentId); | ||
| 126 | return Atom.make<Record<string, KnownTerminalMetadata>>({}).pipe( | ||
| 127 | Atom.keepAlive, | ||
| 128 | Atom.withLabel(`terminal-session:metadata:${environmentId}`), | ||
| 129 | ); | ||
| 130 | }); | ||
| 131 | |||
| 132 | export const terminalSessionBufferAtom = Atom.family((target: KnownTerminalSessionTarget) => { | ||
| 133 | const key = keyFromKnownTarget(target); | ||
| 134 | knownTerminalBufferTargets.set(key, target); | ||
| 135 | return Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( | ||
| 136 | Atom.keepAlive, | ||
| 137 | Atom.withLabel(`terminal-session:buffer:${key}`), | ||
| 138 | ); | ||
| 139 | }); | ||
| 140 | |||
| 141 | export const EMPTY_TERMINAL_BUFFER_ATOM = Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( | ||
| 142 | Atom.keepAlive, | ||
| 143 | Atom.withLabel("terminal-session:buffer:null"), | ||
| 144 | ); | ||
| 145 | |||
| 146 | export const EMPTY_TERMINAL_SESSION_ATOM = Atom.make(EMPTY_TERMINAL_SESSION_STATE).pipe( | ||
| 147 | Atom.keepAlive, | ||
| 148 | Atom.withLabel("terminal-session:state:null"), | ||
| 149 | ); | ||
| 150 | |||
| 151 | export const EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM = Atom.make(EMPTY_KNOWN_TERMINAL_SESSIONS).pipe( | ||
| 152 | Atom.keepAlive, | ||
| 153 | Atom.withLabel("terminal-session:known:null"), | ||
| 154 | ); | ||
| 155 | |||
| 156 | export const EMPTY_TERMINAL_ID_LIST_ATOM = Atom.make(EMPTY_TERMINAL_ID_LIST).pipe( | ||
| 157 | Atom.keepAlive, | ||
| 158 | Atom.withLabel("terminal-session:running-terminal-ids:null"), | ||
| 159 | ); | ||
| 160 | |||
| 161 | export function getKnownTerminalSessionTarget( | ||
| 162 | target: TerminalSessionTarget, | ||
| 163 | ): KnownTerminalSessionTarget | null { | ||
| 164 | if (target.environmentId === null || target.threadId === null || target.terminalId === null) { | ||
| 165 | return null; | ||
| 166 | } | ||
| 167 | |||
| 168 | return { | ||
| 169 | environmentId: target.environmentId, | ||
| 170 | threadId: target.threadId, | ||
| 171 | terminalId: target.terminalId, | ||
| 172 | }; | ||
| 173 | } | ||
| 174 | |||
| 175 | export function getKnownTerminalSessionListFilter( | ||
| 176 | filter: TerminalSessionListFilter, | ||
| 177 | ): KnownTerminalSessionListFilter | null { | ||
| 178 | if (filter.environmentId === null) { | ||
| 179 | return null; | ||
| 180 | } | ||
| 181 | |||
| 182 | return { | ||
| 183 | environmentId: filter.environmentId, | ||
| 184 | threadId: filter.threadId ?? null, | ||
| 185 | terminalId: filter.terminalId ?? null, | ||
| 186 | }; | ||
| 187 | } | ||
| 188 | |||
| 189 | function knownTargetFromSummary( | ||
| 190 | environmentId: EnvironmentId, | ||
| 191 | summary: TerminalSummary, | ||
| 192 | ): KnownTerminalSessionTarget { | ||
| 193 | return { | ||
| 194 | environmentId, | ||
| 195 | threadId: ThreadId.make(summary.threadId), | ||
| 196 | terminalId: summary.terminalId, | ||
| 197 | }; | ||
| 198 | } | ||
| 199 | |||
| 200 | function keyFromKnownTarget(target: KnownTerminalSessionTarget): string { | ||
| 201 | return `${target.environmentId}:${target.threadId}:${target.terminalId}`; | ||
| 202 | } | ||
| 203 | |||
| 204 | function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { | ||
| 205 | if (maxBufferBytes <= 0) { | ||
| 206 | return ""; | ||
| 207 | } | ||
| 208 | |||
| 209 | const encoded = textEncoder.encode(buffer); | ||
| 210 | if (encoded.byteLength <= maxBufferBytes) { | ||
| 211 | return buffer; | ||
| 212 | } | ||
| 213 | |||
| 214 | let start = encoded.byteLength - maxBufferBytes; | ||
| 215 | while (start < encoded.length) { | ||
| 216 | const byte = encoded[start]; | ||
| 217 | if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { | ||
| 218 | break; | ||
| 219 | } | ||
| 220 | start += 1; | ||
| 221 | } | ||
| 222 | |||
| 223 | return textDecoder.decode(encoded.subarray(start)); | ||
| 224 | } | ||
| 225 | |||
| 226 | function bufferFromSnapshot( | ||
| 227 | snapshot: TerminalSessionSnapshot, | ||
| 228 | maxBufferBytes: number, | ||
| 229 | ): TerminalBufferState { | ||
| 230 | return { | ||
| 231 | buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), | ||
| 232 | status: snapshot.status, | ||
| 233 | error: null, | ||
| 234 | updatedAt: snapshot.updatedAt, | ||
| 235 | version: 1, | ||
| 236 | }; | ||
| 237 | } | ||
| 238 | |||
| 239 | function latestTimestamp(left: string | null, right: string | null): string | null { | ||
| 240 | if (left === null) return right; | ||
| 241 | if (right === null) return left; | ||
| 242 | return Date.parse(left) >= Date.parse(right) ? left : right; | ||
| 243 | } | ||
| 244 | |||
| 245 | function combineSessionState( | ||
| 246 | summary: TerminalSummary | null, | ||
| 247 | buffer: TerminalBufferState, | ||
| 248 | ): TerminalSessionState { | ||
| 249 | return { | ||
| 250 | summary, | ||
| 251 | buffer: buffer.buffer, | ||
| 252 | status: summary?.status ?? buffer.status, | ||
| 253 | error: buffer.error, | ||
| 254 | hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, | ||
| 255 | updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), | ||
| 256 | version: buffer.version, | ||
| 257 | }; | ||
| 258 | } | ||
| 259 | |||
| 260 | function listKnownSessionsFromMetadata( | ||
| 261 | metadata: Record<string, KnownTerminalMetadata>, | ||
| 262 | getBuffer: (target: KnownTerminalSessionTarget) => TerminalBufferState, | ||
| 263 | filter?: Partial<KnownTerminalSessionTarget>, | ||
| 264 | ): ReadonlyArray<KnownTerminalSession> { | ||
| 265 | return pipe( | ||
| 266 | Object.values(metadata), | ||
| 267 | Arr.filterMap(({ target, summary }) => { | ||
| 268 | if (filter?.environmentId && target.environmentId !== filter.environmentId) { | ||
| 269 | return Result.failVoid; | ||
| 270 | } | ||
| 271 | if (filter?.threadId && target.threadId !== filter.threadId) { | ||
| 272 | return Result.failVoid; | ||
| 273 | } | ||
| 274 | if (filter?.terminalId && target.terminalId !== filter.terminalId) { | ||
| 275 | return Result.failVoid; | ||
| 276 | } | ||
| 277 | return Result.succeed({ | ||
| 278 | target, | ||
| 279 | state: combineSessionState(summary, getBuffer(target)), | ||
| 280 | }); | ||
| 281 | }), | ||
| 282 | Arr.sort(knownTerminalSessionOrder), | ||
| 283 | ); | ||
| 284 | } | ||
| 285 | |||
| 286 | export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) => | ||
| 287 | Atom.make((get) => { | ||
| 288 | const targetKey = keyFromKnownTarget(target); | ||
| 289 | return combineSessionState( | ||
| 290 | get(terminalSessionMetadataAtom(target.environmentId))[targetKey]?.summary ?? null, | ||
| 291 | get(terminalSessionBufferAtom(target)), | ||
| 292 | ); | ||
| 293 | }).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:state:${keyFromKnownTarget(target)}`)), | ||
| 294 | ); | ||
| 295 | |||
| 296 | export const knownTerminalSessionsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => | ||
| 297 | Atom.make((get) => | ||
| 298 | listKnownSessionsFromMetadata( | ||
| 299 | get(terminalSessionMetadataAtom(filter.environmentId)), | ||
| 300 | (target) => get(terminalSessionBufferAtom(target)), | ||
| 301 | { | ||
| 302 | environmentId: filter.environmentId, | ||
| 303 | ...(filter.threadId !== null ? { threadId: filter.threadId } : {}), | ||
| 304 | ...(filter.terminalId !== null ? { terminalId: filter.terminalId } : {}), | ||
| 305 | }, | ||
| 306 | ), | ||
| 307 | ).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:known:${JSON.stringify(filter)}`)), | ||
| 308 | ); | ||
| 309 | |||
| 310 | export const runningTerminalIdsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => | ||
| 311 | Atom.make((get) => { | ||
| 312 | return pipe( | ||
| 313 | Object.values(get(terminalSessionMetadataAtom(filter.environmentId))), | ||
| 314 | Arr.filterMap((entry) => | ||
| 315 | entry.target.environmentId === filter.environmentId && | ||
| 316 | (filter.threadId === null || entry.target.threadId === filter.threadId) && | ||
| 317 | (filter.terminalId === null || entry.target.terminalId === filter.terminalId) && | ||
| 318 | entry.summary.hasRunningSubprocess | ||
| 319 | ? Result.succeed(entry.target.terminalId) | ||
| 320 | : Result.failVoid, | ||
| 321 | ), | ||
| 322 | Arr.sort(Order.String), | ||
| 323 | ); | ||
| 324 | }).pipe( | ||
| 325 | Atom.keepAlive, | ||
| 326 | Atom.withLabel(`terminal-session:running-terminal-ids:${JSON.stringify(filter)}`), | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | |||
| 330 | export function createTerminalSessionManager(config: TerminalSessionManagerConfig) { | ||
| 331 | const maxBufferBytes = config.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; | ||
| 332 | |||
| 333 | function getMetadata(environmentId: EnvironmentId): Record<string, KnownTerminalMetadata> { | ||
| 334 | return config.getRegistry().get(terminalSessionMetadataAtom(environmentId)); | ||
| 335 | } | ||
| 336 | |||
| 337 | function setMetadata( | ||
| 338 | environmentId: EnvironmentId, | ||
| 339 | next: Record<string, KnownTerminalMetadata>, | ||
| 340 | ): void { | ||
| 341 | config.getRegistry().set(terminalSessionMetadataAtom(environmentId), next); | ||
| 342 | } | ||
| 343 | |||
| 344 | function getBuffer(target: KnownTerminalSessionTarget): TerminalBufferState { | ||
| 345 | return config.getRegistry().get(terminalSessionBufferAtom(target)); | ||
| 346 | } | ||
| 347 | |||
| 348 | function setBuffer(target: KnownTerminalSessionTarget, next: TerminalBufferState): void { | ||
| 349 | config.getRegistry().set(terminalSessionBufferAtom(target), next); | ||
| 350 | } | ||
| 351 | |||
| 352 | function getSnapshot(target: TerminalSessionTarget): TerminalSessionState { | ||
| 353 | const knownTarget = getKnownTerminalSessionTarget(target); | ||
| 354 | if (knownTarget === null) { | ||
| 355 | return EMPTY_TERMINAL_SESSION_STATE; | ||
| 356 | } | ||
| 357 | |||
| 358 | return combineSessionState( | ||
| 359 | getMetadata(knownTarget.environmentId)[keyFromKnownTarget(knownTarget)]?.summary ?? null, | ||
| 360 | getBuffer(knownTarget), | ||
| 361 | ); | ||
| 362 | } | ||
| 363 | |||
| 364 | function syncSnapshot( | ||
| 365 | target: Pick<TerminalSessionTarget, "environmentId">, | ||
| 366 | snapshot: TerminalSessionSnapshot, | ||
| 367 | ): void { | ||
| 368 | const knownTarget = getKnownTerminalSessionTarget({ | ||
| 369 | environmentId: target.environmentId, | ||
| 370 | threadId: ThreadId.make(snapshot.threadId), | ||
| 371 | terminalId: snapshot.terminalId, | ||
| 372 | }); | ||
| 373 | if (knownTarget === null) { | ||
| 374 | return; | ||
| 375 | } | ||
| 376 | |||
| 377 | setBuffer(knownTarget, bufferFromSnapshot(snapshot, maxBufferBytes)); | ||
| 378 | } | ||
| 379 | |||
| 380 | function applyMetadataEvent( | ||
| 381 | target: Pick<TerminalSessionTarget, "environmentId">, | ||
| 382 | event: TerminalMetadataStreamEvent, | ||
| 383 | ): void { | ||
| 384 | const environmentId = target.environmentId; | ||
| 385 | if (environmentId === null) { | ||
| 386 | return; | ||
| 387 | } | ||
| 388 | |||
| 389 | if (event.type === "snapshot") { | ||
| 390 | const retainedKeys = new Set<string>(); | ||
| 391 | const next = { ...getMetadata(environmentId) }; | ||
| 392 | |||
| 393 | for (const terminal of event.terminals) { | ||
| 394 | const knownTarget = knownTargetFromSummary(environmentId, terminal); | ||
| 395 | const targetKey = keyFromKnownTarget(knownTarget); | ||
| 396 | retainedKeys.add(targetKey); | ||
| 397 | next[targetKey] = { | ||
| 398 | target: knownTarget, | ||
| 399 | summary: terminal, | ||
| 400 | }; | ||
| 401 | } | ||
| 402 | |||
| 403 | for (const key of Object.keys(next)) { | ||
| 404 | if (!retainedKeys.has(key)) { | ||
| 405 | delete next[key]; | ||
| 406 | } | ||
| 407 | } | ||
| 408 | |||
| 409 | setMetadata(environmentId, next); | ||
| 410 | return; | ||
| 411 | } | ||
| 412 | |||
| 413 | if (event.type === "upsert") { | ||
| 414 | const knownTarget = knownTargetFromSummary(environmentId, event.terminal); | ||
| 415 | const targetKey = keyFromKnownTarget(knownTarget); | ||
| 416 | setMetadata(environmentId, { | ||
| 417 | ...getMetadata(environmentId), | ||
| 418 | [targetKey]: { | ||
| 419 | target: knownTarget, | ||
| 420 | summary: event.terminal, | ||
| 421 | }, | ||
| 422 | }); | ||
| 423 | return; | ||
| 424 | } | ||
| 425 | |||
| 426 | const knownTarget = getKnownTerminalSessionTarget({ | ||
| 427 | environmentId, | ||
| 428 | threadId: ThreadId.make(event.threadId), | ||
| 429 | terminalId: event.terminalId, | ||
| 430 | }); | ||
| 431 | if (knownTarget === null) { | ||
| 432 | return; | ||
| 433 | } | ||
| 434 | |||
| 435 | const next = { ...getMetadata(environmentId) }; | ||
| 436 | delete next[keyFromKnownTarget(knownTarget)]; | ||
| 437 | setMetadata(environmentId, next); | ||
| 438 | } | ||
| 439 | |||
| 440 | function applyAttachEvent( | ||
| 441 | target: Pick<TerminalSessionTarget, "environmentId">, | ||
| 442 | event: TerminalAttachStreamEvent, | ||
| 443 | ): void { | ||
| 444 | if (event.type === "snapshot") { | ||
| 445 | syncSnapshot(target, event.snapshot); | ||
| 446 | return; | ||
| 447 | } | ||
| 448 | |||
| 449 | const knownTarget = getKnownTerminalSessionTarget({ | ||
| 450 | environmentId: target.environmentId, | ||
| 451 | threadId: ThreadId.make(event.threadId), | ||
| 452 | terminalId: event.terminalId, | ||
| 453 | }); | ||
| 454 | if (knownTarget === null) { | ||
| 455 | return; | ||
| 456 | } | ||
| 457 | |||
| 458 | const current = getBuffer(knownTarget); | ||
| 459 | switch (event.type) { | ||
| 460 | case "restarted": | ||
| 461 | setBuffer(knownTarget, bufferFromSnapshot(event.snapshot, maxBufferBytes)); | ||
| 462 | return; | ||
| 463 | case "output": | ||
| 464 | setBuffer(knownTarget, { | ||
| 465 | ...current, | ||
| 466 | buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), | ||
| 467 | status: current.status === "closed" ? "running" : current.status, | ||
| 468 | error: null, | ||
| 469 | version: current.version + 1, | ||
| 470 | }); | ||
| 471 | return; | ||
| 472 | case "cleared": | ||
| 473 | setBuffer(knownTarget, { | ||
| 474 | ...current, | ||
| 475 | buffer: "", | ||
| 476 | error: null, | ||
| 477 | version: current.version + 1, | ||
| 478 | }); | ||
| 479 | return; | ||
| 480 | case "exited": | ||
| 481 | setBuffer(knownTarget, { | ||
| 482 | ...current, | ||
| 483 | status: "exited", | ||
| 484 | error: null, | ||
| 485 | version: current.version + 1, | ||
| 486 | }); | ||
| 487 | return; | ||
| 488 | case "closed": | ||
| 489 | setBuffer(knownTarget, { | ||
| 490 | ...current, | ||
| 491 | status: "closed", | ||
| 492 | error: null, | ||
| 493 | version: current.version + 1, | ||
| 494 | }); | ||
| 495 | return; | ||
| 496 | case "error": | ||
| 497 | setBuffer(knownTarget, { | ||
| 498 | ...current, | ||
| 499 | status: "error", | ||
| 500 | error: event.message, | ||
| 501 | version: current.version + 1, | ||
| 502 | }); | ||
| 503 | return; | ||
| 504 | case "activity": | ||
| 505 | return; | ||
| 506 | } | ||
| 507 | } | ||
| 508 | |||
| 509 | function invalidate(target?: TerminalSessionTarget): void { | ||
| 510 | if (target) { | ||
| 511 | const knownTarget = getKnownTerminalSessionTarget(target); | ||
| 512 | if (knownTarget !== null) { | ||
| 513 | const targetKey = keyFromKnownTarget(knownTarget); | ||
| 514 | const next = { ...getMetadata(knownTarget.environmentId) }; | ||
| 515 | delete next[targetKey]; | ||
| 516 | setMetadata(knownTarget.environmentId, next); | ||
| 517 | setBuffer(knownTarget, EMPTY_TERMINAL_BUFFER_STATE); | ||
| 518 | } | ||
| 519 | return; | ||
| 520 | } | ||
| 521 | |||
| 522 | for (const environmentId of knownTerminalMetadataEnvironmentIds) { | ||
| 523 | setMetadata(environmentId, {}); | ||
| 524 | } | ||
| 525 | knownTerminalMetadataEnvironmentIds.clear(); | ||
| 526 | for (const target of knownTerminalBufferTargets.values()) { | ||
| 527 | setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); | ||
| 528 | } | ||
| 529 | knownTerminalBufferTargets.clear(); | ||
| 530 | } | ||
| 531 | |||
| 532 | function invalidateEnvironment(environmentId: EnvironmentId): void { | ||
| 533 | setMetadata(environmentId, {}); | ||
| 534 | knownTerminalMetadataEnvironmentIds.delete(environmentId); | ||
| 535 | |||
| 536 | const prefix = `${environmentId}:`; | ||
| 537 | for (const [key, target] of knownTerminalBufferTargets) { | ||
| 538 | if (key.startsWith(prefix)) { | ||
| 539 | setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); | ||
| 540 | } | ||
| 541 | } | ||
| 542 | } | ||
| 543 | |||
| 544 | function reset(): void { | ||
| 545 | invalidate(); | ||
| 546 | } | ||
| 547 | |||
| 548 | function listSessions( | ||
| 549 | filter?: Partial<KnownTerminalSessionTarget>, | ||
| 550 | ): ReadonlyArray<KnownTerminalSession> { | ||
| 551 | if (filter?.environmentId) { | ||
| 552 | return listKnownSessionsFromMetadata(getMetadata(filter.environmentId), getBuffer, filter); | ||
| 553 | } | ||
| 554 | |||
| 555 | return pipe( | ||
| 556 | knownTerminalMetadataEnvironmentIds, | ||
| 557 | Arr.fromIterable, | ||
| 558 | Arr.flatMap((environmentId) => | ||
| 559 | listKnownSessionsFromMetadata(getMetadata(environmentId), getBuffer, filter), | ||
| 560 | ), | ||
| 561 | ); | ||
| 562 | } | ||
| 563 | |||
| 564 | function subscribeMetadata(input: { | ||
| 565 | readonly environmentId: EnvironmentId; | ||
| 566 | readonly client: TerminalMetadataClient; | ||
| 567 | readonly options?: { readonly onResubscribe?: () => void }; | ||
| 568 | }): () => void { | ||
| 569 | return input.client.terminal.onMetadata( | ||
| 570 | (event) => applyMetadataEvent({ environmentId: input.environmentId }, event), | ||
| 571 | input.options, | ||
| 572 | ); | ||
| 573 | } | ||
| 574 | |||
| 575 | function attach(input: { | ||
| 576 | readonly environmentId: EnvironmentId; | ||
| 577 | readonly client: TerminalAttachClient; | ||
| 578 | readonly terminal: TerminalAttachInput; | ||
| 579 | readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; | ||
| 580 | readonly onEvent?: (event: TerminalAttachStreamEvent) => void; | ||
| 581 | readonly options?: { readonly onResubscribe?: () => void }; | ||
| 582 | }): () => void { | ||
| 583 | return input.client.terminal.attach( | ||
| 584 | input.terminal, | ||
| 585 | (event) => { | ||
| 586 | applyAttachEvent({ environmentId: input.environmentId }, event); | ||
| 587 | input.onEvent?.(event); | ||
| 588 | if (event.type === "snapshot") { | ||
| 589 | input.onSnapshot?.(event.snapshot); | ||
| 590 | } | ||
| 591 | }, | ||
| 592 | input.options, | ||
| 593 | ); | ||
| 594 | } | ||
| 595 | |||
| 596 | return { | ||
| 597 | attach, | ||
| 598 | getSnapshot, | ||
| 599 | invalidate, | ||
| 600 | invalidateEnvironment, | ||
| 601 | listSessions, | ||
| 602 | subscribeMetadata, | ||
| 603 | reset, | ||
| 604 | }; | ||
| 605 | } | ||
packages/client-runtime/src/threadDetailReducer.test.ts deleted-657| ... | @@ -1,657 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CheckpointRef, | ||
| 5 | EventId, | ||
| 6 | MessageId, | ||
| 7 | ProjectId, | ||
| 8 | ProviderInstanceId, | ||
| 9 | ThreadId, | ||
| 10 | TurnId, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import type { OrchestrationThread } from "@t3tools/contracts"; | ||
| 13 | |||
| 14 | import { applyThreadDetailEvent } from "./threadDetailReducer.ts"; | ||
| 15 | |||
| 16 | const baseEventFields = { | ||
| 17 | eventId: EventId.make("event-1"), | ||
| 18 | commandId: null, | ||
| 19 | causationEventId: null, | ||
| 20 | correlationId: null, | ||
| 21 | metadata: {}, | ||
| 22 | } as const; | ||
| 23 | |||
| 24 | const baseThread: OrchestrationThread = { | ||
| 25 | id: ThreadId.make("thread-1"), | ||
| 26 | projectId: ProjectId.make("project-1"), | ||
| 27 | title: "Test Thread", | ||
| 28 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 29 | runtimeMode: "full-access", | ||
| 30 | interactionMode: "default", | ||
| 31 | branch: null, | ||
| 32 | worktreePath: null, | ||
| 33 | latestTurn: null, | ||
| 34 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 35 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 36 | archivedAt: null, | ||
| 37 | deletedAt: null, | ||
| 38 | messages: [], | ||
| 39 | proposedPlans: [], | ||
| 40 | activities: [], | ||
| 41 | checkpoints: [], | ||
| 42 | session: null, | ||
| 43 | }; | ||
| 44 | |||
| 45 | describe("applyThreadDetailEvent", () => { | ||
| 46 | describe("project events", () => { | ||
| 47 | it("returns unchanged for project.created", () => { | ||
| 48 | const result = applyThreadDetailEvent(baseThread, { | ||
| 49 | ...baseEventFields, | ||
| 50 | sequence: 1, | ||
| 51 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 52 | aggregateKind: "project", | ||
| 53 | aggregateId: ProjectId.make("project-1"), | ||
| 54 | type: "project.created", | ||
| 55 | payload: { | ||
| 56 | projectId: ProjectId.make("project-1"), | ||
| 57 | title: "T3 Code", | ||
| 58 | workspaceRoot: "/repo", | ||
| 59 | repositoryIdentity: null, | ||
| 60 | defaultModelSelection: null, | ||
| 61 | scripts: [], | ||
| 62 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 63 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 64 | deletedAt: null, | ||
| 65 | }, | ||
| 66 | } as any); | ||
| 67 | expect(result.kind).toBe("unchanged"); | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | |||
| 71 | describe("thread.created", () => { | ||
| 72 | it("creates a fresh thread", () => { | ||
| 73 | const result = applyThreadDetailEvent(baseThread, { | ||
| 74 | ...baseEventFields, | ||
| 75 | sequence: 1, | ||
| 76 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 77 | aggregateKind: "thread", | ||
| 78 | aggregateId: ThreadId.make("thread-2"), | ||
| 79 | type: "thread.created", | ||
| 80 | payload: { | ||
| 81 | threadId: ThreadId.make("thread-2"), | ||
| 82 | projectId: ProjectId.make("project-1"), | ||
| 83 | title: "New Thread", | ||
| 84 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 85 | runtimeMode: "full-access", | ||
| 86 | interactionMode: "default", | ||
| 87 | branch: "main", | ||
| 88 | worktreePath: null, | ||
| 89 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 90 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 91 | }, | ||
| 92 | }); | ||
| 93 | |||
| 94 | expect(result.kind).toBe("updated"); | ||
| 95 | if (result.kind === "updated") { | ||
| 96 | expect(result.thread.id).toBe("thread-2"); | ||
| 97 | expect(result.thread.title).toBe("New Thread"); | ||
| 98 | expect(result.thread.branch).toBe("main"); | ||
| 99 | expect(result.thread.messages).toEqual([]); | ||
| 100 | expect(result.thread.session).toBeNull(); | ||
| 101 | } | ||
| 102 | }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | describe("thread.deleted", () => { | ||
| 106 | it("returns deleted signal", () => { | ||
| 107 | const result = applyThreadDetailEvent(baseThread, { | ||
| 108 | ...baseEventFields, | ||
| 109 | sequence: 2, | ||
| 110 | occurredAt: "2026-04-01T02:00:00.000Z", | ||
| 111 | aggregateKind: "thread", | ||
| 112 | aggregateId: ThreadId.make("thread-1"), | ||
| 113 | type: "thread.deleted", | ||
| 114 | payload: { | ||
| 115 | threadId: ThreadId.make("thread-1"), | ||
| 116 | deletedAt: "2026-04-01T02:00:00.000Z", | ||
| 117 | }, | ||
| 118 | }); | ||
| 119 | expect(result.kind).toBe("deleted"); | ||
| 120 | }); | ||
| 121 | }); | ||
| 122 | |||
| 123 | describe("thread.archived / thread.unarchived", () => { | ||
| 124 | it("sets archivedAt", () => { | ||
| 125 | const result = applyThreadDetailEvent(baseThread, { | ||
| 126 | ...baseEventFields, | ||
| 127 | sequence: 3, | ||
| 128 | occurredAt: "2026-04-01T03:00:00.000Z", | ||
| 129 | aggregateKind: "thread", | ||
| 130 | aggregateId: ThreadId.make("thread-1"), | ||
| 131 | type: "thread.archived", | ||
| 132 | payload: { | ||
| 133 | threadId: ThreadId.make("thread-1"), | ||
| 134 | archivedAt: "2026-04-01T03:00:00.000Z", | ||
| 135 | updatedAt: "2026-04-01T03:00:00.000Z", | ||
| 136 | }, | ||
| 137 | }); | ||
| 138 | |||
| 139 | expect(result.kind).toBe("updated"); | ||
| 140 | if (result.kind === "updated") { | ||
| 141 | expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z"); | ||
| 142 | } | ||
| 143 | }); | ||
| 144 | |||
| 145 | it("clears archivedAt", () => { | ||
| 146 | const archivedThread = { ...baseThread, archivedAt: "2026-04-01T03:00:00.000Z" }; | ||
| 147 | const result = applyThreadDetailEvent(archivedThread, { | ||
| 148 | ...baseEventFields, | ||
| 149 | sequence: 4, | ||
| 150 | occurredAt: "2026-04-01T04:00:00.000Z", | ||
| 151 | aggregateKind: "thread", | ||
| 152 | aggregateId: ThreadId.make("thread-1"), | ||
| 153 | type: "thread.unarchived", | ||
| 154 | payload: { | ||
| 155 | threadId: ThreadId.make("thread-1"), | ||
| 156 | updatedAt: "2026-04-01T04:00:00.000Z", | ||
| 157 | }, | ||
| 158 | }); | ||
| 159 | |||
| 160 | expect(result.kind).toBe("updated"); | ||
| 161 | if (result.kind === "updated") { | ||
| 162 | expect(result.thread.archivedAt).toBeNull(); | ||
| 163 | } | ||
| 164 | }); | ||
| 165 | }); | ||
| 166 | |||
| 167 | describe("thread.meta-updated", () => { | ||
| 168 | it("patches title and branch", () => { | ||
| 169 | const result = applyThreadDetailEvent(baseThread, { | ||
| 170 | ...baseEventFields, | ||
| 171 | sequence: 5, | ||
| 172 | occurredAt: "2026-04-01T05:00:00.000Z", | ||
| 173 | aggregateKind: "thread", | ||
| 174 | aggregateId: ThreadId.make("thread-1"), | ||
| 175 | type: "thread.meta-updated", | ||
| 176 | payload: { | ||
| 177 | threadId: ThreadId.make("thread-1"), | ||
| 178 | title: "Updated Title", | ||
| 179 | branch: "feature/demo", | ||
| 180 | updatedAt: "2026-04-01T05:00:00.000Z", | ||
| 181 | }, | ||
| 182 | }); | ||
| 183 | |||
| 184 | expect(result.kind).toBe("updated"); | ||
| 185 | if (result.kind === "updated") { | ||
| 186 | expect(result.thread.title).toBe("Updated Title"); | ||
| 187 | expect(result.thread.branch).toBe("feature/demo"); | ||
| 188 | // Model selection should be unchanged since it wasn't in the payload | ||
| 189 | expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); | ||
| 190 | } | ||
| 191 | }); | ||
| 192 | }); | ||
| 193 | |||
| 194 | describe("thread.message-sent", () => { | ||
| 195 | it("appends a new message", () => { | ||
| 196 | const result = applyThreadDetailEvent(baseThread, { | ||
| 197 | ...baseEventFields, | ||
| 198 | sequence: 6, | ||
| 199 | occurredAt: "2026-04-01T06:00:00.000Z", | ||
| 200 | aggregateKind: "thread", | ||
| 201 | aggregateId: ThreadId.make("thread-1"), | ||
| 202 | type: "thread.message-sent", | ||
| 203 | payload: { | ||
| 204 | threadId: ThreadId.make("thread-1"), | ||
| 205 | messageId: MessageId.make("msg-1"), | ||
| 206 | role: "user", | ||
| 207 | text: "Hello, world!", | ||
| 208 | turnId: null, | ||
| 209 | streaming: false, | ||
| 210 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 211 | updatedAt: "2026-04-01T06:00:00.000Z", | ||
| 212 | }, | ||
| 213 | }); | ||
| 214 | |||
| 215 | expect(result.kind).toBe("updated"); | ||
| 216 | if (result.kind === "updated") { | ||
| 217 | expect(result.thread.messages).toHaveLength(1); | ||
| 218 | expect(result.thread.messages[0]?.text).toBe("Hello, world!"); | ||
| 219 | } | ||
| 220 | }); | ||
| 221 | |||
| 222 | it("appends text for streaming messages", () => { | ||
| 223 | const threadWithMessage: OrchestrationThread = { | ||
| 224 | ...baseThread, | ||
| 225 | messages: [ | ||
| 226 | { | ||
| 227 | id: MessageId.make("msg-2"), | ||
| 228 | role: "assistant", | ||
| 229 | text: "Hello", | ||
| 230 | turnId: TurnId.make("turn-1"), | ||
| 231 | streaming: true, | ||
| 232 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 233 | updatedAt: "2026-04-01T06:00:00.000Z", | ||
| 234 | }, | ||
| 235 | ], | ||
| 236 | }; | ||
| 237 | |||
| 238 | const result = applyThreadDetailEvent(threadWithMessage, { | ||
| 239 | ...baseEventFields, | ||
| 240 | sequence: 7, | ||
| 241 | occurredAt: "2026-04-01T06:01:00.000Z", | ||
| 242 | aggregateKind: "thread", | ||
| 243 | aggregateId: ThreadId.make("thread-1"), | ||
| 244 | type: "thread.message-sent", | ||
| 245 | payload: { | ||
| 246 | threadId: ThreadId.make("thread-1"), | ||
| 247 | messageId: MessageId.make("msg-2"), | ||
| 248 | role: "assistant", | ||
| 249 | text: ", world!", | ||
| 250 | turnId: TurnId.make("turn-1"), | ||
| 251 | streaming: true, | ||
| 252 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 253 | updatedAt: "2026-04-01T06:01:00.000Z", | ||
| 254 | }, | ||
| 255 | }); | ||
| 256 | |||
| 257 | expect(result.kind).toBe("updated"); | ||
| 258 | if (result.kind === "updated") { | ||
| 259 | expect(result.thread.messages).toHaveLength(1); | ||
| 260 | expect(result.thread.messages[0]?.text).toBe("Hello, world!"); | ||
| 261 | } | ||
| 262 | }); | ||
| 263 | |||
| 264 | it("updates latestTurn for assistant messages with a turn", () => { | ||
| 265 | const result = applyThreadDetailEvent(baseThread, { | ||
| 266 | ...baseEventFields, | ||
| 267 | sequence: 8, | ||
| 268 | occurredAt: "2026-04-01T07:00:00.000Z", | ||
| 269 | aggregateKind: "thread", | ||
| 270 | aggregateId: ThreadId.make("thread-1"), | ||
| 271 | type: "thread.message-sent", | ||
| 272 | payload: { | ||
| 273 | threadId: ThreadId.make("thread-1"), | ||
| 274 | messageId: MessageId.make("msg-3"), | ||
| 275 | role: "assistant", | ||
| 276 | text: "Done.", | ||
| 277 | turnId: TurnId.make("turn-1"), | ||
| 278 | streaming: false, | ||
| 279 | createdAt: "2026-04-01T07:00:00.000Z", | ||
| 280 | updatedAt: "2026-04-01T07:00:00.000Z", | ||
| 281 | }, | ||
| 282 | }); | ||
| 283 | |||
| 284 | expect(result.kind).toBe("updated"); | ||
| 285 | if (result.kind === "updated") { | ||
| 286 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 287 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 288 | expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-3"); | ||
| 289 | } | ||
| 290 | }); | ||
| 291 | |||
| 292 | it("keeps latestTurn running for interim assistant messages while the session runs the turn", () => { | ||
| 293 | const threadWithRunningSession: OrchestrationThread = { | ||
| 294 | ...baseThread, | ||
| 295 | session: { | ||
| 296 | threadId: ThreadId.make("thread-1"), | ||
| 297 | status: "running", | ||
| 298 | providerName: "claude", | ||
| 299 | runtimeMode: "full-access", | ||
| 300 | activeTurnId: TurnId.make("turn-1"), | ||
| 301 | lastError: null, | ||
| 302 | updatedAt: "2026-04-01T06:59:00.000Z", | ||
| 303 | }, | ||
| 304 | latestTurn: { | ||
| 305 | turnId: TurnId.make("turn-1"), | ||
| 306 | state: "running", | ||
| 307 | requestedAt: "2026-04-01T06:59:00.000Z", | ||
| 308 | startedAt: "2026-04-01T06:59:00.000Z", | ||
| 309 | completedAt: null, | ||
| 310 | assistantMessageId: null, | ||
| 311 | }, | ||
| 312 | }; | ||
| 313 | |||
| 314 | const result = applyThreadDetailEvent(threadWithRunningSession, { | ||
| 315 | ...baseEventFields, | ||
| 316 | sequence: 8, | ||
| 317 | occurredAt: "2026-04-01T07:00:00.000Z", | ||
| 318 | aggregateKind: "thread", | ||
| 319 | aggregateId: ThreadId.make("thread-1"), | ||
| 320 | type: "thread.message-sent", | ||
| 321 | payload: { | ||
| 322 | threadId: ThreadId.make("thread-1"), | ||
| 323 | messageId: MessageId.make("msg-3"), | ||
| 324 | role: "assistant", | ||
| 325 | text: "Interim commentary between tool calls.", | ||
| 326 | turnId: TurnId.make("turn-1"), | ||
| 327 | streaming: false, | ||
| 328 | createdAt: "2026-04-01T07:00:00.000Z", | ||
| 329 | updatedAt: "2026-04-01T07:00:00.000Z", | ||
| 330 | }, | ||
| 331 | }); | ||
| 332 | |||
| 333 | expect(result.kind).toBe("updated"); | ||
| 334 | if (result.kind === "updated") { | ||
| 335 | expect(result.thread.latestTurn?.state).toBe("running"); | ||
| 336 | expect(result.thread.latestTurn?.completedAt).toBeNull(); | ||
| 337 | } | ||
| 338 | }); | ||
| 339 | }); | ||
| 340 | |||
| 341 | describe("thread.session-set", () => { | ||
| 342 | it("settles a running latestTurn when the session leaves the running status", () => { | ||
| 343 | const threadWithRunningTurn: OrchestrationThread = { | ||
| 344 | ...baseThread, | ||
| 345 | latestTurn: { | ||
| 346 | turnId: TurnId.make("turn-1"), | ||
| 347 | state: "running", | ||
| 348 | requestedAt: "2026-04-01T07:00:00.000Z", | ||
| 349 | startedAt: "2026-04-01T07:00:00.000Z", | ||
| 350 | completedAt: null, | ||
| 351 | assistantMessageId: MessageId.make("msg-3"), | ||
| 352 | }, | ||
| 353 | }; | ||
| 354 | |||
| 355 | const result = applyThreadDetailEvent(threadWithRunningTurn, { | ||
| 356 | ...baseEventFields, | ||
| 357 | sequence: 9, | ||
| 358 | occurredAt: "2026-04-01T08:00:00.000Z", | ||
| 359 | aggregateKind: "thread", | ||
| 360 | aggregateId: ThreadId.make("thread-1"), | ||
| 361 | type: "thread.session-set", | ||
| 362 | payload: { | ||
| 363 | threadId: ThreadId.make("thread-1"), | ||
| 364 | session: { | ||
| 365 | threadId: ThreadId.make("thread-1"), | ||
| 366 | status: "ready", | ||
| 367 | providerName: "claude", | ||
| 368 | runtimeMode: "full-access", | ||
| 369 | activeTurnId: null, | ||
| 370 | lastError: null, | ||
| 371 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 372 | }, | ||
| 373 | }, | ||
| 374 | }); | ||
| 375 | |||
| 376 | expect(result.kind).toBe("updated"); | ||
| 377 | if (result.kind === "updated") { | ||
| 378 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 379 | expect(result.thread.latestTurn?.completedAt).toBe("2026-04-01T08:00:00.000Z"); | ||
| 380 | } | ||
| 381 | }); | ||
| 382 | |||
| 383 | it("updates session and latestTurn for a running session", () => { | ||
| 384 | const result = applyThreadDetailEvent(baseThread, { | ||
| 385 | ...baseEventFields, | ||
| 386 | sequence: 9, | ||
| 387 | occurredAt: "2026-04-01T08:00:00.000Z", | ||
| 388 | aggregateKind: "thread", | ||
| 389 | aggregateId: ThreadId.make("thread-1"), | ||
| 390 | type: "thread.session-set", | ||
| 391 | payload: { | ||
| 392 | threadId: ThreadId.make("thread-1"), | ||
| 393 | session: { | ||
| 394 | threadId: ThreadId.make("thread-1"), | ||
| 395 | status: "running", | ||
| 396 | providerName: "codex", | ||
| 397 | runtimeMode: "full-access", | ||
| 398 | activeTurnId: TurnId.make("turn-1"), | ||
| 399 | lastError: null, | ||
| 400 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 401 | }, | ||
| 402 | }, | ||
| 403 | }); | ||
| 404 | |||
| 405 | expect(result.kind).toBe("updated"); | ||
| 406 | if (result.kind === "updated") { | ||
| 407 | expect(result.thread.session?.status).toBe("running"); | ||
| 408 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 409 | expect(result.thread.latestTurn?.state).toBe("running"); | ||
| 410 | } | ||
| 411 | }); | ||
| 412 | }); | ||
| 413 | |||
| 414 | describe("thread.session-stop-requested", () => { | ||
| 415 | it("marks session as stopped", () => { | ||
| 416 | const threadWithSession: OrchestrationThread = { | ||
| 417 | ...baseThread, | ||
| 418 | session: { | ||
| 419 | threadId: ThreadId.make("thread-1"), | ||
| 420 | status: "running", | ||
| 421 | providerName: "codex", | ||
| 422 | runtimeMode: "full-access", | ||
| 423 | activeTurnId: TurnId.make("turn-1"), | ||
| 424 | lastError: null, | ||
| 425 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 426 | }, | ||
| 427 | }; | ||
| 428 | |||
| 429 | const result = applyThreadDetailEvent(threadWithSession, { | ||
| 430 | ...baseEventFields, | ||
| 431 | sequence: 10, | ||
| 432 | occurredAt: "2026-04-01T09:00:00.000Z", | ||
| 433 | aggregateKind: "thread", | ||
| 434 | aggregateId: ThreadId.make("thread-1"), | ||
| 435 | type: "thread.session-stop-requested", | ||
| 436 | payload: { | ||
| 437 | threadId: ThreadId.make("thread-1"), | ||
| 438 | createdAt: "2026-04-01T09:00:00.000Z", | ||
| 439 | }, | ||
| 440 | }); | ||
| 441 | |||
| 442 | expect(result.kind).toBe("updated"); | ||
| 443 | if (result.kind === "updated") { | ||
| 444 | expect(result.thread.session?.status).toBe("stopped"); | ||
| 445 | expect(result.thread.session?.activeTurnId).toBeNull(); | ||
| 446 | } | ||
| 447 | }); | ||
| 448 | |||
| 449 | it("returns unchanged when no session exists", () => { | ||
| 450 | const result = applyThreadDetailEvent(baseThread, { | ||
| 451 | ...baseEventFields, | ||
| 452 | sequence: 10, | ||
| 453 | occurredAt: "2026-04-01T09:00:00.000Z", | ||
| 454 | aggregateKind: "thread", | ||
| 455 | aggregateId: ThreadId.make("thread-1"), | ||
| 456 | type: "thread.session-stop-requested", | ||
| 457 | payload: { | ||
| 458 | threadId: ThreadId.make("thread-1"), | ||
| 459 | createdAt: "2026-04-01T09:00:00.000Z", | ||
| 460 | }, | ||
| 461 | }); | ||
| 462 | expect(result.kind).toBe("unchanged"); | ||
| 463 | }); | ||
| 464 | }); | ||
| 465 | |||
| 466 | describe("thread.proposed-plan-upserted", () => { | ||
| 467 | it("adds a proposed plan", () => { | ||
| 468 | const result = applyThreadDetailEvent(baseThread, { | ||
| 469 | ...baseEventFields, | ||
| 470 | sequence: 11, | ||
| 471 | occurredAt: "2026-04-01T10:00:00.000Z", | ||
| 472 | aggregateKind: "thread", | ||
| 473 | aggregateId: ThreadId.make("thread-1"), | ||
| 474 | type: "thread.proposed-plan-upserted", | ||
| 475 | payload: { | ||
| 476 | threadId: ThreadId.make("thread-1"), | ||
| 477 | proposedPlan: { | ||
| 478 | id: "plan-1", | ||
| 479 | turnId: TurnId.make("turn-1"), | ||
| 480 | planMarkdown: "## Plan\n- Do stuff", | ||
| 481 | implementedAt: null, | ||
| 482 | implementationThreadId: null, | ||
| 483 | createdAt: "2026-04-01T10:00:00.000Z", | ||
| 484 | updatedAt: "2026-04-01T10:00:00.000Z", | ||
| 485 | }, | ||
| 486 | }, | ||
| 487 | }); | ||
| 488 | |||
| 489 | expect(result.kind).toBe("updated"); | ||
| 490 | if (result.kind === "updated") { | ||
| 491 | expect(result.thread.proposedPlans).toHaveLength(1); | ||
| 492 | expect(result.thread.proposedPlans[0]?.id).toBe("plan-1"); | ||
| 493 | } | ||
| 494 | }); | ||
| 495 | }); | ||
| 496 | |||
| 497 | describe("thread.activity-appended", () => { | ||
| 498 | it("adds an activity", () => { | ||
| 499 | const result = applyThreadDetailEvent(baseThread, { | ||
| 500 | ...baseEventFields, | ||
| 501 | sequence: 12, | ||
| 502 | occurredAt: "2026-04-01T11:00:00.000Z", | ||
| 503 | aggregateKind: "thread", | ||
| 504 | aggregateId: ThreadId.make("thread-1"), | ||
| 505 | type: "thread.activity-appended", | ||
| 506 | payload: { | ||
| 507 | threadId: ThreadId.make("thread-1"), | ||
| 508 | activity: { | ||
| 509 | id: EventId.make("activity-1"), | ||
| 510 | tone: "tool", | ||
| 511 | kind: "file-edit", | ||
| 512 | summary: "Edited src/index.ts", | ||
| 513 | payload: {}, | ||
| 514 | turnId: TurnId.make("turn-1"), | ||
| 515 | createdAt: "2026-04-01T11:00:00.000Z", | ||
| 516 | }, | ||
| 517 | }, | ||
| 518 | }); | ||
| 519 | |||
| 520 | expect(result.kind).toBe("updated"); | ||
| 521 | if (result.kind === "updated") { | ||
| 522 | expect(result.thread.activities).toHaveLength(1); | ||
| 523 | expect(result.thread.activities[0]?.kind).toBe("file-edit"); | ||
| 524 | } | ||
| 525 | }); | ||
| 526 | }); | ||
| 527 | |||
| 528 | describe("thread.turn-diff-completed", () => { | ||
| 529 | it("adds a checkpoint and updates latestTurn", () => { | ||
| 530 | const result = applyThreadDetailEvent(baseThread, { | ||
| 531 | ...baseEventFields, | ||
| 532 | sequence: 13, | ||
| 533 | occurredAt: "2026-04-01T12:00:00.000Z", | ||
| 534 | aggregateKind: "thread", | ||
| 535 | aggregateId: ThreadId.make("thread-1"), | ||
| 536 | type: "thread.turn-diff-completed", | ||
| 537 | payload: { | ||
| 538 | threadId: ThreadId.make("thread-1"), | ||
| 539 | turnId: TurnId.make("turn-1"), | ||
| 540 | checkpointTurnCount: 1, | ||
| 541 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 542 | status: "ready", | ||
| 543 | files: [], | ||
| 544 | assistantMessageId: MessageId.make("msg-3"), | ||
| 545 | completedAt: "2026-04-01T12:00:00.000Z", | ||
| 546 | }, | ||
| 547 | }); | ||
| 548 | |||
| 549 | expect(result.kind).toBe("updated"); | ||
| 550 | if (result.kind === "updated") { | ||
| 551 | expect(result.thread.checkpoints).toHaveLength(1); | ||
| 552 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 553 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 554 | } | ||
| 555 | }); | ||
| 556 | }); | ||
| 557 | |||
| 558 | describe("thread.reverted", () => { | ||
| 559 | it("filters entities to retained turns", () => { | ||
| 560 | const threadWithData: OrchestrationThread = { | ||
| 561 | ...baseThread, | ||
| 562 | messages: [ | ||
| 563 | { | ||
| 564 | id: MessageId.make("msg-1"), | ||
| 565 | role: "user", | ||
| 566 | text: "First", | ||
| 567 | turnId: null, | ||
| 568 | streaming: false, | ||
| 569 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 570 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 571 | }, | ||
| 572 | { | ||
| 573 | id: MessageId.make("msg-2"), | ||
| 574 | role: "assistant", | ||
| 575 | text: "Response 1", | ||
| 576 | turnId: TurnId.make("turn-1"), | ||
| 577 | streaming: false, | ||
| 578 | createdAt: "2026-04-01T02:00:00.000Z", | ||
| 579 | updatedAt: "2026-04-01T02:00:00.000Z", | ||
| 580 | }, | ||
| 581 | { | ||
| 582 | id: MessageId.make("msg-3"), | ||
| 583 | role: "assistant", | ||
| 584 | text: "Response 2", | ||
| 585 | turnId: TurnId.make("turn-2"), | ||
| 586 | streaming: false, | ||
| 587 | createdAt: "2026-04-01T03:00:00.000Z", | ||
| 588 | updatedAt: "2026-04-01T03:00:00.000Z", | ||
| 589 | }, | ||
| 590 | ], | ||
| 591 | checkpoints: [ | ||
| 592 | { | ||
| 593 | turnId: TurnId.make("turn-1"), | ||
| 594 | checkpointTurnCount: 1, | ||
| 595 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 596 | status: "ready", | ||
| 597 | files: [], | ||
| 598 | assistantMessageId: MessageId.make("msg-2"), | ||
| 599 | completedAt: "2026-04-01T02:00:00.000Z", | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | turnId: TurnId.make("turn-2"), | ||
| 603 | checkpointTurnCount: 2, | ||
| 604 | checkpointRef: CheckpointRef.make("ref-2"), | ||
| 605 | status: "ready", | ||
| 606 | files: [], | ||
| 607 | assistantMessageId: MessageId.make("msg-3"), | ||
| 608 | completedAt: "2026-04-01T03:00:00.000Z", | ||
| 609 | }, | ||
| 610 | ], | ||
| 611 | }; | ||
| 612 | |||
| 613 | const result = applyThreadDetailEvent(threadWithData, { | ||
| 614 | ...baseEventFields, | ||
| 615 | sequence: 14, | ||
| 616 | occurredAt: "2026-04-01T04:00:00.000Z", | ||
| 617 | aggregateKind: "thread", | ||
| 618 | aggregateId: ThreadId.make("thread-1"), | ||
| 619 | type: "thread.reverted", | ||
| 620 | payload: { | ||
| 621 | threadId: ThreadId.make("thread-1"), | ||
| 622 | turnCount: 1, | ||
| 623 | }, | ||
| 624 | }); | ||
| 625 | |||
| 626 | expect(result.kind).toBe("updated"); | ||
| 627 | if (result.kind === "updated") { | ||
| 628 | // turn-2 checkpoint is filtered out (turnCount 2 > revert target 1) | ||
| 629 | expect(result.thread.checkpoints).toHaveLength(1); | ||
| 630 | expect(result.thread.checkpoints[0]?.turnId).toBe("turn-1"); | ||
| 631 | // msg-3 (turn-2) is filtered, msg-1 (no turn) and msg-2 (turn-1) remain | ||
| 632 | expect(result.thread.messages).toHaveLength(2); | ||
| 633 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 634 | } | ||
| 635 | }); | ||
| 636 | }); | ||
| 637 | |||
| 638 | describe("no-op events", () => { | ||
| 639 | it("returns unchanged for approval-response-requested", () => { | ||
| 640 | const result = applyThreadDetailEvent(baseThread, { | ||
| 641 | ...baseEventFields, | ||
| 642 | sequence: 15, | ||
| 643 | occurredAt: "2026-04-01T13:00:00.000Z", | ||
| 644 | aggregateKind: "thread", | ||
| 645 | aggregateId: ThreadId.make("thread-1"), | ||
| 646 | type: "thread.approval-response-requested", | ||
| 647 | payload: { | ||
| 648 | threadId: ThreadId.make("thread-1"), | ||
| 649 | requestId: "req-1", | ||
| 650 | decision: "approve", | ||
| 651 | createdAt: "2026-04-01T13:00:00.000Z", | ||
| 652 | }, | ||
| 653 | } as any); | ||
| 654 | expect(result.kind).toBe("unchanged"); | ||
| 655 | }); | ||
| 656 | }); | ||
| 657 | }); | ||
packages/client-runtime/src/threadDetailReducer.ts deleted-576| ... | @@ -1,576 +0,0 @@ | ||
| 1 | import { pipe } from "effect/Function"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import * as O from "effect/Order"; | ||
| 4 | import type { | ||
| 5 | MessageId, | ||
| 6 | OrchestrationCheckpointSummary, | ||
| 7 | OrchestrationEvent, | ||
| 8 | OrchestrationLatestTurn, | ||
| 9 | OrchestrationMessage, | ||
| 10 | OrchestrationSession, | ||
| 11 | OrchestrationThread, | ||
| 12 | OrchestrationThreadActivity, | ||
| 13 | TurnId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | /** | ||
| 17 | * Retention limits for collections within a thread. | ||
| 18 | * These prevent unbounded growth of in-memory thread state. | ||
| 19 | */ | ||
| 20 | export interface ThreadDetailRetentionLimits { | ||
| 21 | readonly maxMessages: number; | ||
| 22 | readonly maxProposedPlans: number; | ||
| 23 | readonly maxCheckpoints: number; | ||
| 24 | readonly maxActivities: number; | ||
| 25 | } | ||
| 26 | |||
| 27 | export const DEFAULT_THREAD_DETAIL_LIMITS: ThreadDetailRetentionLimits = { | ||
| 28 | maxMessages: 512, | ||
| 29 | maxProposedPlans: 64, | ||
| 30 | maxCheckpoints: 256, | ||
| 31 | maxActivities: 128, | ||
| 32 | }; | ||
| 33 | |||
| 34 | export type ThreadDetailReducerResult = | ||
| 35 | | { readonly kind: "updated"; readonly thread: OrchestrationThread } | ||
| 36 | | { readonly kind: "deleted" } | ||
| 37 | | { readonly kind: "unchanged" }; | ||
| 38 | |||
| 39 | const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>( | ||
| 40 | O.mapInput(O.String, (p) => p.createdAt), | ||
| 41 | O.mapInput(O.String, (p) => p.id), | ||
| 42 | ); | ||
| 43 | |||
| 44 | const checkpointOrder = O.mapInput( | ||
| 45 | O.Number, | ||
| 46 | (cp: OrchestrationThread["checkpoints"][number]) => | ||
| 47 | cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER, | ||
| 48 | ); | ||
| 49 | |||
| 50 | const activityOrder = O.combineAll<OrchestrationThreadActivity>([ | ||
| 51 | O.mapInput(O.Number, (a) => a.sequence ?? Number.MAX_SAFE_INTEGER), | ||
| 52 | O.mapInput(O.String, (a) => a.createdAt), | ||
| 53 | O.mapInput(O.String, (a) => a.id), | ||
| 54 | ]); | ||
| 55 | |||
| 56 | /** | ||
| 57 | * Apply a single orchestration event to an `OrchestrationThread`, returning | ||
| 58 | * the updated thread, a deletion signal, or an "unchanged" marker when the | ||
| 59 | * event doesn't affect this thread. | ||
| 60 | * | ||
| 61 | * This is a pure reducer operating on contract types. UI-specific mapping | ||
| 62 | * (e.g. resolving attachment preview URLs, normalising model slugs, adding | ||
| 63 | * scoped fields like `environmentId`) is the caller's responsibility. | ||
| 64 | */ | ||
| 65 | export function applyThreadDetailEvent( | ||
| 66 | thread: OrchestrationThread, | ||
| 67 | event: OrchestrationEvent, | ||
| 68 | limits: ThreadDetailRetentionLimits = DEFAULT_THREAD_DETAIL_LIMITS, | ||
| 69 | ): ThreadDetailReducerResult { | ||
| 70 | switch (event.type) { | ||
| 71 | // ── Project events (irrelevant to thread detail) ──────────────── | ||
| 72 | case "project.created": | ||
| 73 | case "project.meta-updated": | ||
| 74 | case "project.deleted": | ||
| 75 | return { kind: "unchanged" }; | ||
| 76 | |||
| 77 | // ── Thread lifecycle ──────────────────────────────────────────── | ||
| 78 | case "thread.created": | ||
| 79 | return { | ||
| 80 | kind: "updated", | ||
| 81 | thread: { | ||
| 82 | id: event.payload.threadId, | ||
| 83 | projectId: event.payload.projectId, | ||
| 84 | title: event.payload.title, | ||
| 85 | modelSelection: event.payload.modelSelection, | ||
| 86 | runtimeMode: event.payload.runtimeMode, | ||
| 87 | interactionMode: event.payload.interactionMode, | ||
| 88 | branch: event.payload.branch, | ||
| 89 | worktreePath: event.payload.worktreePath, | ||
| 90 | latestTurn: null, | ||
| 91 | createdAt: event.payload.createdAt, | ||
| 92 | updatedAt: event.payload.updatedAt, | ||
| 93 | archivedAt: null, | ||
| 94 | deletedAt: null, | ||
| 95 | messages: [], | ||
| 96 | proposedPlans: [], | ||
| 97 | activities: [], | ||
| 98 | checkpoints: [], | ||
| 99 | session: null, | ||
| 100 | }, | ||
| 101 | }; | ||
| 102 | |||
| 103 | case "thread.deleted": | ||
| 104 | return { kind: "deleted" }; | ||
| 105 | |||
| 106 | case "thread.archived": | ||
| 107 | return { | ||
| 108 | kind: "updated", | ||
| 109 | thread: { | ||
| 110 | ...thread, | ||
| 111 | archivedAt: event.payload.archivedAt, | ||
| 112 | updatedAt: event.payload.updatedAt, | ||
| 113 | }, | ||
| 114 | }; | ||
| 115 | |||
| 116 | case "thread.unarchived": | ||
| 117 | return { | ||
| 118 | kind: "updated", | ||
| 119 | thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, | ||
| 120 | }; | ||
| 121 | |||
| 122 | // ── Thread metadata ───────────────────────────────────────────── | ||
| 123 | case "thread.meta-updated": | ||
| 124 | return { | ||
| 125 | kind: "updated", | ||
| 126 | thread: { | ||
| 127 | ...thread, | ||
| 128 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | ||
| 129 | ...(event.payload.modelSelection !== undefined | ||
| 130 | ? { modelSelection: event.payload.modelSelection } | ||
| 131 | : {}), | ||
| 132 | ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), | ||
| 133 | ...(event.payload.worktreePath !== undefined | ||
| 134 | ? { worktreePath: event.payload.worktreePath } | ||
| 135 | : {}), | ||
| 136 | updatedAt: event.payload.updatedAt, | ||
| 137 | }, | ||
| 138 | }; | ||
| 139 | |||
| 140 | case "thread.runtime-mode-set": | ||
| 141 | return { | ||
| 142 | kind: "updated", | ||
| 143 | thread: { | ||
| 144 | ...thread, | ||
| 145 | runtimeMode: event.payload.runtimeMode, | ||
| 146 | updatedAt: event.payload.updatedAt, | ||
| 147 | }, | ||
| 148 | }; | ||
| 149 | |||
| 150 | case "thread.interaction-mode-set": | ||
| 151 | return { | ||
| 152 | kind: "updated", | ||
| 153 | thread: { | ||
| 154 | ...thread, | ||
| 155 | interactionMode: event.payload.interactionMode, | ||
| 156 | updatedAt: event.payload.updatedAt, | ||
| 157 | }, | ||
| 158 | }; | ||
| 159 | |||
| 160 | // ── Turn lifecycle ────────────────────────────────────────────── | ||
| 161 | case "thread.turn-start-requested": | ||
| 162 | return { | ||
| 163 | kind: "updated", | ||
| 164 | thread: { | ||
| 165 | ...thread, | ||
| 166 | ...(event.payload.modelSelection !== undefined | ||
| 167 | ? { modelSelection: event.payload.modelSelection } | ||
| 168 | : {}), | ||
| 169 | runtimeMode: event.payload.runtimeMode, | ||
| 170 | interactionMode: event.payload.interactionMode, | ||
| 171 | updatedAt: event.occurredAt, | ||
| 172 | }, | ||
| 173 | }; | ||
| 174 | |||
| 175 | case "thread.turn-interrupt-requested": { | ||
| 176 | if (event.payload.turnId === undefined) { | ||
| 177 | return { kind: "unchanged" }; | ||
| 178 | } | ||
| 179 | const latestTurn = thread.latestTurn; | ||
| 180 | if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { | ||
| 181 | return { kind: "unchanged" }; | ||
| 182 | } | ||
| 183 | return { | ||
| 184 | kind: "updated", | ||
| 185 | thread: { | ||
| 186 | ...thread, | ||
| 187 | latestTurn: { | ||
| 188 | ...latestTurn, | ||
| 189 | state: "interrupted", | ||
| 190 | startedAt: latestTurn.startedAt ?? event.payload.createdAt, | ||
| 191 | completedAt: latestTurn.completedAt ?? event.payload.createdAt, | ||
| 192 | }, | ||
| 193 | updatedAt: event.occurredAt, | ||
| 194 | }, | ||
| 195 | }; | ||
| 196 | } | ||
| 197 | |||
| 198 | // ── Messages ──────────────────────────────────────────────────── | ||
| 199 | case "thread.message-sent": { | ||
| 200 | const message: OrchestrationMessage = { | ||
| 201 | id: event.payload.messageId, | ||
| 202 | role: event.payload.role, | ||
| 203 | text: event.payload.text, | ||
| 204 | ...(event.payload.attachments !== undefined | ||
| 205 | ? { attachments: event.payload.attachments } | ||
| 206 | : {}), | ||
| 207 | turnId: event.payload.turnId, | ||
| 208 | streaming: event.payload.streaming, | ||
| 209 | createdAt: event.payload.createdAt, | ||
| 210 | updatedAt: event.payload.updatedAt, | ||
| 211 | }; | ||
| 212 | |||
| 213 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); | ||
| 214 | const messages = existingMessage | ||
| 215 | ? Arr.map(thread.messages, (entry) => | ||
| 216 | entry.id !== message.id | ||
| 217 | ? entry | ||
| 218 | : { | ||
| 219 | ...entry, | ||
| 220 | text: message.streaming | ||
| 221 | ? `${entry.text}${message.text}` | ||
| 222 | : message.text.length > 0 | ||
| 223 | ? message.text | ||
| 224 | : entry.text, | ||
| 225 | streaming: message.streaming, | ||
| 226 | ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), | ||
| 227 | ...(message.streaming ? {} : { updatedAt: message.updatedAt }), | ||
| 228 | ...(message.attachments !== undefined | ||
| 229 | ? { attachments: message.attachments } | ||
| 230 | : {}), | ||
| 231 | }, | ||
| 232 | ) | ||
| 233 | : Arr.append(thread.messages, message); | ||
| 234 | const cappedMessages = Arr.takeRight(messages, limits.maxMessages); | ||
| 235 | |||
| 236 | // Update latestTurn for assistant messages bound to a turn. A completed | ||
| 237 | // assistant message only settles the turn once the session is no longer | ||
| 238 | // running it — providers may emit several assistant messages per turn | ||
| 239 | // (commentary between tool calls), and the turn must stay unsettled | ||
| 240 | // until the provider reports turn end. | ||
| 241 | const turnStillRunning = | ||
| 242 | event.payload.turnId !== null && | ||
| 243 | thread.session?.status === "running" && | ||
| 244 | thread.session.activeTurnId === event.payload.turnId; | ||
| 245 | const settlesTurn = !event.payload.streaming && !turnStillRunning; | ||
| 246 | const latestTurn: OrchestrationThread["latestTurn"] = | ||
| 247 | event.payload.role === "assistant" && | ||
| 248 | event.payload.turnId !== null && | ||
| 249 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | ||
| 250 | ? { | ||
| 251 | turnId: event.payload.turnId, | ||
| 252 | state: settlesTurn | ||
| 253 | ? thread.latestTurn?.state === "interrupted" | ||
| 254 | ? "interrupted" | ||
| 255 | : thread.latestTurn?.state === "error" | ||
| 256 | ? "error" | ||
| 257 | : "completed" | ||
| 258 | : "running", | ||
| 259 | requestedAt: | ||
| 260 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 261 | ? thread.latestTurn.requestedAt | ||
| 262 | : event.payload.createdAt, | ||
| 263 | startedAt: | ||
| 264 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 265 | ? (thread.latestTurn.startedAt ?? event.payload.createdAt) | ||
| 266 | : event.payload.createdAt, | ||
| 267 | completedAt: settlesTurn | ||
| 268 | ? event.payload.updatedAt | ||
| 269 | : thread.latestTurn?.turnId === event.payload.turnId | ||
| 270 | ? (thread.latestTurn.completedAt ?? null) | ||
| 271 | : null, | ||
| 272 | assistantMessageId: event.payload.messageId, | ||
| 273 | } | ||
| 274 | : thread.latestTurn; | ||
| 275 | |||
| 276 | // Rebind checkpoint assistant message IDs for assistant messages. | ||
| 277 | const checkpoints = | ||
| 278 | event.payload.role === "assistant" && event.payload.turnId !== null | ||
| 279 | ? rebindCheckpointAssistantMessage( | ||
| 280 | thread.checkpoints, | ||
| 281 | event.payload.turnId, | ||
| 282 | event.payload.messageId, | ||
| 283 | ) | ||
| 284 | : thread.checkpoints; | ||
| 285 | |||
| 286 | return { | ||
| 287 | kind: "updated", | ||
| 288 | thread: { | ||
| 289 | ...thread, | ||
| 290 | messages: cappedMessages, | ||
| 291 | checkpoints, | ||
| 292 | latestTurn, | ||
| 293 | updatedAt: event.occurredAt, | ||
| 294 | }, | ||
| 295 | }; | ||
| 296 | } | ||
| 297 | |||
| 298 | // ── Session ───────────────────────────────────────────────────── | ||
| 299 | case "thread.session-set": { | ||
| 300 | // Leaving the "running" session status is the turn-end signal: settle a | ||
| 301 | // still-running latest turn so its duration reflects the whole turn. | ||
| 302 | const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); | ||
| 303 | const latestTurn: OrchestrationLatestTurn | null = | ||
| 304 | event.payload.session.status === "running" && event.payload.session.activeTurnId !== null | ||
| 305 | ? { | ||
| 306 | turnId: event.payload.session.activeTurnId, | ||
| 307 | state: "running", | ||
| 308 | requestedAt: | ||
| 309 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 310 | ? thread.latestTurn.requestedAt | ||
| 311 | : event.payload.session.updatedAt, | ||
| 312 | startedAt: | ||
| 313 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 314 | ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) | ||
| 315 | : event.payload.session.updatedAt, | ||
| 316 | completedAt: null, | ||
| 317 | assistantMessageId: | ||
| 318 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 319 | ? thread.latestTurn.assistantMessageId | ||
| 320 | : null, | ||
| 321 | } | ||
| 322 | : thread.latestTurn !== null && | ||
| 323 | thread.latestTurn.state === "running" && | ||
| 324 | settledTurnState !== null | ||
| 325 | ? { | ||
| 326 | ...thread.latestTurn, | ||
| 327 | state: settledTurnState, | ||
| 328 | // A running turn's completedAt can only hold a mid-turn | ||
| 329 | // placeholder checkpoint timestamp — the session leaving | ||
| 330 | // "running" is the authoritative turn end. | ||
| 331 | completedAt: event.payload.session.updatedAt, | ||
| 332 | } | ||
| 333 | : thread.latestTurn; | ||
| 334 | |||
| 335 | return { | ||
| 336 | kind: "updated", | ||
| 337 | thread: { | ||
| 338 | ...thread, | ||
| 339 | session: event.payload.session, | ||
| 340 | latestTurn, | ||
| 341 | updatedAt: event.occurredAt, | ||
| 342 | }, | ||
| 343 | }; | ||
| 344 | } | ||
| 345 | |||
| 346 | case "thread.session-stop-requested": | ||
| 347 | return thread.session === null | ||
| 348 | ? { kind: "unchanged" } | ||
| 349 | : { | ||
| 350 | kind: "updated", | ||
| 351 | thread: { | ||
| 352 | ...thread, | ||
| 353 | session: { | ||
| 354 | ...thread.session, | ||
| 355 | status: "stopped", | ||
| 356 | activeTurnId: null, | ||
| 357 | updatedAt: event.payload.createdAt, | ||
| 358 | }, | ||
| 359 | updatedAt: event.occurredAt, | ||
| 360 | }, | ||
| 361 | }; | ||
| 362 | |||
| 363 | // ── Proposed plans ────────────────────────────────────────────── | ||
| 364 | case "thread.proposed-plan-upserted": { | ||
| 365 | const proposedPlan = event.payload.proposedPlan; | ||
| 366 | |||
| 367 | const proposedPlans = pipe( | ||
| 368 | thread.proposedPlans, | ||
| 369 | Arr.filter((entry) => entry.id !== proposedPlan.id), | ||
| 370 | Arr.append(proposedPlan), | ||
| 371 | Arr.sort(proposedPlanOrder), | ||
| 372 | Arr.takeRight(limits.maxProposedPlans), | ||
| 373 | ); | ||
| 374 | |||
| 375 | return { | ||
| 376 | kind: "updated", | ||
| 377 | thread: { ...thread, proposedPlans, updatedAt: event.occurredAt }, | ||
| 378 | }; | ||
| 379 | } | ||
| 380 | |||
| 381 | // ── Checkpoints / turn diffs ──────────────────────────────────── | ||
| 382 | case "thread.turn-diff-completed": { | ||
| 383 | const checkpoint: OrchestrationCheckpointSummary = { | ||
| 384 | turnId: event.payload.turnId, | ||
| 385 | checkpointTurnCount: event.payload.checkpointTurnCount, | ||
| 386 | checkpointRef: event.payload.checkpointRef, | ||
| 387 | status: event.payload.status, | ||
| 388 | files: event.payload.files, | ||
| 389 | assistantMessageId: event.payload.assistantMessageId, | ||
| 390 | completedAt: event.payload.completedAt, | ||
| 391 | }; | ||
| 392 | |||
| 393 | const existing = thread.checkpoints.find((entry) => entry.turnId === checkpoint.turnId); | ||
| 394 | // Don't overwrite a non-missing checkpoint with a missing one. | ||
| 395 | if (existing && existing.status !== "missing" && checkpoint.status === "missing") { | ||
| 396 | return { kind: "unchanged" }; | ||
| 397 | } | ||
| 398 | |||
| 399 | const checkpoints = pipe( | ||
| 400 | thread.checkpoints, | ||
| 401 | Arr.filter((entry) => entry.turnId !== checkpoint.turnId), | ||
| 402 | Arr.append(checkpoint), | ||
| 403 | Arr.sort(checkpointOrder), | ||
| 404 | Arr.takeRight(limits.maxCheckpoints), | ||
| 405 | ); | ||
| 406 | |||
| 407 | // Mid-turn diff updates produce placeholder checkpoints; record the | ||
| 408 | // checkpoint, but don't settle a turn its session is still running. | ||
| 409 | const diffTurnStillRunning = | ||
| 410 | thread.session?.status === "running" && | ||
| 411 | thread.session.activeTurnId === event.payload.turnId; | ||
| 412 | const latestTurn = | ||
| 413 | !diffTurnStillRunning && | ||
| 414 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | ||
| 415 | ? { | ||
| 416 | turnId: event.payload.turnId, | ||
| 417 | state: checkpointStatusToTurnState(event.payload.status), | ||
| 418 | requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, | ||
| 419 | startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, | ||
| 420 | completedAt: event.payload.completedAt, | ||
| 421 | assistantMessageId: event.payload.assistantMessageId, | ||
| 422 | } | ||
| 423 | : thread.latestTurn; | ||
| 424 | |||
| 425 | return { | ||
| 426 | kind: "updated", | ||
| 427 | thread: { ...thread, checkpoints, latestTurn, updatedAt: event.occurredAt }, | ||
| 428 | }; | ||
| 429 | } | ||
| 430 | |||
| 431 | // ── Revert ────────────────────────────────────────────────────── | ||
| 432 | case "thread.reverted": { | ||
| 433 | const checkpoints = pipe( | ||
| 434 | thread.checkpoints, | ||
| 435 | Arr.filter( | ||
| 436 | (entry) => | ||
| 437 | entry.checkpointTurnCount !== undefined && | ||
| 438 | entry.checkpointTurnCount <= event.payload.turnCount, | ||
| 439 | ), | ||
| 440 | Arr.sort(checkpointOrder), | ||
| 441 | Arr.takeRight(limits.maxCheckpoints), | ||
| 442 | ); | ||
| 443 | |||
| 444 | const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); | ||
| 445 | const messages = pipe( | ||
| 446 | retainMessagesAfterRevert(thread.messages, retainedTurnIds), | ||
| 447 | Arr.takeRight(limits.maxMessages), | ||
| 448 | ); | ||
| 449 | const proposedPlans = pipe( | ||
| 450 | thread.proposedPlans, | ||
| 451 | Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), | ||
| 452 | Arr.takeRight(limits.maxProposedPlans), | ||
| 453 | ); | ||
| 454 | const activities = pipe( | ||
| 455 | thread.activities, | ||
| 456 | Arr.filter((activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId)), | ||
| 457 | ); | ||
| 458 | const latestCheckpoint = checkpoints.at(-1) ?? null; | ||
| 459 | |||
| 460 | return { | ||
| 461 | kind: "updated", | ||
| 462 | thread: { | ||
| 463 | ...thread, | ||
| 464 | checkpoints, | ||
| 465 | messages, | ||
| 466 | proposedPlans, | ||
| 467 | activities, | ||
| 468 | latestTurn: | ||
| 469 | latestCheckpoint === null | ||
| 470 | ? null | ||
| 471 | : { | ||
| 472 | turnId: latestCheckpoint.turnId, | ||
| 473 | state: checkpointStatusToTurnState( | ||
| 474 | latestCheckpoint.status as "ready" | "missing" | "error", | ||
| 475 | ), | ||
| 476 | requestedAt: latestCheckpoint.completedAt, | ||
| 477 | startedAt: latestCheckpoint.completedAt, | ||
| 478 | completedAt: latestCheckpoint.completedAt, | ||
| 479 | assistantMessageId: latestCheckpoint.assistantMessageId ?? null, | ||
| 480 | }, | ||
| 481 | updatedAt: event.occurredAt, | ||
| 482 | }, | ||
| 483 | }; | ||
| 484 | } | ||
| 485 | |||
| 486 | // ── Activities ────────────────────────────────────────────────── | ||
| 487 | case "thread.activity-appended": { | ||
| 488 | const activities = pipe( | ||
| 489 | thread.activities, | ||
| 490 | Arr.filter((activity) => activity.id !== event.payload.activity.id), | ||
| 491 | Arr.append(event.payload.activity), | ||
| 492 | Arr.sort(activityOrder), | ||
| 493 | Arr.takeRight(limits.maxActivities), | ||
| 494 | ); | ||
| 495 | |||
| 496 | return { | ||
| 497 | kind: "updated", | ||
| 498 | thread: { ...thread, activities, updatedAt: event.occurredAt }, | ||
| 499 | }; | ||
| 500 | } | ||
| 501 | |||
| 502 | // ── Events that don't mutate thread state directly ────────────── | ||
| 503 | case "thread.approval-response-requested": | ||
| 504 | case "thread.user-input-response-requested": | ||
| 505 | case "thread.checkpoint-revert-requested": | ||
| 506 | return { kind: "unchanged" }; | ||
| 507 | } | ||
| 508 | |||
| 509 | // Forward-compatible: ignore unrecognized event types. | ||
| 510 | return { kind: "unchanged" }; | ||
| 511 | } | ||
| 512 | |||
| 513 | // ── Helpers ────────────────────────────────────────────────────────── | ||
| 514 | |||
| 515 | /** | ||
| 516 | * Turn state to settle a still-running latest turn with when its session | ||
| 517 | * leaves the "running" status, or null while the session is (re)starting or | ||
| 518 | * running and the turn must stay unsettled. | ||
| 519 | */ | ||
| 520 | function settledTurnStateForSessionStatus( | ||
| 521 | status: OrchestrationSession["status"], | ||
| 522 | ): "completed" | "interrupted" | "error" | null { | ||
| 523 | switch (status) { | ||
| 524 | case "idle": | ||
| 525 | case "ready": | ||
| 526 | return "completed"; | ||
| 527 | case "error": | ||
| 528 | return "error"; | ||
| 529 | case "interrupted": | ||
| 530 | case "stopped": | ||
| 531 | return "interrupted"; | ||
| 532 | case "starting": | ||
| 533 | case "running": | ||
| 534 | return null; | ||
| 535 | } | ||
| 536 | } | ||
| 537 | |||
| 538 | function checkpointStatusToTurnState( | ||
| 539 | status: "ready" | "missing" | "error", | ||
| 540 | ): OrchestrationLatestTurn["state"] { | ||
| 541 | switch (status) { | ||
| 542 | case "ready": | ||
| 543 | return "completed"; | ||
| 544 | case "error": | ||
| 545 | return "error"; | ||
| 546 | case "missing": | ||
| 547 | return "completed"; | ||
| 548 | } | ||
| 549 | } | ||
| 550 | |||
| 551 | function rebindCheckpointAssistantMessage( | ||
| 552 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, | ||
| 553 | turnId: TurnId, | ||
| 554 | messageId: MessageId, | ||
| 555 | ): OrchestrationCheckpointSummary[] { | ||
| 556 | return Arr.map(checkpoints, (entry) => | ||
| 557 | entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, | ||
| 558 | ); | ||
| 559 | } | ||
| 560 | |||
| 561 | function retainMessagesAfterRevert( | ||
| 562 | messages: ReadonlyArray<OrchestrationMessage>, | ||
| 563 | retainedTurnIds: ReadonlySet<string>, | ||
| 564 | ): OrchestrationMessage[] { | ||
| 565 | // Keep messages that belong to a retained turn, plus system messages and | ||
| 566 | // messages without a turn binding (pre-turn-0 user messages). | ||
| 567 | return Arr.filter(messages, (message) => { | ||
| 568 | if (message.role === "system") { | ||
| 569 | return true; | ||
| 570 | } | ||
| 571 | if (message.turnId === null) { | ||
| 572 | return true; | ||
| 573 | } | ||
| 574 | return retainedTurnIds.has(message.turnId); | ||
| 575 | }); | ||
| 576 | } | ||
packages/client-runtime/src/threadDetailState.test.ts deleted-322| ... | @@ -1,322 +0,0 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | EventId, | ||
| 6 | EnvironmentId, | ||
| 7 | MessageId, | ||
| 8 | ProjectId, | ||
| 9 | ProviderInstanceId, | ||
| 10 | ThreadId, | ||
| 11 | TurnId, | ||
| 12 | type OrchestrationThread, | ||
| 13 | type OrchestrationThreadStreamItem, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import { createThreadDetailManager, type ThreadDetailClient } from "./threadDetailState.ts"; | ||
| 17 | |||
| 18 | let atomRegistry = AtomRegistry.make(); | ||
| 19 | |||
| 20 | function resetAtomRegistry() { | ||
| 21 | atomRegistry.dispose(); | ||
| 22 | atomRegistry = AtomRegistry.make(); | ||
| 23 | } | ||
| 24 | |||
| 25 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | ||
| 26 | listeners.add(listener); | ||
| 27 | return () => { | ||
| 28 | listeners.delete(listener); | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | const baseEventFields = { | ||
| 33 | eventId: EventId.make("event-1"), | ||
| 34 | commandId: null, | ||
| 35 | causationEventId: null, | ||
| 36 | correlationId: null, | ||
| 37 | metadata: {}, | ||
| 38 | } as const; | ||
| 39 | |||
| 40 | const BASE_THREAD: OrchestrationThread = { | ||
| 41 | id: ThreadId.make("thread-1"), | ||
| 42 | projectId: ProjectId.make("project-1"), | ||
| 43 | title: "Test Thread", | ||
| 44 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 45 | runtimeMode: "full-access", | ||
| 46 | interactionMode: "default", | ||
| 47 | branch: null, | ||
| 48 | worktreePath: null, | ||
| 49 | latestTurn: null, | ||
| 50 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 51 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 52 | archivedAt: null, | ||
| 53 | deletedAt: null, | ||
| 54 | messages: [], | ||
| 55 | proposedPlans: [], | ||
| 56 | activities: [], | ||
| 57 | checkpoints: [], | ||
| 58 | session: null, | ||
| 59 | }; | ||
| 60 | |||
| 61 | const TARGET = { | ||
| 62 | environmentId: EnvironmentId.make("env-local"), | ||
| 63 | threadId: ThreadId.make("thread-1"), | ||
| 64 | } as const; | ||
| 65 | |||
| 66 | function createMockClient(): { | ||
| 67 | client: ThreadDetailClient; | ||
| 68 | listeners: Set<(event: OrchestrationThreadStreamItem) => void>; | ||
| 69 | emit: (event: OrchestrationThreadStreamItem) => void; | ||
| 70 | } { | ||
| 71 | const listeners = new Set<(event: OrchestrationThreadStreamItem) => void>(); | ||
| 72 | const client: ThreadDetailClient = { | ||
| 73 | subscribeThread: vi.fn((_input, listener: (event: OrchestrationThreadStreamItem) => void) => | ||
| 74 | registerListener(listeners, listener), | ||
| 75 | ), | ||
| 76 | }; | ||
| 77 | |||
| 78 | return { | ||
| 79 | client, | ||
| 80 | listeners, | ||
| 81 | emit: (event) => { | ||
| 82 | for (const listener of listeners) { | ||
| 83 | listener(event); | ||
| 84 | } | ||
| 85 | }, | ||
| 86 | }; | ||
| 87 | } | ||
| 88 | |||
| 89 | describe("createThreadDetailManager", () => { | ||
| 90 | afterEach(() => { | ||
| 91 | vi.useRealTimers(); | ||
| 92 | resetAtomRegistry(); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("starts in a pending state when watching", () => { | ||
| 96 | const { client } = createMockClient(); | ||
| 97 | const manager = createThreadDetailManager({ | ||
| 98 | getRegistry: () => atomRegistry, | ||
| 99 | getClient: () => null, | ||
| 100 | }); | ||
| 101 | |||
| 102 | manager.watch(TARGET, client); | ||
| 103 | |||
| 104 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 105 | data: null, | ||
| 106 | error: null, | ||
| 107 | isPending: true, | ||
| 108 | isDeleted: false, | ||
| 109 | }); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it("applies snapshots and incremental events", () => { | ||
| 113 | const { client, emit } = createMockClient(); | ||
| 114 | const manager = createThreadDetailManager({ | ||
| 115 | getRegistry: () => atomRegistry, | ||
| 116 | getClient: () => null, | ||
| 117 | }); | ||
| 118 | |||
| 119 | const release = manager.watch(TARGET, client); | ||
| 120 | |||
| 121 | emit({ | ||
| 122 | kind: "snapshot", | ||
| 123 | snapshot: { | ||
| 124 | snapshotSequence: 1, | ||
| 125 | thread: BASE_THREAD, | ||
| 126 | }, | ||
| 127 | }); | ||
| 128 | |||
| 129 | emit({ | ||
| 130 | kind: "event", | ||
| 131 | event: { | ||
| 132 | ...baseEventFields, | ||
| 133 | sequence: 2, | ||
| 134 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 135 | aggregateKind: "thread", | ||
| 136 | aggregateId: ThreadId.make("thread-1"), | ||
| 137 | type: "thread.message-sent", | ||
| 138 | payload: { | ||
| 139 | threadId: ThreadId.make("thread-1"), | ||
| 140 | turnId: TurnId.make("turn-1"), | ||
| 141 | messageId: MessageId.make("message-1"), | ||
| 142 | role: "assistant", | ||
| 143 | text: "hello", | ||
| 144 | streaming: false, | ||
| 145 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 146 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 147 | }, | ||
| 148 | } as any, | ||
| 149 | }); | ||
| 150 | |||
| 151 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 152 | data: { | ||
| 153 | ...BASE_THREAD, | ||
| 154 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 155 | latestTurn: { | ||
| 156 | turnId: TurnId.make("turn-1"), | ||
| 157 | state: "completed", | ||
| 158 | requestedAt: "2026-04-01T01:00:00.000Z", | ||
| 159 | startedAt: "2026-04-01T01:00:00.000Z", | ||
| 160 | completedAt: "2026-04-01T01:00:00.000Z", | ||
| 161 | assistantMessageId: MessageId.make("message-1"), | ||
| 162 | }, | ||
| 163 | messages: [ | ||
| 164 | { | ||
| 165 | id: MessageId.make("message-1"), | ||
| 166 | role: "assistant", | ||
| 167 | text: "hello", | ||
| 168 | turnId: TurnId.make("turn-1"), | ||
| 169 | streaming: false, | ||
| 170 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 171 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 172 | }, | ||
| 173 | ], | ||
| 174 | }, | ||
| 175 | error: null, | ||
| 176 | isPending: false, | ||
| 177 | isDeleted: false, | ||
| 178 | }); | ||
| 179 | |||
| 180 | release(); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("marks threads as deleted when the stream deletes them", () => { | ||
| 184 | const { client, emit } = createMockClient(); | ||
| 185 | const manager = createThreadDetailManager({ | ||
| 186 | getRegistry: () => atomRegistry, | ||
| 187 | getClient: () => null, | ||
| 188 | }); | ||
| 189 | |||
| 190 | const release = manager.watch(TARGET, client); | ||
| 191 | |||
| 192 | emit({ | ||
| 193 | kind: "snapshot", | ||
| 194 | snapshot: { | ||
| 195 | snapshotSequence: 1, | ||
| 196 | thread: BASE_THREAD, | ||
| 197 | }, | ||
| 198 | }); | ||
| 199 | emit({ | ||
| 200 | kind: "event", | ||
| 201 | event: { | ||
| 202 | ...baseEventFields, | ||
| 203 | sequence: 3, | ||
| 204 | occurredAt: "2026-04-01T01:10:00.000Z", | ||
| 205 | aggregateKind: "thread", | ||
| 206 | aggregateId: ThreadId.make("thread-1"), | ||
| 207 | type: "thread.deleted", | ||
| 208 | payload: { | ||
| 209 | threadId: ThreadId.make("thread-1"), | ||
| 210 | deletedAt: "2026-04-01T01:10:00.000Z", | ||
| 211 | }, | ||
| 212 | } as any, | ||
| 213 | }); | ||
| 214 | |||
| 215 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 216 | data: null, | ||
| 217 | error: null, | ||
| 218 | isPending: false, | ||
| 219 | isDeleted: true, | ||
| 220 | }); | ||
| 221 | |||
| 222 | release(); | ||
| 223 | }); | ||
| 224 | |||
| 225 | it("waits for delayed client registration when subscribeClientChanges is configured", () => { | ||
| 226 | const connectionListeners = new Set<() => void>(); | ||
| 227 | const clients = new Map<string, ReturnType<typeof createMockClient>>(); | ||
| 228 | const manager = createThreadDetailManager({ | ||
| 229 | getRegistry: () => atomRegistry, | ||
| 230 | getClient: (environmentId) => clients.get(environmentId)?.client ?? null, | ||
| 231 | getClientIdentity: (environmentId) => (clients.has(environmentId) ? environmentId : null), | ||
| 232 | subscribeClientChanges: (listener) => { | ||
| 233 | connectionListeners.add(listener); | ||
| 234 | return () => connectionListeners.delete(listener); | ||
| 235 | }, | ||
| 236 | }); | ||
| 237 | |||
| 238 | const release = manager.watch(TARGET); | ||
| 239 | expect(manager.getSnapshot(TARGET).isPending).toBe(true); | ||
| 240 | |||
| 241 | const mock = createMockClient(); | ||
| 242 | clients.set("env-local", mock); | ||
| 243 | for (const listener of connectionListeners) { | ||
| 244 | listener(); | ||
| 245 | } | ||
| 246 | |||
| 247 | mock.emit({ | ||
| 248 | kind: "snapshot", | ||
| 249 | snapshot: { | ||
| 250 | snapshotSequence: 1, | ||
| 251 | thread: BASE_THREAD, | ||
| 252 | }, | ||
| 253 | }); | ||
| 254 | |||
| 255 | expect(manager.getSnapshot(TARGET).data?.id).toBe(ThreadId.make("thread-1")); | ||
| 256 | |||
| 257 | release(); | ||
| 258 | }); | ||
| 259 | |||
| 260 | it("evicts idle subscriptions after the configured ttl", () => { | ||
| 261 | vi.useFakeTimers(); | ||
| 262 | const mock = createMockClient(); | ||
| 263 | const manager = createThreadDetailManager({ | ||
| 264 | getRegistry: () => atomRegistry, | ||
| 265 | getClient: () => mock.client, | ||
| 266 | retention: { | ||
| 267 | idleTtlMs: 60_000, | ||
| 268 | maxRetainedEntries: 10, | ||
| 269 | }, | ||
| 270 | }); | ||
| 271 | |||
| 272 | const release = manager.watch(TARGET); | ||
| 273 | expect(mock.listeners.size).toBe(1); | ||
| 274 | |||
| 275 | release(); | ||
| 276 | expect(mock.listeners.size).toBe(1); | ||
| 277 | |||
| 278 | vi.advanceTimersByTime(60_000); | ||
| 279 | expect(mock.listeners.size).toBe(0); | ||
| 280 | }); | ||
| 281 | |||
| 282 | it("keeps non-idle threads warm when the retention policy says to", () => { | ||
| 283 | vi.useFakeTimers(); | ||
| 284 | const mock = createMockClient(); | ||
| 285 | const manager = createThreadDetailManager({ | ||
| 286 | getRegistry: () => atomRegistry, | ||
| 287 | getClient: () => mock.client, | ||
| 288 | retention: { | ||
| 289 | idleTtlMs: 60_000, | ||
| 290 | maxRetainedEntries: 10, | ||
| 291 | shouldKeepWarm: (_target, state) => state.data?.session?.status === "running", | ||
| 292 | }, | ||
| 293 | }); | ||
| 294 | |||
| 295 | const release = manager.watch(TARGET); | ||
| 296 | mock.emit({ | ||
| 297 | kind: "snapshot", | ||
| 298 | snapshot: { | ||
| 299 | snapshotSequence: 1, | ||
| 300 | thread: { | ||
| 301 | ...BASE_THREAD, | ||
| 302 | session: { | ||
| 303 | threadId: ThreadId.make("thread-1"), | ||
| 304 | status: "running", | ||
| 305 | providerName: "codex", | ||
| 306 | runtimeMode: "full-access", | ||
| 307 | activeTurnId: TurnId.make("turn-1"), | ||
| 308 | lastError: null, | ||
| 309 | updatedAt: "2026-04-01T00:10:00.000Z", | ||
| 310 | }, | ||
| 311 | }, | ||
| 312 | }, | ||
| 313 | }); | ||
| 314 | |||
| 315 | release(); | ||
| 316 | vi.advanceTimersByTime(60_000); | ||
| 317 | |||
| 318 | expect(mock.listeners.size).toBe(1); | ||
| 319 | manager.reset(); | ||
| 320 | expect(mock.listeners.size).toBe(0); | ||
| 321 | }); | ||
| 322 | }); | ||
packages/client-runtime/src/threadDetailState.ts deleted-444| ... | @@ -1,444 +0,0 @@ | ||
| 1 | import { pipe } from "effect/Function"; | ||
| 2 | import * as Order from "effect/Order"; | ||
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import * as Duration from "effect/Duration"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Fiber from "effect/Fiber"; | ||
| 8 | import type { | ||
| 9 | OrchestrationThread, | ||
| 10 | OrchestrationThreadStreamItem, | ||
| 11 | EnvironmentId, | ||
| 12 | ThreadId as ThreadIdType, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | DEFAULT_THREAD_DETAIL_LIMITS, | ||
| 18 | applyThreadDetailEvent, | ||
| 19 | type ThreadDetailRetentionLimits, | ||
| 20 | } from "./threadDetailReducer.ts"; | ||
| 21 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 22 | |||
| 23 | export interface ThreadDetailState { | ||
| 24 | readonly data: OrchestrationThread | null; | ||
| 25 | readonly error: string | null; | ||
| 26 | readonly isPending: boolean; | ||
| 27 | readonly isDeleted: boolean; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface ThreadDetailTarget { | ||
| 31 | readonly environmentId: EnvironmentId | null; | ||
| 32 | readonly threadId: ThreadIdType | null; | ||
| 33 | } | ||
| 34 | |||
| 35 | export type ThreadDetailClient = Pick<WsRpcClient["orchestration"], "subscribeThread">; | ||
| 36 | |||
| 37 | export interface ThreadDetailRetentionPolicy { | ||
| 38 | readonly idleTtlMs: number; | ||
| 39 | readonly maxRetainedEntries: number; | ||
| 40 | readonly shouldKeepWarm?: ( | ||
| 41 | target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, | ||
| 42 | state: ThreadDetailState, | ||
| 43 | ) => boolean; | ||
| 44 | } | ||
| 45 | |||
| 46 | interface ThreadDetailEntry { | ||
| 47 | readonly target: { | ||
| 48 | readonly environmentId: EnvironmentId; | ||
| 49 | readonly threadId: ThreadIdType; | ||
| 50 | }; | ||
| 51 | watcherCount: number; | ||
| 52 | retainCount: number; | ||
| 53 | teardown: () => void; | ||
| 54 | lastAccessedAt: number; | ||
| 55 | evictionFiber: Fiber.Fiber<void, never> | null; | ||
| 56 | } | ||
| 57 | |||
| 58 | const NOOP: () => void = () => undefined; | ||
| 59 | const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); | ||
| 60 | |||
| 61 | function clearEntryEviction(entry: ThreadDetailEntry): void { | ||
| 62 | if (entry.evictionFiber !== null) { | ||
| 63 | Effect.runFork(Fiber.interrupt(entry.evictionFiber)); | ||
| 64 | entry.evictionFiber = null; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | export const EMPTY_THREAD_DETAIL_STATE = Object.freeze<ThreadDetailState>({ | ||
| 69 | data: null, | ||
| 70 | error: null, | ||
| 71 | isPending: false, | ||
| 72 | isDeleted: false, | ||
| 73 | }); | ||
| 74 | |||
| 75 | const INITIAL_THREAD_DETAIL_STATE = Object.freeze<ThreadDetailState>({ | ||
| 76 | data: null, | ||
| 77 | error: null, | ||
| 78 | isPending: true, | ||
| 79 | isDeleted: false, | ||
| 80 | }); | ||
| 81 | |||
| 82 | const knownThreadDetailKeys = new Set<string>(); | ||
| 83 | |||
| 84 | export const threadDetailStateAtom = Atom.family((key: string) => { | ||
| 85 | knownThreadDetailKeys.add(key); | ||
| 86 | return Atom.make(INITIAL_THREAD_DETAIL_STATE).pipe( | ||
| 87 | Atom.keepAlive, | ||
| 88 | Atom.withLabel(`thread-detail:${key}`), | ||
| 89 | ); | ||
| 90 | }); | ||
| 91 | |||
| 92 | export const EMPTY_THREAD_DETAIL_ATOM = Atom.make(EMPTY_THREAD_DETAIL_STATE).pipe( | ||
| 93 | Atom.keepAlive, | ||
| 94 | Atom.withLabel("thread-detail:null"), | ||
| 95 | ); | ||
| 96 | |||
| 97 | export function getThreadDetailTargetKey(target: ThreadDetailTarget): string | null { | ||
| 98 | if (target.environmentId === null || target.threadId === null) { | ||
| 99 | return null; | ||
| 100 | } | ||
| 101 | |||
| 102 | return `${target.environmentId}:${target.threadId}`; | ||
| 103 | } | ||
| 104 | |||
| 105 | export interface ThreadDetailManagerConfig { | ||
| 106 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 107 | readonly getClient: (environmentId: EnvironmentId) => ThreadDetailClient | null; | ||
| 108 | readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; | ||
| 109 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 110 | readonly limits?: ThreadDetailRetentionLimits; | ||
| 111 | readonly retention?: ThreadDetailRetentionPolicy; | ||
| 112 | } | ||
| 113 | |||
| 114 | export function createThreadDetailManager(config: ThreadDetailManagerConfig) { | ||
| 115 | const entries = new Map<string, ThreadDetailEntry>(); | ||
| 116 | |||
| 117 | function getSnapshot(target: ThreadDetailTarget): ThreadDetailState { | ||
| 118 | const targetKey = getThreadDetailTargetKey(target); | ||
| 119 | if (targetKey === null) { | ||
| 120 | return EMPTY_THREAD_DETAIL_STATE; | ||
| 121 | } | ||
| 122 | |||
| 123 | return config.getRegistry().get(threadDetailStateAtom(targetKey)); | ||
| 124 | } | ||
| 125 | |||
| 126 | function setState(targetKey: string, nextState: ThreadDetailState): void { | ||
| 127 | config.getRegistry().set(threadDetailStateAtom(targetKey), nextState); | ||
| 128 | reconcileRetention(targetKey); | ||
| 129 | } | ||
| 130 | |||
| 131 | function markPending(targetKey: string): void { | ||
| 132 | const current = config.getRegistry().get(threadDetailStateAtom(targetKey)); | ||
| 133 | setState(targetKey, { | ||
| 134 | ...current, | ||
| 135 | error: null, | ||
| 136 | isPending: true, | ||
| 137 | }); | ||
| 138 | } | ||
| 139 | |||
| 140 | function setData(targetKey: string, thread: OrchestrationThread): void { | ||
| 141 | setState(targetKey, { | ||
| 142 | data: thread, | ||
| 143 | error: null, | ||
| 144 | isPending: false, | ||
| 145 | isDeleted: false, | ||
| 146 | }); | ||
| 147 | } | ||
| 148 | |||
| 149 | function setDeleted(targetKey: string): void { | ||
| 150 | setState(targetKey, { | ||
| 151 | data: null, | ||
| 152 | error: null, | ||
| 153 | isPending: false, | ||
| 154 | isDeleted: true, | ||
| 155 | }); | ||
| 156 | } | ||
| 157 | |||
| 158 | function shouldKeepWarm(entry: ThreadDetailEntry): boolean { | ||
| 159 | return config.retention?.shouldKeepWarm?.(entry.target, getSnapshot(entry.target)) ?? false; | ||
| 160 | } | ||
| 161 | |||
| 162 | function disposeEntry(targetKey: string): void { | ||
| 163 | const entry = entries.get(targetKey); | ||
| 164 | if (!entry) { | ||
| 165 | return; | ||
| 166 | } | ||
| 167 | |||
| 168 | clearEntryEviction(entry); | ||
| 169 | entry.teardown(); | ||
| 170 | entries.delete(targetKey); | ||
| 171 | } | ||
| 172 | |||
| 173 | function evictIdleEntriesToCapacity(): void { | ||
| 174 | const retention = config.retention; | ||
| 175 | if (!retention || entries.size <= retention.maxRetainedEntries) { | ||
| 176 | return; | ||
| 177 | } | ||
| 178 | |||
| 179 | const idleEntries = pipe( | ||
| 180 | Arr.fromIterable(entries), | ||
| 181 | Arr.filter( | ||
| 182 | ([, entry]) => | ||
| 183 | entry.watcherCount === 0 && entry.retainCount === 0 && !shouldKeepWarm(entry), | ||
| 184 | ), | ||
| 185 | Arr.sortWith(([, e]) => e.lastAccessedAt, Order.Number), | ||
| 186 | ); | ||
| 187 | |||
| 188 | for (const [targetKey] of idleEntries) { | ||
| 189 | if (entries.size <= retention.maxRetainedEntries) { | ||
| 190 | return; | ||
| 191 | } | ||
| 192 | disposeEntry(targetKey); | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | function scheduleEviction(targetKey: string, entry: ThreadDetailEntry): void { | ||
| 197 | const retention = config.retention; | ||
| 198 | clearEntryEviction(entry); | ||
| 199 | |||
| 200 | if (!retention) { | ||
| 201 | disposeEntry(targetKey); | ||
| 202 | return; | ||
| 203 | } | ||
| 204 | |||
| 205 | if (retention.idleTtlMs <= 0) { | ||
| 206 | disposeEntry(targetKey); | ||
| 207 | return; | ||
| 208 | } | ||
| 209 | |||
| 210 | entry.evictionFiber = Effect.runFork( | ||
| 211 | Effect.sleep(Duration.millis(retention.idleTtlMs)).pipe( | ||
| 212 | Effect.andThen( | ||
| 213 | Effect.sync(() => { | ||
| 214 | const current = entries.get(targetKey); | ||
| 215 | if (!current) { | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | |||
| 219 | current.evictionFiber = null; | ||
| 220 | if (current.watcherCount > 0 || current.retainCount > 0 || shouldKeepWarm(current)) { | ||
| 221 | return; | ||
| 222 | } | ||
| 223 | |||
| 224 | disposeEntry(targetKey); | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | ), | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | |||
| 231 | function reconcileRetention(targetKey: string): void { | ||
| 232 | const entry = entries.get(targetKey); | ||
| 233 | if (!entry) { | ||
| 234 | return; | ||
| 235 | } | ||
| 236 | |||
| 237 | clearEntryEviction(entry); | ||
| 238 | if (entry.watcherCount > 0 || entry.retainCount > 0 || shouldKeepWarm(entry)) { | ||
| 239 | return; | ||
| 240 | } | ||
| 241 | |||
| 242 | scheduleEviction(targetKey, entry); | ||
| 243 | evictIdleEntriesToCapacity(); | ||
| 244 | } | ||
| 245 | |||
| 246 | function applyStreamItem( | ||
| 247 | targetKey: string, | ||
| 248 | item: OrchestrationThreadStreamItem, | ||
| 249 | threadId: ThreadIdType, | ||
| 250 | ): void { | ||
| 251 | if (item.kind === "snapshot") { | ||
| 252 | setData(targetKey, item.snapshot.thread); | ||
| 253 | return; | ||
| 254 | } | ||
| 255 | |||
| 256 | const current = getSnapshot({ | ||
| 257 | environmentId: entries.get(targetKey)?.target.environmentId ?? null, | ||
| 258 | threadId, | ||
| 259 | }).data; | ||
| 260 | |||
| 261 | if (current === null) { | ||
| 262 | if (item.event.type === "thread.deleted") { | ||
| 263 | setDeleted(targetKey); | ||
| 264 | } | ||
| 265 | return; | ||
| 266 | } | ||
| 267 | |||
| 268 | const result = applyThreadDetailEvent( | ||
| 269 | current, | ||
| 270 | item.event, | ||
| 271 | config.limits ?? DEFAULT_THREAD_DETAIL_LIMITS, | ||
| 272 | ); | ||
| 273 | |||
| 274 | if (result.kind === "updated") { | ||
| 275 | setData(targetKey, result.thread); | ||
| 276 | return; | ||
| 277 | } | ||
| 278 | |||
| 279 | if (result.kind === "deleted") { | ||
| 280 | setDeleted(targetKey); | ||
| 281 | } | ||
| 282 | } | ||
| 283 | |||
| 284 | function subscribeStream( | ||
| 285 | targetKey: string, | ||
| 286 | target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, | ||
| 287 | client: ThreadDetailClient, | ||
| 288 | ): () => void { | ||
| 289 | markPending(targetKey); | ||
| 290 | return client.subscribeThread( | ||
| 291 | { threadId: target.threadId }, | ||
| 292 | (item) => applyStreamItem(targetKey, item, target.threadId), | ||
| 293 | { | ||
| 294 | onResubscribe: () => markPending(targetKey), | ||
| 295 | }, | ||
| 296 | ); | ||
| 297 | } | ||
| 298 | |||
| 299 | function createDynamicSubscription( | ||
| 300 | targetKey: string, | ||
| 301 | target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, | ||
| 302 | ): () => void { | ||
| 303 | let currentIdentity: string | null = null; | ||
| 304 | let currentUnsub = NOOP; | ||
| 305 | |||
| 306 | const sync = () => { | ||
| 307 | const client = config.getClient(target.environmentId); | ||
| 308 | const identity = client | ||
| 309 | ? (config.getClientIdentity?.(target.environmentId) ?? target.environmentId) | ||
| 310 | : null; | ||
| 311 | |||
| 312 | if (!client || identity === null) { | ||
| 313 | if (currentIdentity !== null) { | ||
| 314 | currentUnsub(); | ||
| 315 | currentUnsub = NOOP; | ||
| 316 | currentIdentity = null; | ||
| 317 | } | ||
| 318 | markPending(targetKey); | ||
| 319 | return; | ||
| 320 | } | ||
| 321 | |||
| 322 | if (currentIdentity === identity) { | ||
| 323 | return; | ||
| 324 | } | ||
| 325 | |||
| 326 | currentUnsub(); | ||
| 327 | currentIdentity = identity; | ||
| 328 | currentUnsub = subscribeStream(targetKey, target, client); | ||
| 329 | }; | ||
| 330 | |||
| 331 | const unsubChanges = config.subscribeClientChanges!(sync); | ||
| 332 | sync(); | ||
| 333 | |||
| 334 | return () => { | ||
| 335 | unsubChanges(); | ||
| 336 | currentUnsub(); | ||
| 337 | }; | ||
| 338 | } | ||
| 339 | |||
| 340 | function acquire( | ||
| 341 | target: ThreadDetailTarget, | ||
| 342 | kind: "watcher" | "retain", | ||
| 343 | client?: ThreadDetailClient, | ||
| 344 | ): () => void { | ||
| 345 | const targetKey = getThreadDetailTargetKey(target); | ||
| 346 | if (targetKey === null || target.environmentId === null || target.threadId === null) { | ||
| 347 | return NOOP; | ||
| 348 | } | ||
| 349 | |||
| 350 | const existing = entries.get(targetKey); | ||
| 351 | if (existing) { | ||
| 352 | clearEntryEviction(existing); | ||
| 353 | existing.lastAccessedAt = nowMs(); | ||
| 354 | if (kind === "watcher") { | ||
| 355 | existing.watcherCount += 1; | ||
| 356 | } else { | ||
| 357 | existing.retainCount += 1; | ||
| 358 | } | ||
| 359 | return () => release(targetKey, kind); | ||
| 360 | } | ||
| 361 | |||
| 362 | let teardown: () => void; | ||
| 363 | const resolvedTarget = { | ||
| 364 | environmentId: target.environmentId, | ||
| 365 | threadId: target.threadId, | ||
| 366 | }; | ||
| 367 | |||
| 368 | if (client) { | ||
| 369 | teardown = subscribeStream(targetKey, resolvedTarget, client); | ||
| 370 | } else if (config.subscribeClientChanges) { | ||
| 371 | teardown = createDynamicSubscription(targetKey, resolvedTarget); | ||
| 372 | } else { | ||
| 373 | const resolved = config.getClient(target.environmentId); | ||
| 374 | if (!resolved) { | ||
| 375 | return NOOP; | ||
| 376 | } | ||
| 377 | teardown = subscribeStream(targetKey, resolvedTarget, resolved); | ||
| 378 | } | ||
| 379 | |||
| 380 | entries.set(targetKey, { | ||
| 381 | target: resolvedTarget, | ||
| 382 | watcherCount: kind === "watcher" ? 1 : 0, | ||
| 383 | retainCount: kind === "retain" ? 1 : 0, | ||
| 384 | teardown, | ||
| 385 | lastAccessedAt: nowMs(), | ||
| 386 | evictionFiber: null, | ||
| 387 | }); | ||
| 388 | evictIdleEntriesToCapacity(); | ||
| 389 | return () => release(targetKey, kind); | ||
| 390 | } | ||
| 391 | |||
| 392 | function release(targetKey: string, kind: "watcher" | "retain"): void { | ||
| 393 | const entry = entries.get(targetKey); | ||
| 394 | if (!entry) { | ||
| 395 | return; | ||
| 396 | } | ||
| 397 | |||
| 398 | if (kind === "watcher") { | ||
| 399 | entry.watcherCount = Math.max(0, entry.watcherCount - 1); | ||
| 400 | } else { | ||
| 401 | entry.retainCount = Math.max(0, entry.retainCount - 1); | ||
| 402 | } | ||
| 403 | entry.lastAccessedAt = nowMs(); | ||
| 404 | reconcileRetention(targetKey); | ||
| 405 | } | ||
| 406 | |||
| 407 | function watch(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { | ||
| 408 | return acquire(target, "watcher", client); | ||
| 409 | } | ||
| 410 | |||
| 411 | function retain(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { | ||
| 412 | return acquire(target, "retain", client); | ||
| 413 | } | ||
| 414 | |||
| 415 | function invalidate(target?: ThreadDetailTarget): void { | ||
| 416 | if (target) { | ||
| 417 | const targetKey = getThreadDetailTargetKey(target); | ||
| 418 | if (targetKey !== null) { | ||
| 419 | disposeEntry(targetKey); | ||
| 420 | config.getRegistry().set(threadDetailStateAtom(targetKey), EMPTY_THREAD_DETAIL_STATE); | ||
| 421 | } | ||
| 422 | return; | ||
| 423 | } | ||
| 424 | |||
| 425 | for (const targetKey of entries.keys()) { | ||
| 426 | disposeEntry(targetKey); | ||
| 427 | } | ||
| 428 | for (const key of knownThreadDetailKeys) { | ||
| 429 | config.getRegistry().set(threadDetailStateAtom(key), EMPTY_THREAD_DETAIL_STATE); | ||
| 430 | } | ||
| 431 | } | ||
| 432 | |||
| 433 | function reset(): void { | ||
| 434 | invalidate(); | ||
| 435 | } | ||
| 436 | |||
| 437 | return { | ||
| 438 | watch, | ||
| 439 | retain, | ||
| 440 | getSnapshot, | ||
| 441 | invalidate, | ||
| 442 | reset, | ||
| 443 | }; | ||
| 444 | } | ||
packages/client-runtime/src/transportError.test.ts deleted-61| ... | @@ -1,61 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError.ts"; | ||
| 4 | |||
| 5 | describe("isTransportConnectionErrorMessage", () => { | ||
| 6 | it("returns true for SocketCloseError", () => { | ||
| 7 | expect(isTransportConnectionErrorMessage("SocketCloseError: connection reset")).toBe(true); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("returns true for SocketOpenError", () => { | ||
| 11 | expect(isTransportConnectionErrorMessage("SocketOpenError: ECONNREFUSED")).toBe(true); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("returns true for React Native disconnected socket errors", () => { | ||
| 15 | expect( | ||
| 16 | isTransportConnectionErrorMessage( | ||
| 17 | "The operation couldn't be completed. Socket is not connected", | ||
| 18 | ), | ||
| 19 | ).toBe(true); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("returns true for the T3 server WebSocket message", () => { | ||
| 23 | expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( | ||
| 24 | true, | ||
| 25 | ); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("returns true for ping timeout", () => { | ||
| 29 | expect(isTransportConnectionErrorMessage("ping timeout")).toBe(true); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("returns false for business logic errors", () => { | ||
| 33 | expect(isTransportConnectionErrorMessage("Thread not found")).toBe(false); | ||
| 34 | expect(isTransportConnectionErrorMessage("Invalid model selection")).toBe(false); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("returns false for null, undefined, and empty strings", () => { | ||
| 38 | expect(isTransportConnectionErrorMessage(null)).toBe(false); | ||
| 39 | expect(isTransportConnectionErrorMessage(undefined)).toBe(false); | ||
| 40 | expect(isTransportConnectionErrorMessage("")).toBe(false); | ||
| 41 | expect(isTransportConnectionErrorMessage(" ")).toBe(false); | ||
| 42 | }); | ||
| 43 | }); | ||
| 44 | |||
| 45 | describe("sanitizeThreadErrorMessage", () => { | ||
| 46 | it("strips transport errors", () => { | ||
| 47 | expect(sanitizeThreadErrorMessage("SocketCloseError: oops")).toBeNull(); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("preserves non-transport errors", () => { | ||
| 51 | expect(sanitizeThreadErrorMessage("Thread not found")).toBe("Thread not found"); | ||
| 52 | expect(sanitizeThreadErrorMessage("Select a base branch before sending.")).toBe( | ||
| 53 | "Select a base branch before sending.", | ||
| 54 | ); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("returns null for null/undefined", () => { | ||
| 58 | expect(sanitizeThreadErrorMessage(null)).toBeNull(); | ||
| 59 | expect(sanitizeThreadErrorMessage(undefined)).toBeNull(); | ||
| 60 | }); | ||
| 61 | }); | ||
packages/client-runtime/src/transportError.ts deleted-34| ... | @@ -1,34 +0,0 @@ | ||
| 1 | const TRANSPORT_ERROR_PATTERNS = [ | ||
| 2 | /\bSocketCloseError\b/i, | ||
| 3 | /\bSocketOpenError\b/i, | ||
| 4 | /\bSocket is not connected\b/i, | ||
| 5 | /Unable to connect to the T3 server WebSocket\./i, | ||
| 6 | /\bping timeout\b/i, | ||
| 7 | ] as const; | ||
| 8 | |||
| 9 | /** | ||
| 10 | * Test whether an error message originates from a transport-level connection | ||
| 11 | * failure (socket close, socket open, ping timeout, etc.) rather than a | ||
| 12 | * business-logic error. | ||
| 13 | */ | ||
| 14 | export function isTransportConnectionErrorMessage(message: string | null | undefined): boolean { | ||
| 15 | if (typeof message !== "string") { | ||
| 16 | return false; | ||
| 17 | } | ||
| 18 | |||
| 19 | const normalizedMessage = message.trim(); | ||
| 20 | if (normalizedMessage.length === 0) { | ||
| 21 | return false; | ||
| 22 | } | ||
| 23 | |||
| 24 | return TRANSPORT_ERROR_PATTERNS.some((pattern) => pattern.test(normalizedMessage)); | ||
| 25 | } | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Strip transport connection errors from user-facing error messages. | ||
| 29 | * Returns `null` for transport errors so the UI can distinguish between | ||
| 30 | * real errors and transient connection issues. | ||
| 31 | */ | ||
| 32 | export function sanitizeThreadErrorMessage(message: string | null | undefined): string | null { | ||
| 33 | return isTransportConnectionErrorMessage(message) ? null : (message ?? null); | ||
| 34 | } | ||
packages/client-runtime/src/vcsActionState.test.ts deleted-292| ... | @@ -1,292 +0,0 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | type GitActionProgressEvent, | ||
| 4 | type GitRunStackedActionResult, | ||
| 5 | type VcsCreateRefResult, | ||
| 6 | type VcsCreateWorktreeResult, | ||
| 7 | type VcsPullResult, | ||
| 8 | type VcsStatusResult, | ||
| 9 | type VcsSwitchRefResult, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 12 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | type VcsActionClient, | ||
| 16 | createVcsActionManager, | ||
| 17 | EMPTY_VCS_ACTION_STATE, | ||
| 18 | } from "./vcsActionState.ts"; | ||
| 19 | |||
| 20 | let atomRegistry = AtomRegistry.make(); | ||
| 21 | |||
| 22 | function resetAtomRegistry() { | ||
| 23 | atomRegistry.dispose(); | ||
| 24 | atomRegistry = AtomRegistry.make(); | ||
| 25 | } | ||
| 26 | |||
| 27 | function createDeferred<T>() { | ||
| 28 | let resolve!: (value: T) => void; | ||
| 29 | let reject!: (reason?: unknown) => void; | ||
| 30 | const promise = new Promise<T>((res, rej) => { | ||
| 31 | resolve = res; | ||
| 32 | reject = rej; | ||
| 33 | }); | ||
| 34 | return { promise, resolve, reject }; | ||
| 35 | } | ||
| 36 | |||
| 37 | const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; | ||
| 38 | |||
| 39 | const BASE_STATUS: VcsStatusResult = { | ||
| 40 | isRepo: true, | ||
| 41 | hasPrimaryRemote: true, | ||
| 42 | isDefaultRef: false, | ||
| 43 | refName: "feature/test", | ||
| 44 | hasWorkingTreeChanges: true, | ||
| 45 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 46 | hasUpstream: true, | ||
| 47 | aheadCount: 0, | ||
| 48 | behindCount: 0, | ||
| 49 | pr: null, | ||
| 50 | }; | ||
| 51 | |||
| 52 | function createPhaseStartedEvent(): Extract<GitActionProgressEvent, { kind: "phase_started" }> { | ||
| 53 | return { | ||
| 54 | actionId: "action-123", | ||
| 55 | cwd: "/repo", | ||
| 56 | action: "commit_push", | ||
| 57 | kind: "phase_started", | ||
| 58 | phase: "commit", | ||
| 59 | label: "Committing...", | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | function createHookStartedEvent(): Extract<GitActionProgressEvent, { kind: "hook_started" }> { | ||
| 64 | return { | ||
| 65 | actionId: "action-123", | ||
| 66 | cwd: "/repo", | ||
| 67 | action: "commit_push", | ||
| 68 | kind: "hook_started", | ||
| 69 | hookName: "post-commit", | ||
| 70 | }; | ||
| 71 | } | ||
| 72 | |||
| 73 | function createHookOutputEvent(): Extract<GitActionProgressEvent, { kind: "hook_output" }> { | ||
| 74 | return { | ||
| 75 | actionId: "action-123", | ||
| 76 | cwd: "/repo", | ||
| 77 | action: "commit_push", | ||
| 78 | kind: "hook_output", | ||
| 79 | hookName: "post-commit", | ||
| 80 | stream: "stdout", | ||
| 81 | text: "hook output", | ||
| 82 | }; | ||
| 83 | } | ||
| 84 | |||
| 85 | function createHookFinishedEvent(): Extract<GitActionProgressEvent, { kind: "hook_finished" }> { | ||
| 86 | return { | ||
| 87 | actionId: "action-123", | ||
| 88 | cwd: "/repo", | ||
| 89 | action: "commit_push", | ||
| 90 | kind: "hook_finished", | ||
| 91 | hookName: "post-commit", | ||
| 92 | exitCode: 0, | ||
| 93 | durationMs: 12, | ||
| 94 | }; | ||
| 95 | } | ||
| 96 | |||
| 97 | function createActionFinishedEvent(): Extract<GitActionProgressEvent, { kind: "action_finished" }> { | ||
| 98 | return { | ||
| 99 | actionId: "action-123", | ||
| 100 | cwd: "/repo", | ||
| 101 | action: "commit_push", | ||
| 102 | kind: "action_finished", | ||
| 103 | result: { | ||
| 104 | action: "commit_push", | ||
| 105 | branch: { status: "skipped_not_requested" }, | ||
| 106 | commit: { status: "created", commitSha: "abc123", subject: "Test commit" }, | ||
| 107 | push: { | ||
| 108 | status: "pushed", | ||
| 109 | branch: "feature/test", | ||
| 110 | upstreamBranch: "origin/feature/test", | ||
| 111 | }, | ||
| 112 | pr: { status: "skipped_not_requested" }, | ||
| 113 | toast: { | ||
| 114 | title: "Done", | ||
| 115 | description: "Action finished", | ||
| 116 | cta: { kind: "none" }, | ||
| 117 | }, | ||
| 118 | } satisfies GitRunStackedActionResult, | ||
| 119 | }; | ||
| 120 | } | ||
| 121 | |||
| 122 | function createMockClient() { | ||
| 123 | const refreshDeferred = createDeferred<VcsStatusResult>(); | ||
| 124 | const pullDeferred = createDeferred<VcsPullResult>(); | ||
| 125 | const switchRefDeferred = createDeferred<VcsSwitchRefResult>(); | ||
| 126 | const createRefDeferred = createDeferred<VcsCreateRefResult>(); | ||
| 127 | const createWorktreeDeferred = createDeferred<VcsCreateWorktreeResult>(); | ||
| 128 | const initDeferred = createDeferred<void>(); | ||
| 129 | const runChangeRequestDeferred = createDeferred<GitRunStackedActionResult>(); | ||
| 130 | let runChangeRequestProgressListener: ((event: GitActionProgressEvent) => void) | null = null; | ||
| 131 | |||
| 132 | const client: VcsActionClient = { | ||
| 133 | refreshStatus: vi.fn(() => refreshDeferred.promise), | ||
| 134 | pull: vi.fn(() => pullDeferred.promise), | ||
| 135 | switchRef: vi.fn(() => switchRefDeferred.promise), | ||
| 136 | createRef: vi.fn(() => createRefDeferred.promise), | ||
| 137 | createWorktree: vi.fn(() => createWorktreeDeferred.promise), | ||
| 138 | init: vi.fn(() => initDeferred.promise), | ||
| 139 | runChangeRequest: vi.fn((_, options) => { | ||
| 140 | runChangeRequestProgressListener = options?.onProgress ?? null; | ||
| 141 | return runChangeRequestDeferred.promise; | ||
| 142 | }), | ||
| 143 | }; | ||
| 144 | |||
| 145 | return { | ||
| 146 | client, | ||
| 147 | refreshDeferred, | ||
| 148 | pullDeferred, | ||
| 149 | switchRefDeferred, | ||
| 150 | createRefDeferred, | ||
| 151 | createWorktreeDeferred, | ||
| 152 | initDeferred, | ||
| 153 | runChangeRequestDeferred, | ||
| 154 | emitProgress(event: GitActionProgressEvent) { | ||
| 155 | runChangeRequestProgressListener?.(event); | ||
| 156 | }, | ||
| 157 | }; | ||
| 158 | } | ||
| 159 | |||
| 160 | describe("createVcsActionManager", () => { | ||
| 161 | afterEach(() => { | ||
| 162 | resetAtomRegistry(); | ||
| 163 | }); | ||
| 164 | |||
| 165 | it("tracks refreshStatus progress and clears state on success", async () => { | ||
| 166 | const mock = createMockClient(); | ||
| 167 | const manager = createVcsActionManager({ | ||
| 168 | getRegistry: () => atomRegistry, | ||
| 169 | getClient: () => mock.client, | ||
| 170 | }); | ||
| 171 | |||
| 172 | const promise = manager.refreshStatus(TARGET, mock.client); | ||
| 173 | |||
| 174 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 175 | isRunning: true, | ||
| 176 | operation: "refresh_status", | ||
| 177 | currentLabel: "Refreshing source control status", | ||
| 178 | error: null, | ||
| 179 | }); | ||
| 180 | |||
| 181 | mock.refreshDeferred.resolve(BASE_STATUS); | ||
| 182 | |||
| 183 | await expect(promise).resolves.toEqual(BASE_STATUS); | ||
| 184 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("tracks runChangeRequest progress events", async () => { | ||
| 188 | const mock = createMockClient(); | ||
| 189 | const onProgress = vi.fn(); | ||
| 190 | const manager = createVcsActionManager({ | ||
| 191 | getRegistry: () => atomRegistry, | ||
| 192 | getClient: () => mock.client, | ||
| 193 | getActionId: () => "action-123", | ||
| 194 | }); | ||
| 195 | |||
| 196 | const promise = manager.runChangeRequest( | ||
| 197 | TARGET, | ||
| 198 | { action: "commit_push", commitMessage: "Test commit" }, | ||
| 199 | { client: mock.client, gitStatus: BASE_STATUS, onProgress }, | ||
| 200 | ); | ||
| 201 | |||
| 202 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 203 | isRunning: true, | ||
| 204 | operation: "run_change_request", | ||
| 205 | actionId: "action-123", | ||
| 206 | currentLabel: "Committing...", | ||
| 207 | error: null, | ||
| 208 | }); | ||
| 209 | |||
| 210 | mock.emitProgress(createPhaseStartedEvent()); | ||
| 211 | expect(manager.getSnapshot(TARGET).currentLabel).toBe("Committing..."); | ||
| 212 | expect(onProgress).toHaveBeenLastCalledWith(createPhaseStartedEvent()); | ||
| 213 | |||
| 214 | mock.emitProgress(createHookStartedEvent()); | ||
| 215 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 216 | currentLabel: "Running post-commit...", | ||
| 217 | hookName: "post-commit", | ||
| 218 | isRunning: true, | ||
| 219 | }); | ||
| 220 | |||
| 221 | mock.emitProgress(createHookOutputEvent()); | ||
| 222 | expect(manager.getSnapshot(TARGET).lastOutputLine).toBe("hook output"); | ||
| 223 | |||
| 224 | mock.emitProgress(createHookFinishedEvent()); | ||
| 225 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 226 | currentLabel: "Committing...", | ||
| 227 | hookName: null, | ||
| 228 | lastOutputLine: null, | ||
| 229 | }); | ||
| 230 | |||
| 231 | const result = createActionFinishedEvent().result; | ||
| 232 | mock.runChangeRequestDeferred.resolve(result); | ||
| 233 | |||
| 234 | await expect(promise).resolves.toEqual(result); | ||
| 235 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); | ||
| 236 | }); | ||
| 237 | |||
| 238 | it("stores the error when an operation fails", async () => { | ||
| 239 | const mock = createMockClient(); | ||
| 240 | const manager = createVcsActionManager({ | ||
| 241 | getRegistry: () => atomRegistry, | ||
| 242 | getClient: () => mock.client, | ||
| 243 | }); | ||
| 244 | |||
| 245 | const promise = manager.pull(TARGET, mock.client); | ||
| 246 | |||
| 247 | mock.pullDeferred.reject(new Error("Pull failed.")); | ||
| 248 | |||
| 249 | await expect(promise).rejects.toThrow("Pull failed."); | ||
| 250 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 251 | isRunning: false, | ||
| 252 | operation: "pull", | ||
| 253 | currentLabel: null, | ||
| 254 | error: "Pull failed.", | ||
| 255 | }); | ||
| 256 | }); | ||
| 257 | |||
| 258 | it("invalidates after successful mutations but not refreshStatus", async () => { | ||
| 259 | const mock = createMockClient(); | ||
| 260 | const onInvalidate = vi.fn(); | ||
| 261 | const manager = createVcsActionManager({ | ||
| 262 | getRegistry: () => atomRegistry, | ||
| 263 | getClient: () => mock.client, | ||
| 264 | onInvalidate, | ||
| 265 | }); | ||
| 266 | |||
| 267 | const refreshPromise = manager.refreshStatus(TARGET, mock.client); | ||
| 268 | mock.refreshDeferred.resolve(BASE_STATUS); | ||
| 269 | await expect(refreshPromise).resolves.toEqual(BASE_STATUS); | ||
| 270 | expect(onInvalidate).not.toHaveBeenCalled(); | ||
| 271 | |||
| 272 | const pullPromise = manager.pull(TARGET, mock.client); | ||
| 273 | const pullResult: VcsPullResult = { | ||
| 274 | status: "skipped_up_to_date", | ||
| 275 | refName: "main", | ||
| 276 | upstreamRef: null, | ||
| 277 | }; | ||
| 278 | mock.pullDeferred.resolve(pullResult); | ||
| 279 | await expect(pullPromise).resolves.toEqual(pullResult); | ||
| 280 | expect(onInvalidate).toHaveBeenCalledWith(TARGET); | ||
| 281 | }); | ||
| 282 | |||
| 283 | it("returns null when no client is available", async () => { | ||
| 284 | const manager = createVcsActionManager({ | ||
| 285 | getRegistry: () => atomRegistry, | ||
| 286 | getClient: () => null, | ||
| 287 | }); | ||
| 288 | |||
| 289 | await expect(manager.switchRef(TARGET, { refName: "main" })).resolves.toBeNull(); | ||
| 290 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); | ||
| 291 | }); | ||
| 292 | }); | ||
packages/client-runtime/src/vcsActionState.ts deleted-458| ... | @@ -1,458 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | GitActionProgressEvent, | ||
| 3 | GitRunStackedActionInput, | ||
| 4 | GitRunStackedActionResult, | ||
| 5 | GitStackedAction, | ||
| 6 | EnvironmentId, | ||
| 7 | VcsCreateRefInput, | ||
| 8 | VcsCreateRefResult, | ||
| 9 | VcsCreateWorktreeInput, | ||
| 10 | VcsCreateWorktreeResult, | ||
| 11 | VcsPullInput, | ||
| 12 | VcsPullResult, | ||
| 13 | VcsStatusResult, | ||
| 14 | VcsSwitchRefInput, | ||
| 15 | VcsSwitchRefResult, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import * as DateTime from "effect/DateTime"; | ||
| 18 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 19 | |||
| 20 | import { buildGitActionProgressStages } from "./gitActions.ts"; | ||
| 21 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 22 | |||
| 23 | export type VcsActionOperation = | ||
| 24 | | "refresh_status" | ||
| 25 | | "run_change_request" | ||
| 26 | | "pull" | ||
| 27 | | "switch_ref" | ||
| 28 | | "create_ref" | ||
| 29 | | "create_worktree" | ||
| 30 | | "init"; | ||
| 31 | |||
| 32 | export interface VcsActionState { | ||
| 33 | readonly isRunning: boolean; | ||
| 34 | readonly operation: VcsActionOperation | null; | ||
| 35 | readonly actionId: string | null; | ||
| 36 | readonly action: GitStackedAction | null; | ||
| 37 | readonly currentLabel: string | null; | ||
| 38 | readonly currentPhaseLabel: string | null; | ||
| 39 | readonly hookName: string | null; | ||
| 40 | readonly lastOutputLine: string | null; | ||
| 41 | readonly phaseStartedAtMs: number | null; | ||
| 42 | readonly hookStartedAtMs: number | null; | ||
| 43 | readonly error: string | null; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface VcsActionTarget { | ||
| 47 | readonly environmentId: EnvironmentId | null; | ||
| 48 | readonly cwd: string | null; | ||
| 49 | } | ||
| 50 | |||
| 51 | export type VcsActionClient = Pick< | ||
| 52 | WsRpcClient["vcs"], | ||
| 53 | "refreshStatus" | "pull" | "switchRef" | "createRef" | "createWorktree" | "init" | ||
| 54 | > & { | ||
| 55 | readonly runChangeRequest: WsRpcClient["git"]["runStackedAction"]; | ||
| 56 | }; | ||
| 57 | |||
| 58 | export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({ | ||
| 59 | isRunning: false, | ||
| 60 | operation: null, | ||
| 61 | actionId: null, | ||
| 62 | action: null, | ||
| 63 | currentLabel: null, | ||
| 64 | currentPhaseLabel: null, | ||
| 65 | hookName: null, | ||
| 66 | lastOutputLine: null, | ||
| 67 | phaseStartedAtMs: null, | ||
| 68 | hookStartedAtMs: null, | ||
| 69 | error: null, | ||
| 70 | }); | ||
| 71 | |||
| 72 | const knownVcsActionKeys = new Set<string>(); | ||
| 73 | let nextGeneratedActionId = 0; | ||
| 74 | const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); | ||
| 75 | |||
| 76 | export const vcsActionStateAtom = Atom.family((key: string) => { | ||
| 77 | knownVcsActionKeys.add(key); | ||
| 78 | return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( | ||
| 79 | Atom.keepAlive, | ||
| 80 | Atom.withLabel(`vcs-action:${key}`), | ||
| 81 | ); | ||
| 82 | }); | ||
| 83 | |||
| 84 | export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( | ||
| 85 | Atom.keepAlive, | ||
| 86 | Atom.withLabel("vcs-action:null"), | ||
| 87 | ); | ||
| 88 | |||
| 89 | export function getVcsActionTargetKey(target: VcsActionTarget): string | null { | ||
| 90 | if (target.environmentId === null || target.cwd === null) { | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | return `${target.environmentId}:${target.cwd}`; | ||
| 94 | } | ||
| 95 | |||
| 96 | export function applyVcsActionProgressEvent( | ||
| 97 | current: VcsActionState, | ||
| 98 | event: GitActionProgressEvent, | ||
| 99 | ): VcsActionState { | ||
| 100 | const now = nowMs(); | ||
| 101 | |||
| 102 | switch (event.kind) { | ||
| 103 | case "action_started": | ||
| 104 | return { | ||
| 105 | ...current, | ||
| 106 | isRunning: true, | ||
| 107 | actionId: event.actionId, | ||
| 108 | action: event.action, | ||
| 109 | operation: "run_change_request", | ||
| 110 | phaseStartedAtMs: now, | ||
| 111 | hookStartedAtMs: null, | ||
| 112 | hookName: null, | ||
| 113 | lastOutputLine: null, | ||
| 114 | error: null, | ||
| 115 | }; | ||
| 116 | case "phase_started": | ||
| 117 | return { | ||
| 118 | ...current, | ||
| 119 | isRunning: true, | ||
| 120 | actionId: event.actionId, | ||
| 121 | action: event.action, | ||
| 122 | operation: "run_change_request", | ||
| 123 | currentLabel: event.label, | ||
| 124 | currentPhaseLabel: event.label, | ||
| 125 | phaseStartedAtMs: now, | ||
| 126 | hookStartedAtMs: null, | ||
| 127 | hookName: null, | ||
| 128 | lastOutputLine: null, | ||
| 129 | error: null, | ||
| 130 | }; | ||
| 131 | case "hook_started": | ||
| 132 | return { | ||
| 133 | ...current, | ||
| 134 | isRunning: true, | ||
| 135 | actionId: event.actionId, | ||
| 136 | action: event.action, | ||
| 137 | operation: "run_change_request", | ||
| 138 | currentLabel: `Running ${event.hookName}...`, | ||
| 139 | hookName: event.hookName, | ||
| 140 | hookStartedAtMs: now, | ||
| 141 | lastOutputLine: null, | ||
| 142 | error: null, | ||
| 143 | }; | ||
| 144 | case "hook_output": | ||
| 145 | return { | ||
| 146 | ...current, | ||
| 147 | isRunning: true, | ||
| 148 | actionId: event.actionId, | ||
| 149 | action: event.action, | ||
| 150 | operation: "run_change_request", | ||
| 151 | lastOutputLine: event.text, | ||
| 152 | error: null, | ||
| 153 | }; | ||
| 154 | case "hook_finished": | ||
| 155 | return { | ||
| 156 | ...current, | ||
| 157 | isRunning: true, | ||
| 158 | actionId: event.actionId, | ||
| 159 | action: event.action, | ||
| 160 | operation: "run_change_request", | ||
| 161 | currentLabel: current.currentPhaseLabel, | ||
| 162 | hookName: null, | ||
| 163 | hookStartedAtMs: null, | ||
| 164 | lastOutputLine: null, | ||
| 165 | error: null, | ||
| 166 | }; | ||
| 167 | case "action_finished": | ||
| 168 | return { | ||
| 169 | ...current, | ||
| 170 | isRunning: false, | ||
| 171 | actionId: event.actionId, | ||
| 172 | action: event.action, | ||
| 173 | operation: "run_change_request", | ||
| 174 | error: null, | ||
| 175 | }; | ||
| 176 | case "action_failed": | ||
| 177 | return { | ||
| 178 | ...EMPTY_VCS_ACTION_STATE, | ||
| 179 | actionId: event.actionId, | ||
| 180 | action: event.action, | ||
| 181 | operation: "run_change_request", | ||
| 182 | error: event.message, | ||
| 183 | }; | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | export interface VcsActionManagerConfig { | ||
| 188 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 189 | readonly getClient: (environmentId: EnvironmentId) => VcsActionClient | null; | ||
| 190 | readonly getActionId?: () => string; | ||
| 191 | readonly onInvalidate?: (target: VcsActionTarget) => void | Promise<void>; | ||
| 192 | } | ||
| 193 | |||
| 194 | export function createVcsActionManager(config: VcsActionManagerConfig) { | ||
| 195 | function setState(targetKey: string, nextState: VcsActionState): void { | ||
| 196 | config.getRegistry().set(vcsActionStateAtom(targetKey), nextState); | ||
| 197 | } | ||
| 198 | |||
| 199 | function startOperation( | ||
| 200 | targetKey: string, | ||
| 201 | input: { | ||
| 202 | readonly operation: VcsActionOperation; | ||
| 203 | readonly actionId?: string; | ||
| 204 | readonly action?: GitStackedAction; | ||
| 205 | readonly label: string; | ||
| 206 | }, | ||
| 207 | ): void { | ||
| 208 | setState(targetKey, { | ||
| 209 | isRunning: true, | ||
| 210 | operation: input.operation, | ||
| 211 | actionId: input.actionId ?? null, | ||
| 212 | action: input.action ?? null, | ||
| 213 | currentLabel: input.label, | ||
| 214 | currentPhaseLabel: input.label, | ||
| 215 | hookName: null, | ||
| 216 | lastOutputLine: null, | ||
| 217 | phaseStartedAtMs: nowMs(), | ||
| 218 | hookStartedAtMs: null, | ||
| 219 | error: null, | ||
| 220 | }); | ||
| 221 | } | ||
| 222 | |||
| 223 | function finishOperation(targetKey: string): void { | ||
| 224 | setState(targetKey, EMPTY_VCS_ACTION_STATE); | ||
| 225 | } | ||
| 226 | |||
| 227 | function failOperation( | ||
| 228 | targetKey: string, | ||
| 229 | error: unknown, | ||
| 230 | input: { | ||
| 231 | readonly operation: VcsActionOperation; | ||
| 232 | readonly actionId?: string; | ||
| 233 | readonly action?: GitStackedAction; | ||
| 234 | }, | ||
| 235 | ): void { | ||
| 236 | setState(targetKey, { | ||
| 237 | ...EMPTY_VCS_ACTION_STATE, | ||
| 238 | operation: input.operation, | ||
| 239 | actionId: input.actionId ?? null, | ||
| 240 | action: input.action ?? null, | ||
| 241 | error: error instanceof Error ? error.message : "Source control action failed.", | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | |||
| 245 | async function runOperation<TResult>( | ||
| 246 | target: VcsActionTarget, | ||
| 247 | input: { | ||
| 248 | readonly operation: VcsActionOperation; | ||
| 249 | readonly label: string; | ||
| 250 | readonly actionId?: string; | ||
| 251 | readonly action?: GitStackedAction; | ||
| 252 | readonly client?: VcsActionClient | undefined; | ||
| 253 | readonly invalidateOnSuccess?: boolean; | ||
| 254 | readonly execute: (client: VcsActionClient) => Promise<TResult>; | ||
| 255 | }, | ||
| 256 | ): Promise<TResult | null> { | ||
| 257 | const targetKey = getVcsActionTargetKey(target); | ||
| 258 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 259 | return null; | ||
| 260 | } | ||
| 261 | |||
| 262 | const resolved = input.client ?? config.getClient(target.environmentId); | ||
| 263 | if (!resolved) { | ||
| 264 | return null; | ||
| 265 | } | ||
| 266 | |||
| 267 | startOperation(targetKey, input); | ||
| 268 | try { | ||
| 269 | const result = await input.execute(resolved); | ||
| 270 | finishOperation(targetKey); | ||
| 271 | if (input.invalidateOnSuccess ?? true) { | ||
| 272 | await config.onInvalidate?.(target); | ||
| 273 | } | ||
| 274 | return result; | ||
| 275 | } catch (error) { | ||
| 276 | failOperation(targetKey, error, input); | ||
| 277 | throw error; | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | function getSnapshot(target: VcsActionTarget): VcsActionState { | ||
| 282 | const targetKey = getVcsActionTargetKey(target); | ||
| 283 | if (targetKey === null) { | ||
| 284 | return EMPTY_VCS_ACTION_STATE; | ||
| 285 | } | ||
| 286 | |||
| 287 | return config.getRegistry().get(vcsActionStateAtom(targetKey)); | ||
| 288 | } | ||
| 289 | |||
| 290 | async function refreshStatus( | ||
| 291 | target: VcsActionTarget, | ||
| 292 | client?: VcsActionClient, | ||
| 293 | options?: { readonly quiet?: boolean }, | ||
| 294 | ): Promise<Awaited<ReturnType<VcsActionClient["refreshStatus"]>> | null> { | ||
| 295 | if (options?.quiet) { | ||
| 296 | if (target.environmentId === null || target.cwd === null) { | ||
| 297 | return null; | ||
| 298 | } | ||
| 299 | const resolved = client ?? config.getClient(target.environmentId); | ||
| 300 | return resolved ? resolved.refreshStatus({ cwd: target.cwd }) : null; | ||
| 301 | } | ||
| 302 | |||
| 303 | return runOperation(target, { | ||
| 304 | operation: "refresh_status", | ||
| 305 | label: "Refreshing source control status", | ||
| 306 | client, | ||
| 307 | invalidateOnSuccess: false, | ||
| 308 | execute: (resolved) => resolved.refreshStatus({ cwd: target.cwd! }), | ||
| 309 | }); | ||
| 310 | } | ||
| 311 | |||
| 312 | async function pull( | ||
| 313 | target: VcsActionTarget, | ||
| 314 | client?: VcsActionClient, | ||
| 315 | options?: { readonly label?: string }, | ||
| 316 | ): Promise<VcsPullResult | null> { | ||
| 317 | return runOperation(target, { | ||
| 318 | operation: "pull", | ||
| 319 | label: options?.label ?? "Pulling latest changes", | ||
| 320 | client, | ||
| 321 | execute: (resolved) => resolved.pull({ cwd: target.cwd! } satisfies VcsPullInput), | ||
| 322 | }); | ||
| 323 | } | ||
| 324 | |||
| 325 | async function switchRef( | ||
| 326 | target: VcsActionTarget, | ||
| 327 | input: Omit<VcsSwitchRefInput, "cwd">, | ||
| 328 | client?: VcsActionClient, | ||
| 329 | options?: { readonly label?: string }, | ||
| 330 | ): Promise<VcsSwitchRefResult | null> { | ||
| 331 | return runOperation(target, { | ||
| 332 | operation: "switch_ref", | ||
| 333 | label: options?.label ?? "Switching branch", | ||
| 334 | client, | ||
| 335 | execute: (resolved) => resolved.switchRef({ cwd: target.cwd!, ...input }), | ||
| 336 | }); | ||
| 337 | } | ||
| 338 | |||
| 339 | async function createRef( | ||
| 340 | target: VcsActionTarget, | ||
| 341 | input: Omit<VcsCreateRefInput, "cwd">, | ||
| 342 | client?: VcsActionClient, | ||
| 343 | options?: { readonly label?: string }, | ||
| 344 | ): Promise<VcsCreateRefResult | null> { | ||
| 345 | return runOperation(target, { | ||
| 346 | operation: "create_ref", | ||
| 347 | label: options?.label ?? "Creating branch", | ||
| 348 | client, | ||
| 349 | execute: (resolved) => resolved.createRef({ cwd: target.cwd!, ...input }), | ||
| 350 | }); | ||
| 351 | } | ||
| 352 | |||
| 353 | async function createWorktree( | ||
| 354 | target: VcsActionTarget, | ||
| 355 | input: Omit<VcsCreateWorktreeInput, "cwd">, | ||
| 356 | client?: VcsActionClient, | ||
| 357 | options?: { readonly label?: string }, | ||
| 358 | ): Promise<VcsCreateWorktreeResult | null> { | ||
| 359 | return runOperation(target, { | ||
| 360 | operation: "create_worktree", | ||
| 361 | label: options?.label ?? "Creating worktree", | ||
| 362 | client, | ||
| 363 | execute: (resolved) => resolved.createWorktree({ cwd: target.cwd!, ...input }), | ||
| 364 | }); | ||
| 365 | } | ||
| 366 | |||
| 367 | async function init( | ||
| 368 | target: VcsActionTarget, | ||
| 369 | client?: VcsActionClient, | ||
| 370 | options?: { readonly label?: string }, | ||
| 371 | ): Promise<Awaited<ReturnType<VcsActionClient["init"]>> | null> { | ||
| 372 | return runOperation(target, { | ||
| 373 | operation: "init", | ||
| 374 | label: options?.label ?? "Initializing repository", | ||
| 375 | client, | ||
| 376 | execute: (resolved) => resolved.init({ cwd: target.cwd! }), | ||
| 377 | }); | ||
| 378 | } | ||
| 379 | |||
| 380 | async function runChangeRequest( | ||
| 381 | target: VcsActionTarget, | ||
| 382 | input: Omit<GitRunStackedActionInput, "cwd" | "actionId"> & { readonly actionId?: string }, | ||
| 383 | options?: { | ||
| 384 | readonly client?: VcsActionClient; | ||
| 385 | readonly gitStatus?: VcsStatusResult | null; | ||
| 386 | readonly onProgress?: (event: GitActionProgressEvent) => void; | ||
| 387 | }, | ||
| 388 | ): Promise<GitRunStackedActionResult | null> { | ||
| 389 | const actionId = | ||
| 390 | input.actionId ?? | ||
| 391 | config.getActionId?.() ?? | ||
| 392 | `vcs-action-${nowMs()}-${++nextGeneratedActionId}`; | ||
| 393 | const targetKey = getVcsActionTargetKey(target); | ||
| 394 | |||
| 395 | return runOperation(target, { | ||
| 396 | operation: "run_change_request", | ||
| 397 | label: | ||
| 398 | buildGitActionProgressStages({ | ||
| 399 | action: input.action, | ||
| 400 | hasCustomCommitMessage: Boolean(input.commitMessage?.trim()), | ||
| 401 | hasWorkingTreeChanges: options?.gitStatus?.hasWorkingTreeChanges ?? false, | ||
| 402 | featureBranch: input.featureBranch ?? false, | ||
| 403 | shouldPushBeforePr: | ||
| 404 | input.action === "create_pr" && | ||
| 405 | (!(options?.gitStatus?.hasUpstream ?? false) || | ||
| 406 | (options?.gitStatus?.aheadCount ?? 0) > 0), | ||
| 407 | })[0] ?? "Running source control action", | ||
| 408 | actionId, | ||
| 409 | action: input.action, | ||
| 410 | client: options?.client, | ||
| 411 | execute: async (resolved) => { | ||
| 412 | const result = await resolved.runChangeRequest( | ||
| 413 | { | ||
| 414 | cwd: target.cwd!, | ||
| 415 | actionId, | ||
| 416 | ...input, | ||
| 417 | }, | ||
| 418 | { | ||
| 419 | onProgress: (event) => { | ||
| 420 | if (targetKey !== null) { | ||
| 421 | const current = getSnapshot(target); | ||
| 422 | setState(targetKey, applyVcsActionProgressEvent(current, event)); | ||
| 423 | } | ||
| 424 | options?.onProgress?.(event); | ||
| 425 | }, | ||
| 426 | }, | ||
| 427 | ); | ||
| 428 | return result; | ||
| 429 | }, | ||
| 430 | }); | ||
| 431 | } | ||
| 432 | |||
| 433 | function reset(target?: VcsActionTarget): void { | ||
| 434 | if (target) { | ||
| 435 | const targetKey = getVcsActionTargetKey(target); | ||
| 436 | if (targetKey !== null) { | ||
| 437 | setState(targetKey, EMPTY_VCS_ACTION_STATE); | ||
| 438 | } | ||
| 439 | return; | ||
| 440 | } | ||
| 441 | |||
| 442 | for (const key of knownVcsActionKeys) { | ||
| 443 | setState(key, EMPTY_VCS_ACTION_STATE); | ||
| 444 | } | ||
| 445 | } | ||
| 446 | |||
| 447 | return { | ||
| 448 | getSnapshot, | ||
| 449 | refreshStatus, | ||
| 450 | pull, | ||
| 451 | switchRef, | ||
| 452 | createRef, | ||
| 453 | createWorktree, | ||
| 454 | init, | ||
| 455 | runChangeRequest, | ||
| 456 | reset, | ||
| 457 | }; | ||
| 458 | } | ||
packages/client-runtime/src/vcsRefState.test.ts deleted-399| ... | @@ -1,399 +0,0 @@ | ||
| 1 | import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | createVcsRefManager, | ||
| 7 | EMPTY_VCS_REF_STATE, | ||
| 8 | vcsRefStateAtom, | ||
| 9 | type VcsRefClient, | ||
| 10 | } from "./vcsRefState.ts"; | ||
| 11 | |||
| 12 | let atomRegistry = AtomRegistry.make(); | ||
| 13 | |||
| 14 | function resetAtomRegistry() { | ||
| 15 | atomRegistry.dispose(); | ||
| 16 | atomRegistry = AtomRegistry.make(); | ||
| 17 | } | ||
| 18 | |||
| 19 | const noop = () => undefined; | ||
| 20 | |||
| 21 | const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; | ||
| 22 | |||
| 23 | const FIRST_PAGE: VcsListRefsResult = { | ||
| 24 | refs: [ | ||
| 25 | { name: "main", current: true, isDefault: true, worktreePath: null }, | ||
| 26 | { name: "feature/a", current: false, isDefault: false, worktreePath: null }, | ||
| 27 | ], | ||
| 28 | isRepo: true, | ||
| 29 | hasPrimaryRemote: true, | ||
| 30 | nextCursor: 2, | ||
| 31 | totalCount: 3, | ||
| 32 | }; | ||
| 33 | |||
| 34 | const SECOND_PAGE: VcsListRefsResult = { | ||
| 35 | refs: [{ name: "feature/b", current: false, isDefault: false, worktreePath: null }], | ||
| 36 | isRepo: true, | ||
| 37 | hasPrimaryRemote: true, | ||
| 38 | nextCursor: null, | ||
| 39 | totalCount: 3, | ||
| 40 | }; | ||
| 41 | |||
| 42 | function createMockClient() { | ||
| 43 | const listRefs = vi.fn(async (input: Parameters<VcsRefClient["listRefs"]>[0]) => { | ||
| 44 | if (input.query === "feature") { | ||
| 45 | return { | ||
| 46 | ...FIRST_PAGE, | ||
| 47 | refs: FIRST_PAGE.refs.filter((branch) => branch.name.includes("feature")), | ||
| 48 | nextCursor: null, | ||
| 49 | totalCount: 2, | ||
| 50 | } satisfies VcsListRefsResult; | ||
| 51 | } | ||
| 52 | |||
| 53 | if (input.cursor === 2) { | ||
| 54 | return SECOND_PAGE; | ||
| 55 | } | ||
| 56 | |||
| 57 | return FIRST_PAGE; | ||
| 58 | }); | ||
| 59 | |||
| 60 | return { | ||
| 61 | client: { listRefs } satisfies VcsRefClient, | ||
| 62 | listRefs, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | function deferred<T>() { | ||
| 67 | let resolve!: (value: T) => void; | ||
| 68 | let reject!: (error: unknown) => void; | ||
| 69 | const promise = new Promise<T>((resolvePromise, rejectPromise) => { | ||
| 70 | resolve = resolvePromise; | ||
| 71 | reject = rejectPromise; | ||
| 72 | }); | ||
| 73 | return { promise, resolve, reject }; | ||
| 74 | } | ||
| 75 | |||
| 76 | describe("createVcsRefManager", () => { | ||
| 77 | afterEach(() => { | ||
| 78 | resetAtomRegistry(); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("loads the first page and stores it in atom state", async () => { | ||
| 82 | const mock = createMockClient(); | ||
| 83 | const manager = createVcsRefManager({ | ||
| 84 | getRegistry: () => atomRegistry, | ||
| 85 | getClient: () => mock.client, | ||
| 86 | }); | ||
| 87 | |||
| 88 | const promise = manager.load(TARGET, mock.client, { limit: 100 }); | ||
| 89 | |||
| 90 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 91 | data: null, | ||
| 92 | isPending: true, | ||
| 93 | error: null, | ||
| 94 | }); | ||
| 95 | |||
| 96 | await expect(promise).resolves.toEqual(FIRST_PAGE); | ||
| 97 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 98 | data: FIRST_PAGE, | ||
| 99 | isPending: false, | ||
| 100 | error: null, | ||
| 101 | }); | ||
| 102 | expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("loads the next page and appends refs", async () => { | ||
| 106 | const mock = createMockClient(); | ||
| 107 | const manager = createVcsRefManager({ | ||
| 108 | getRegistry: () => atomRegistry, | ||
| 109 | getClient: () => mock.client, | ||
| 110 | }); | ||
| 111 | |||
| 112 | await manager.load(TARGET, mock.client); | ||
| 113 | const next = await manager.loadNext(TARGET, mock.client); | ||
| 114 | |||
| 115 | expect(next).toEqual({ | ||
| 116 | ...SECOND_PAGE, | ||
| 117 | refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], | ||
| 118 | }); | ||
| 119 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 120 | data: { | ||
| 121 | ...SECOND_PAGE, | ||
| 122 | refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], | ||
| 123 | }, | ||
| 124 | isPending: false, | ||
| 125 | error: null, | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("keeps cached refs visible while refreshing", async () => { | ||
| 130 | const nextLoad = deferred<VcsListRefsResult>(); | ||
| 131 | let callCount = 0; | ||
| 132 | const listRefs = vi.fn((async () => { | ||
| 133 | callCount += 1; | ||
| 134 | return callCount === 1 ? FIRST_PAGE : nextLoad.promise; | ||
| 135 | }) satisfies VcsRefClient["listRefs"]); | ||
| 136 | const client = { listRefs } satisfies VcsRefClient; | ||
| 137 | const manager = createVcsRefManager({ | ||
| 138 | getRegistry: () => atomRegistry, | ||
| 139 | getClient: () => client, | ||
| 140 | }); | ||
| 141 | |||
| 142 | await manager.load(TARGET, client); | ||
| 143 | |||
| 144 | const refresh = manager.load(TARGET, client); | ||
| 145 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 146 | data: FIRST_PAGE, | ||
| 147 | isPending: true, | ||
| 148 | error: null, | ||
| 149 | }); | ||
| 150 | |||
| 151 | nextLoad.resolve(SECOND_PAGE); | ||
| 152 | await expect(refresh).resolves.toEqual(SECOND_PAGE); | ||
| 153 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 154 | data: SECOND_PAGE, | ||
| 155 | isPending: false, | ||
| 156 | error: null, | ||
| 157 | }); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("preserves loaded pages during first-page revalidation", async () => { | ||
| 161 | const refreshedFirstPage: VcsListRefsResult = { | ||
| 162 | ...FIRST_PAGE, | ||
| 163 | refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], | ||
| 164 | nextCursor: 1, | ||
| 165 | totalCount: 3, | ||
| 166 | }; | ||
| 167 | let callCount = 0; | ||
| 168 | const listRefs = vi.fn((async (input) => { | ||
| 169 | callCount += 1; | ||
| 170 | if (input.cursor === 2) { | ||
| 171 | return SECOND_PAGE; | ||
| 172 | } | ||
| 173 | return callCount === 1 ? FIRST_PAGE : refreshedFirstPage; | ||
| 174 | }) satisfies VcsRefClient["listRefs"]); | ||
| 175 | const client = { listRefs } satisfies VcsRefClient; | ||
| 176 | const manager = createVcsRefManager({ | ||
| 177 | getRegistry: () => atomRegistry, | ||
| 178 | getClient: () => client, | ||
| 179 | }); | ||
| 180 | |||
| 181 | await manager.load(TARGET, client); | ||
| 182 | await manager.loadNext(TARGET, client); | ||
| 183 | const beforeRefresh = manager.getSnapshot(TARGET).data; | ||
| 184 | expect(beforeRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); | ||
| 185 | |||
| 186 | await manager.load(TARGET, client, { preserveLoadedRefs: true }); | ||
| 187 | |||
| 188 | const afterRefresh = manager.getSnapshot(TARGET).data; | ||
| 189 | expect(afterRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); | ||
| 190 | expect(afterRefresh?.nextCursor).toBeNull(); | ||
| 191 | }); | ||
| 192 | |||
| 193 | it("stores query-specific state independently", async () => { | ||
| 194 | const mock = createMockClient(); | ||
| 195 | const manager = createVcsRefManager({ | ||
| 196 | getRegistry: () => atomRegistry, | ||
| 197 | getClient: () => mock.client, | ||
| 198 | }); | ||
| 199 | |||
| 200 | const queriedTarget = { ...TARGET, query: "feature" } as const; | ||
| 201 | const queried = await manager.load(queriedTarget, mock.client); | ||
| 202 | |||
| 203 | expect(queried?.refs.map((branch) => branch.name)).toEqual(["feature/a"]); | ||
| 204 | expect(manager.getSnapshot(TARGET).data).toBeNull(); | ||
| 205 | expect(manager.getSnapshot(queriedTarget).data?.refs.map((branch) => branch.name)).toEqual([ | ||
| 206 | "feature/a", | ||
| 207 | ]); | ||
| 208 | }); | ||
| 209 | |||
| 210 | it("returns cached data when no client is available", async () => { | ||
| 211 | const manager = createVcsRefManager({ | ||
| 212 | getRegistry: () => atomRegistry, | ||
| 213 | getClient: () => null, | ||
| 214 | }); | ||
| 215 | |||
| 216 | atomRegistry.set(vcsRefStateAtom("env-local:/repo:"), { | ||
| 217 | data: FIRST_PAGE, | ||
| 218 | isPending: false, | ||
| 219 | error: null, | ||
| 220 | }); | ||
| 221 | |||
| 222 | await expect(manager.load(TARGET)).resolves.toEqual(FIRST_PAGE); | ||
| 223 | }); | ||
| 224 | |||
| 225 | it("resets state", async () => { | ||
| 226 | const mock = createMockClient(); | ||
| 227 | const manager = createVcsRefManager({ | ||
| 228 | getRegistry: () => atomRegistry, | ||
| 229 | getClient: () => mock.client, | ||
| 230 | }); | ||
| 231 | |||
| 232 | await manager.load(TARGET, mock.client); | ||
| 233 | manager.reset(); | ||
| 234 | |||
| 235 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); | ||
| 236 | }); | ||
| 237 | |||
| 238 | it("invalidates every query for a cwd scope", async () => { | ||
| 239 | const mock = createMockClient(); | ||
| 240 | const manager = createVcsRefManager({ | ||
| 241 | getRegistry: () => atomRegistry, | ||
| 242 | getClient: () => mock.client, | ||
| 243 | }); | ||
| 244 | const queriedTarget = { ...TARGET, query: "feature" } as const; | ||
| 245 | |||
| 246 | await manager.load(TARGET, mock.client); | ||
| 247 | await manager.load(queriedTarget, mock.client); | ||
| 248 | |||
| 249 | manager.invalidateScope({ environmentId: TARGET.environmentId, cwd: TARGET.cwd }); | ||
| 250 | |||
| 251 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); | ||
| 252 | expect(manager.getSnapshot(queriedTarget)).toEqual(EMPTY_VCS_REF_STATE); | ||
| 253 | }); | ||
| 254 | |||
| 255 | it("invalidates target in-flight loads before they can write stale data", async () => { | ||
| 256 | const firstLoad = deferred<VcsListRefsResult>(); | ||
| 257 | let callCount = 0; | ||
| 258 | const listRefs = vi.fn((async () => { | ||
| 259 | callCount += 1; | ||
| 260 | return callCount === 1 ? firstLoad.promise : SECOND_PAGE; | ||
| 261 | }) satisfies VcsRefClient["listRefs"]); | ||
| 262 | const client = { listRefs } satisfies VcsRefClient; | ||
| 263 | const manager = createVcsRefManager({ | ||
| 264 | getRegistry: () => atomRegistry, | ||
| 265 | getClient: () => client, | ||
| 266 | }); | ||
| 267 | |||
| 268 | const staleLoad = manager.load(TARGET, client); | ||
| 269 | manager.invalidate(TARGET); | ||
| 270 | const freshLoad = manager.load(TARGET, client); | ||
| 271 | |||
| 272 | expect(listRefs).toHaveBeenCalledTimes(2); | ||
| 273 | |||
| 274 | firstLoad.resolve(FIRST_PAGE); | ||
| 275 | await expect(staleLoad).resolves.toEqual(FIRST_PAGE); | ||
| 276 | await expect(freshLoad).resolves.toEqual(SECOND_PAGE); | ||
| 277 | expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); | ||
| 278 | }); | ||
| 279 | |||
| 280 | it("watches refs with a ref-counted client-change subscription", async () => { | ||
| 281 | const mock = createMockClient(); | ||
| 282 | let listener: () => void = noop; | ||
| 283 | const unsubscribe = vi.fn(); | ||
| 284 | const manager = createVcsRefManager({ | ||
| 285 | getRegistry: () => atomRegistry, | ||
| 286 | getClient: () => mock.client, | ||
| 287 | subscribeClientChanges: (nextListener) => { | ||
| 288 | listener = nextListener; | ||
| 289 | return unsubscribe; | ||
| 290 | }, | ||
| 291 | watchLimit: 100, | ||
| 292 | }); | ||
| 293 | |||
| 294 | const firstUnwatch = manager.watch(TARGET); | ||
| 295 | const secondUnwatch = manager.watch(TARGET); | ||
| 296 | await Promise.resolve(); | ||
| 297 | |||
| 298 | expect(mock.listRefs).toHaveBeenCalledTimes(1); | ||
| 299 | expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); | ||
| 300 | |||
| 301 | listener(); | ||
| 302 | await Promise.resolve(); | ||
| 303 | expect(mock.listRefs).toHaveBeenCalledTimes(1); | ||
| 304 | |||
| 305 | firstUnwatch(); | ||
| 306 | expect(unsubscribe).not.toHaveBeenCalled(); | ||
| 307 | secondUnwatch(); | ||
| 308 | expect(unsubscribe).toHaveBeenCalledTimes(1); | ||
| 309 | }); | ||
| 310 | |||
| 311 | it("skips watched refresh while cached refs are fresh", async () => { | ||
| 312 | const mock = createMockClient(); | ||
| 313 | const manager = createVcsRefManager({ | ||
| 314 | getRegistry: () => atomRegistry, | ||
| 315 | getClient: () => mock.client, | ||
| 316 | staleTimeMs: 60_000, | ||
| 317 | watchLimit: 100, | ||
| 318 | }); | ||
| 319 | |||
| 320 | const firstUnwatch = manager.watch(TARGET); | ||
| 321 | await vi.waitFor(() => { | ||
| 322 | expect(manager.getSnapshot(TARGET).data).toEqual(FIRST_PAGE); | ||
| 323 | }); | ||
| 324 | firstUnwatch(); | ||
| 325 | |||
| 326 | const secondUnwatch = manager.watch(TARGET); | ||
| 327 | await Promise.resolve(); | ||
| 328 | expect(mock.listRefs).toHaveBeenCalledTimes(1); | ||
| 329 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 330 | data: FIRST_PAGE, | ||
| 331 | isPending: false, | ||
| 332 | error: null, | ||
| 333 | }); | ||
| 334 | |||
| 335 | secondUnwatch(); | ||
| 336 | }); | ||
| 337 | |||
| 338 | it("swallows watched refresh failures after storing error state", async () => { | ||
| 339 | const refreshError = new Error("backend unavailable"); | ||
| 340 | const listRefs = vi.fn(async () => { | ||
| 341 | throw refreshError; | ||
| 342 | }); | ||
| 343 | const onBackgroundError = vi.fn(); | ||
| 344 | const manager = createVcsRefManager({ | ||
| 345 | getRegistry: () => atomRegistry, | ||
| 346 | getClient: () => ({ listRefs }), | ||
| 347 | onBackgroundError, | ||
| 348 | }); | ||
| 349 | |||
| 350 | manager.watch(TARGET); | ||
| 351 | await Promise.resolve(); | ||
| 352 | await Promise.resolve(); | ||
| 353 | |||
| 354 | await vi.waitFor(() => { | ||
| 355 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 356 | data: null, | ||
| 357 | isPending: false, | ||
| 358 | error: "backend unavailable", | ||
| 359 | }); | ||
| 360 | expect(onBackgroundError).toHaveBeenCalledWith(refreshError); | ||
| 361 | }); | ||
| 362 | }); | ||
| 363 | |||
| 364 | it("starts a new watched refresh when the client is replaced while a load is in flight", async () => { | ||
| 365 | const firstLoad = deferred<VcsListRefsResult>(); | ||
| 366 | const secondLoad = deferred<VcsListRefsResult>(); | ||
| 367 | const firstListRefs = vi.fn(() => firstLoad.promise); | ||
| 368 | const secondListRefs = vi.fn(() => secondLoad.promise); | ||
| 369 | const firstClient = { listRefs: firstListRefs } satisfies VcsRefClient; | ||
| 370 | const secondClient = { listRefs: secondListRefs } satisfies VcsRefClient; | ||
| 371 | let currentClient: VcsRefClient = firstClient; | ||
| 372 | let listener: () => void = noop; | ||
| 373 | const manager = createVcsRefManager({ | ||
| 374 | getRegistry: () => atomRegistry, | ||
| 375 | getClient: () => currentClient, | ||
| 376 | subscribeClientChanges: (nextListener) => { | ||
| 377 | listener = nextListener; | ||
| 378 | return noop; | ||
| 379 | }, | ||
| 380 | }); | ||
| 381 | |||
| 382 | manager.watch(TARGET); | ||
| 383 | await Promise.resolve(); | ||
| 384 | expect(firstListRefs).toHaveBeenCalledTimes(1); | ||
| 385 | |||
| 386 | currentClient = secondClient; | ||
| 387 | listener(); | ||
| 388 | await Promise.resolve(); | ||
| 389 | expect(secondListRefs).toHaveBeenCalledTimes(1); | ||
| 390 | |||
| 391 | secondLoad.resolve(SECOND_PAGE); | ||
| 392 | await secondLoad.promise; | ||
| 393 | expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); | ||
| 394 | |||
| 395 | firstLoad.resolve(FIRST_PAGE); | ||
| 396 | await firstLoad.promise; | ||
| 397 | expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); | ||
| 398 | }); | ||
| 399 | }); | ||
packages/client-runtime/src/vcsRefState.ts deleted-451| ... | @@ -1,451 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | VcsListRefsInput, | ||
| 4 | VcsListRefsResult, | ||
| 5 | VcsRef as ContractVcsRef, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Clock from "effect/Clock"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import { Atom, type AtomRegistry, type AsyncResult } from "effect/unstable/reactivity"; | ||
| 10 | |||
| 11 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 12 | |||
| 13 | export interface VcsRefTarget { | ||
| 14 | readonly environmentId: EnvironmentId | null; | ||
| 15 | readonly cwd: string | null; | ||
| 16 | readonly query?: string | null; | ||
| 17 | } | ||
| 18 | |||
| 19 | export interface VcsRefScope { | ||
| 20 | readonly environmentId: EnvironmentId | null; | ||
| 21 | readonly cwd: string | null; | ||
| 22 | } | ||
| 23 | |||
| 24 | export interface VcsRefState { | ||
| 25 | readonly data: VcsListRefsResult | null; | ||
| 26 | readonly isPending: boolean; | ||
| 27 | readonly error: string | null; | ||
| 28 | } | ||
| 29 | |||
| 30 | export type VcsRef = ContractVcsRef; | ||
| 31 | export type VcsRefClient = Pick<WsRpcClient["vcs"], "listRefs">; | ||
| 32 | |||
| 33 | export const EMPTY_VCS_REF_STATE = Object.freeze<VcsRefState>({ | ||
| 34 | data: null, | ||
| 35 | isPending: false, | ||
| 36 | error: null, | ||
| 37 | }); | ||
| 38 | |||
| 39 | const INITIAL_VCS_REF_STATE = Object.freeze<VcsRefState>({ | ||
| 40 | data: null, | ||
| 41 | isPending: true, | ||
| 42 | error: null, | ||
| 43 | }); | ||
| 44 | |||
| 45 | const knownVcsRefKeys = new Set<string>(); | ||
| 46 | |||
| 47 | export const vcsRefStateAtom = Atom.family((key: string) => { | ||
| 48 | knownVcsRefKeys.add(key); | ||
| 49 | return Atom.make(EMPTY_VCS_REF_STATE).pipe(Atom.keepAlive, Atom.withLabel(`vcs-refs:${key}`)); | ||
| 50 | }); | ||
| 51 | |||
| 52 | export const EMPTY_VCS_REF_ATOM = Atom.make(EMPTY_VCS_REF_STATE).pipe( | ||
| 53 | Atom.keepAlive, | ||
| 54 | Atom.withLabel("vcs-refs:null"), | ||
| 55 | ); | ||
| 56 | |||
| 57 | function normalizeQuery(query: string | null | undefined): string { | ||
| 58 | return query?.trim() ?? ""; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function getVcsRefTargetKey(target: VcsRefTarget): string | null { | ||
| 62 | if (target.environmentId === null || target.cwd === null) { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | |||
| 66 | return `${target.environmentId}:${target.cwd}:${normalizeQuery(target.query)}`; | ||
| 67 | } | ||
| 68 | |||
| 69 | function toErrorMessage(error: unknown): string { | ||
| 70 | return error instanceof Error ? error.message : "Failed to load refs."; | ||
| 71 | } | ||
| 72 | |||
| 73 | function mergeRefs( | ||
| 74 | previous: ReadonlyArray<VcsRef>, | ||
| 75 | next: ReadonlyArray<VcsRef>, | ||
| 76 | ): ReadonlyArray<VcsRef> { | ||
| 77 | const merged = new Map<string, VcsRef>(); | ||
| 78 | for (const branch of previous) { | ||
| 79 | merged.set(branch.name, branch); | ||
| 80 | } | ||
| 81 | for (const branch of next) { | ||
| 82 | merged.set(branch.name, branch); | ||
| 83 | } | ||
| 84 | return [...merged.values()]; | ||
| 85 | } | ||
| 86 | |||
| 87 | export interface VcsRefManagerConfig { | ||
| 88 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 89 | readonly getClient: (environmentId: EnvironmentId) => VcsRefClient | null; | ||
| 90 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 91 | readonly watchLimit?: number; | ||
| 92 | readonly staleTimeMs?: number; | ||
| 93 | readonly onBackgroundError?: (error: unknown) => void; | ||
| 94 | } | ||
| 95 | |||
| 96 | interface WatchedEntry { | ||
| 97 | refCount: number; | ||
| 98 | teardown: () => void; | ||
| 99 | } | ||
| 100 | |||
| 101 | const NOOP: () => void = () => undefined; | ||
| 102 | |||
| 103 | export function createVcsRefManager(config: VcsRefManagerConfig) { | ||
| 104 | const inFlight = new Map< | ||
| 105 | string, | ||
| 106 | { | ||
| 107 | readonly client: VcsRefClient; | ||
| 108 | readonly promise: Promise<VcsListRefsResult | null>; | ||
| 109 | } | ||
| 110 | >(); | ||
| 111 | const loadVersions = new Map<string, number>(); | ||
| 112 | const watched = new Map<string, WatchedEntry>(); | ||
| 113 | const lastLoadedAt = new Map<string, number>(); | ||
| 114 | const refreshTargets = new Map<string, VcsRefTarget>(); | ||
| 115 | const watchLoadOptions = | ||
| 116 | config.watchLimit === undefined | ||
| 117 | ? undefined | ||
| 118 | : { limit: config.watchLimit, preserveLoadedRefs: true }; | ||
| 119 | |||
| 120 | const watchedRefreshAtom = Atom.family((targetKey: string) => | ||
| 121 | Atom.make(() => | ||
| 122 | Effect.promise(() => { | ||
| 123 | const target = refreshTargets.get(targetKey); | ||
| 124 | return target ? load(target, undefined, watchLoadOptions) : Promise.resolve(null); | ||
| 125 | }), | ||
| 126 | ).pipe( | ||
| 127 | Atom.swr({ | ||
| 128 | staleTime: config.staleTimeMs ?? 0, | ||
| 129 | revalidateOnMount: true, | ||
| 130 | }), | ||
| 131 | Atom.withLabel(`vcs-refs:watched-refresh:${targetKey}`), | ||
| 132 | ), | ||
| 133 | ); | ||
| 134 | |||
| 135 | function getLoadVersion(targetKey: string): number { | ||
| 136 | return loadVersions.get(targetKey) ?? 0; | ||
| 137 | } | ||
| 138 | |||
| 139 | function bumpLoadVersion(targetKey: string): number { | ||
| 140 | const next = getLoadVersion(targetKey) + 1; | ||
| 141 | loadVersions.set(targetKey, next); | ||
| 142 | return next; | ||
| 143 | } | ||
| 144 | |||
| 145 | function getSnapshot(target: VcsRefTarget): VcsRefState { | ||
| 146 | const targetKey = getVcsRefTargetKey(target); | ||
| 147 | if (targetKey === null) { | ||
| 148 | return EMPTY_VCS_REF_STATE; | ||
| 149 | } | ||
| 150 | return config.getRegistry().get(vcsRefStateAtom(targetKey)); | ||
| 151 | } | ||
| 152 | |||
| 153 | function setState(targetKey: string, nextState: VcsRefState): void { | ||
| 154 | config.getRegistry().set(vcsRefStateAtom(targetKey), nextState); | ||
| 155 | } | ||
| 156 | |||
| 157 | function markPending(targetKey: string): void { | ||
| 158 | const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); | ||
| 159 | setState( | ||
| 160 | targetKey, | ||
| 161 | current.data === null ? INITIAL_VCS_REF_STATE : { ...current, isPending: true, error: null }, | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | |||
| 165 | function setData(targetKey: string, data: VcsListRefsResult): void { | ||
| 166 | lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); | ||
| 167 | setState(targetKey, { | ||
| 168 | data, | ||
| 169 | isPending: false, | ||
| 170 | error: null, | ||
| 171 | }); | ||
| 172 | } | ||
| 173 | |||
| 174 | function setError(targetKey: string, error: unknown): void { | ||
| 175 | const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); | ||
| 176 | setState(targetKey, { | ||
| 177 | data: current.data, | ||
| 178 | isPending: false, | ||
| 179 | error: toErrorMessage(error), | ||
| 180 | }); | ||
| 181 | } | ||
| 182 | |||
| 183 | async function load( | ||
| 184 | target: VcsRefTarget, | ||
| 185 | client?: VcsRefClient, | ||
| 186 | options?: { | ||
| 187 | readonly cursor?: number; | ||
| 188 | readonly limit?: number; | ||
| 189 | readonly append?: boolean; | ||
| 190 | readonly preserveLoadedRefs?: boolean; | ||
| 191 | }, | ||
| 192 | ): Promise<VcsListRefsResult | null> { | ||
| 193 | const targetKey = getVcsRefTargetKey(target); | ||
| 194 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 195 | return null; | ||
| 196 | } | ||
| 197 | refreshTargets.set(targetKey, target); | ||
| 198 | |||
| 199 | const resolved = client ?? config.getClient(target.environmentId); | ||
| 200 | if (!resolved) { | ||
| 201 | return getSnapshot(target).data; | ||
| 202 | } | ||
| 203 | |||
| 204 | const inFlightKey = `${targetKey}:${options?.cursor ?? "start"}:${options?.append ? "append" : "replace"}`; | ||
| 205 | const existing = inFlight.get(inFlightKey); | ||
| 206 | if (existing && existing.client === resolved) { | ||
| 207 | return existing.promise; | ||
| 208 | } | ||
| 209 | |||
| 210 | markPending(targetKey); | ||
| 211 | const loadVersion = bumpLoadVersion(targetKey); | ||
| 212 | |||
| 213 | const current = getSnapshot(target).data; | ||
| 214 | const request: VcsListRefsInput = { | ||
| 215 | cwd: target.cwd, | ||
| 216 | ...(normalizeQuery(target.query).length > 0 ? { query: normalizeQuery(target.query) } : {}), | ||
| 217 | ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}), | ||
| 218 | ...(options?.limit !== undefined ? { limit: options.limit } : {}), | ||
| 219 | }; | ||
| 220 | |||
| 221 | const promise = resolved.listRefs(request).then( | ||
| 222 | (result) => { | ||
| 223 | const nextData = | ||
| 224 | options?.append && current | ||
| 225 | ? { | ||
| 226 | ...result, | ||
| 227 | refs: mergeRefs(current.refs, result.refs), | ||
| 228 | } | ||
| 229 | : options?.preserveLoadedRefs && current && current.refs.length > result.refs.length | ||
| 230 | ? { | ||
| 231 | ...result, | ||
| 232 | refs: mergeRefs(result.refs, current.refs), | ||
| 233 | nextCursor: current.nextCursor, | ||
| 234 | totalCount: Math.max(result.totalCount, current.totalCount), | ||
| 235 | } | ||
| 236 | : result; | ||
| 237 | if (getLoadVersion(targetKey) === loadVersion) { | ||
| 238 | setData(targetKey, nextData); | ||
| 239 | } | ||
| 240 | return nextData; | ||
| 241 | }, | ||
| 242 | (error) => { | ||
| 243 | if (getLoadVersion(targetKey) === loadVersion) { | ||
| 244 | setError(targetKey, error); | ||
| 245 | } | ||
| 246 | throw error; | ||
| 247 | }, | ||
| 248 | ); | ||
| 249 | |||
| 250 | inFlight.set(inFlightKey, { client: resolved, promise }); | ||
| 251 | try { | ||
| 252 | return await promise; | ||
| 253 | } finally { | ||
| 254 | if (inFlight.get(inFlightKey)?.promise === promise) { | ||
| 255 | inFlight.delete(inFlightKey); | ||
| 256 | } | ||
| 257 | } | ||
| 258 | } | ||
| 259 | |||
| 260 | function loadInBackground( | ||
| 261 | target: VcsRefTarget, | ||
| 262 | client: VcsRefClient, | ||
| 263 | options?: { | ||
| 264 | readonly cursor?: number; | ||
| 265 | readonly limit?: number; | ||
| 266 | readonly append?: boolean; | ||
| 267 | readonly preserveLoadedRefs?: boolean; | ||
| 268 | }, | ||
| 269 | ): void { | ||
| 270 | void load(target, client, options).catch((error: unknown) => { | ||
| 271 | config.onBackgroundError?.(error); | ||
| 272 | }); | ||
| 273 | } | ||
| 274 | |||
| 275 | async function loadNext( | ||
| 276 | target: VcsRefTarget, | ||
| 277 | client?: VcsRefClient, | ||
| 278 | options?: { readonly limit?: number }, | ||
| 279 | ): Promise<VcsListRefsResult | null> { | ||
| 280 | const current = getSnapshot(target).data; | ||
| 281 | if (!current?.nextCursor && current?.nextCursor !== 0) { | ||
| 282 | return current ?? null; | ||
| 283 | } | ||
| 284 | |||
| 285 | return load(target, client, { | ||
| 286 | cursor: current.nextCursor, | ||
| 287 | append: true, | ||
| 288 | ...(options?.limit !== undefined ? { limit: options.limit } : {}), | ||
| 289 | }); | ||
| 290 | } | ||
| 291 | |||
| 292 | function refreshWatchedTarget(targetKey: string, target: VcsRefTarget, client?: VcsRefClient) { | ||
| 293 | refreshTargets.set(targetKey, target); | ||
| 294 | |||
| 295 | if (client || config.staleTimeMs === undefined) { | ||
| 296 | const resolved = | ||
| 297 | client ?? (target.environmentId ? config.getClient(target.environmentId) : null); | ||
| 298 | if (resolved) { | ||
| 299 | loadInBackground(target, resolved, watchLoadOptions); | ||
| 300 | } | ||
| 301 | return; | ||
| 302 | } | ||
| 303 | |||
| 304 | const lastLoaded = lastLoadedAt.get(targetKey); | ||
| 305 | if ( | ||
| 306 | lastLoaded !== undefined && | ||
| 307 | Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs | ||
| 308 | ) { | ||
| 309 | return; | ||
| 310 | } | ||
| 311 | |||
| 312 | const result = config | ||
| 313 | .getRegistry() | ||
| 314 | .get(watchedRefreshAtom(targetKey)) as AsyncResult.AsyncResult< | ||
| 315 | VcsListRefsResult | null, | ||
| 316 | unknown | ||
| 317 | >; | ||
| 318 | if (result._tag === "Failure") { | ||
| 319 | config.onBackgroundError?.(result.cause); | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | function watch(target: VcsRefTarget, client?: VcsRefClient): () => void { | ||
| 324 | const targetKey = getVcsRefTargetKey(target); | ||
| 325 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 326 | return NOOP; | ||
| 327 | } | ||
| 328 | |||
| 329 | const existing = watched.get(targetKey); | ||
| 330 | if (existing) { | ||
| 331 | existing.refCount += 1; | ||
| 332 | return () => unwatch(targetKey); | ||
| 333 | } | ||
| 334 | |||
| 335 | let teardown: () => void; | ||
| 336 | |||
| 337 | if (client) { | ||
| 338 | refreshWatchedTarget(targetKey, target, client); | ||
| 339 | teardown = NOOP; | ||
| 340 | } else if (config.subscribeClientChanges) { | ||
| 341 | let currentClient: VcsRefClient | null = null; | ||
| 342 | const sync = () => { | ||
| 343 | const resolved = config.getClient(target.environmentId!); | ||
| 344 | if (!resolved) { | ||
| 345 | currentClient = null; | ||
| 346 | return; | ||
| 347 | } | ||
| 348 | if (currentClient === resolved) { | ||
| 349 | return; | ||
| 350 | } | ||
| 351 | |||
| 352 | const hadClient = currentClient !== null; | ||
| 353 | currentClient = resolved; | ||
| 354 | refreshWatchedTarget(targetKey, target, hadClient ? resolved : undefined); | ||
| 355 | }; | ||
| 356 | |||
| 357 | const unsubscribe = config.subscribeClientChanges(sync); | ||
| 358 | sync(); | ||
| 359 | teardown = unsubscribe; | ||
| 360 | } else { | ||
| 361 | const resolved = config.getClient(target.environmentId); | ||
| 362 | if (!resolved) { | ||
| 363 | return NOOP; | ||
| 364 | } | ||
| 365 | refreshWatchedTarget(targetKey, target); | ||
| 366 | teardown = NOOP; | ||
| 367 | } | ||
| 368 | |||
| 369 | watched.set(targetKey, { refCount: 1, teardown }); | ||
| 370 | return () => unwatch(targetKey); | ||
| 371 | } | ||
| 372 | |||
| 373 | function unwatch(targetKey: string): void { | ||
| 374 | const entry = watched.get(targetKey); | ||
| 375 | if (!entry) { | ||
| 376 | return; | ||
| 377 | } | ||
| 378 | |||
| 379 | entry.refCount -= 1; | ||
| 380 | if (entry.refCount > 0) { | ||
| 381 | return; | ||
| 382 | } | ||
| 383 | |||
| 384 | entry.teardown(); | ||
| 385 | watched.delete(targetKey); | ||
| 386 | } | ||
| 387 | |||
| 388 | function invalidate(target?: VcsRefTarget): void { | ||
| 389 | if (target) { | ||
| 390 | const targetKey = getVcsRefTargetKey(target); | ||
| 391 | if (targetKey !== null) { | ||
| 392 | bumpLoadVersion(targetKey); | ||
| 393 | setState(targetKey, EMPTY_VCS_REF_STATE); | ||
| 394 | for (const key of inFlight.keys()) { | ||
| 395 | if (key.startsWith(`${targetKey}:`)) { | ||
| 396 | inFlight.delete(key); | ||
| 397 | } | ||
| 398 | } | ||
| 399 | } | ||
| 400 | return; | ||
| 401 | } | ||
| 402 | |||
| 403 | for (const key of knownVcsRefKeys) { | ||
| 404 | bumpLoadVersion(key); | ||
| 405 | setState(key, EMPTY_VCS_REF_STATE); | ||
| 406 | } | ||
| 407 | inFlight.clear(); | ||
| 408 | } | ||
| 409 | |||
| 410 | function invalidateScope(scope: VcsRefScope): void { | ||
| 411 | if (scope.environmentId === null || scope.cwd === null) { | ||
| 412 | return; | ||
| 413 | } | ||
| 414 | |||
| 415 | const keyPrefix = `${scope.environmentId}:${scope.cwd}:`; | ||
| 416 | for (const key of knownVcsRefKeys) { | ||
| 417 | if (key.startsWith(keyPrefix)) { | ||
| 418 | bumpLoadVersion(key); | ||
| 419 | setState(key, EMPTY_VCS_REF_STATE); | ||
| 420 | } | ||
| 421 | } | ||
| 422 | |||
| 423 | for (const key of inFlight.keys()) { | ||
| 424 | if (key.startsWith(keyPrefix)) { | ||
| 425 | inFlight.delete(key); | ||
| 426 | } | ||
| 427 | } | ||
| 428 | } | ||
| 429 | |||
| 430 | function reset(): void { | ||
| 431 | for (const entry of watched.values()) { | ||
| 432 | entry.teardown(); | ||
| 433 | } | ||
| 434 | watched.clear(); | ||
| 435 | inFlight.clear(); | ||
| 436 | loadVersions.clear(); | ||
| 437 | lastLoadedAt.clear(); | ||
| 438 | refreshTargets.clear(); | ||
| 439 | invalidate(); | ||
| 440 | } | ||
| 441 | |||
| 442 | return { | ||
| 443 | getSnapshot, | ||
| 444 | watch, | ||
| 445 | load, | ||
| 446 | loadNext, | ||
| 447 | invalidate, | ||
| 448 | invalidateScope, | ||
| 449 | reset, | ||
| 450 | }; | ||
| 451 | } | ||
packages/client-runtime/src/vcsStatusState.test.ts deleted-363| ... | @@ -1,363 +0,0 @@ | ||
| 1 | import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type VcsStatusClient, | ||
| 7 | createVcsStatusManager, | ||
| 8 | getVcsStatusDataForTarget, | ||
| 9 | } from "./vcsStatusState.ts"; | ||
| 10 | |||
| 11 | /* ─── Test helpers ──────────────────────────────────────────────────── */ | ||
| 12 | |||
| 13 | let atomRegistry = AtomRegistry.make(); | ||
| 14 | |||
| 15 | function resetAtomRegistry() { | ||
| 16 | atomRegistry.dispose(); | ||
| 17 | atomRegistry = AtomRegistry.make(); | ||
| 18 | } | ||
| 19 | |||
| 20 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | ||
| 21 | listeners.add(listener); | ||
| 22 | return () => { | ||
| 23 | listeners.delete(listener); | ||
| 24 | }; | ||
| 25 | } | ||
| 26 | |||
| 27 | const BASE_STATUS: VcsStatusResult = { | ||
| 28 | isRepo: true, | ||
| 29 | hasPrimaryRemote: true, | ||
| 30 | isDefaultRef: false, | ||
| 31 | refName: "feature/push-status", | ||
| 32 | hasWorkingTreeChanges: false, | ||
| 33 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 34 | hasUpstream: true, | ||
| 35 | aheadCount: 0, | ||
| 36 | behindCount: 0, | ||
| 37 | pr: null, | ||
| 38 | }; | ||
| 39 | |||
| 40 | function createMockClient(): { | ||
| 41 | client: VcsStatusClient; | ||
| 42 | listeners: Set<(event: VcsStatusResult) => void>; | ||
| 43 | emit: (event: VcsStatusResult) => void; | ||
| 44 | } { | ||
| 45 | const listeners = new Set<(event: VcsStatusResult) => void>(); | ||
| 46 | const client: VcsStatusClient = { | ||
| 47 | refreshStatus: vi.fn(async (input: { cwd: string }) => ({ | ||
| 48 | ...BASE_STATUS, | ||
| 49 | refName: `${input.cwd}-refreshed`, | ||
| 50 | })), | ||
| 51 | onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => | ||
| 52 | registerListener(listeners, listener), | ||
| 53 | ), | ||
| 54 | }; | ||
| 55 | return { | ||
| 56 | client, | ||
| 57 | listeners, | ||
| 58 | emit: (event) => { | ||
| 59 | for (const listener of listeners) listener(event); | ||
| 60 | }, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; | ||
| 65 | const FRESH_TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/fresh" } as const; | ||
| 66 | const OTHER_ENV_TARGET = { environmentId: EnvironmentId.make("env-remote"), cwd: "/repo" } as const; | ||
| 67 | const TARGET_KEY = "env-local:/repo"; | ||
| 68 | const PENDING = { | ||
| 69 | targetKey: TARGET_KEY, | ||
| 70 | data: null, | ||
| 71 | error: null, | ||
| 72 | cause: null, | ||
| 73 | isPending: true, | ||
| 74 | }; | ||
| 75 | const EMPTY = { | ||
| 76 | targetKey: null, | ||
| 77 | data: null, | ||
| 78 | error: null, | ||
| 79 | cause: null, | ||
| 80 | isPending: false, | ||
| 81 | }; | ||
| 82 | |||
| 83 | /* ─── Tests ─────────────────────────────────────────────────────────── */ | ||
| 84 | |||
| 85 | describe("createVcsStatusManager", () => { | ||
| 86 | afterEach(() => { | ||
| 87 | resetAtomRegistry(); | ||
| 88 | }); | ||
| 89 | |||
| 90 | describe("with explicit client (no reconnection)", () => { | ||
| 91 | it("starts in a pending state when watching", () => { | ||
| 92 | const { client } = createMockClient(); | ||
| 93 | const manager = createVcsStatusManager({ | ||
| 94 | getRegistry: () => atomRegistry, | ||
| 95 | getClient: () => null, | ||
| 96 | }); | ||
| 97 | |||
| 98 | manager.watch(TARGET, client); | ||
| 99 | expect(manager.getSnapshot(TARGET)).toEqual(PENDING); | ||
| 100 | manager.reset(); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("shares one subscription per cwd and updates the snapshot", () => { | ||
| 104 | const { client, listeners, emit } = createMockClient(); | ||
| 105 | const manager = createVcsStatusManager({ | ||
| 106 | getRegistry: () => atomRegistry, | ||
| 107 | getClient: () => null, | ||
| 108 | }); | ||
| 109 | |||
| 110 | const releaseA = manager.watch(TARGET, client); | ||
| 111 | const releaseB = manager.watch(TARGET, client); | ||
| 112 | |||
| 113 | expect(client.onStatus).toHaveBeenCalledOnce(); | ||
| 114 | expect(manager.getSnapshot(TARGET)).toEqual(PENDING); | ||
| 115 | |||
| 116 | emit(BASE_STATUS); | ||
| 117 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 118 | targetKey: TARGET_KEY, | ||
| 119 | data: BASE_STATUS, | ||
| 120 | error: null, | ||
| 121 | cause: null, | ||
| 122 | isPending: false, | ||
| 123 | }); | ||
| 124 | |||
| 125 | releaseA(); | ||
| 126 | expect(listeners.size).toBe(1); | ||
| 127 | |||
| 128 | releaseB(); | ||
| 129 | expect(listeners.size).toBe(0); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("refreshes via unary RPC without restarting the stream", async () => { | ||
| 133 | const { client, emit } = createMockClient(); | ||
| 134 | const manager = createVcsStatusManager({ | ||
| 135 | getRegistry: () => atomRegistry, | ||
| 136 | getClient: () => null, | ||
| 137 | }); | ||
| 138 | |||
| 139 | const release = manager.watch(TARGET, client); | ||
| 140 | emit(BASE_STATUS); | ||
| 141 | |||
| 142 | const refreshed = await manager.refresh(TARGET, client); | ||
| 143 | |||
| 144 | expect(client.onStatus).toHaveBeenCalledOnce(); | ||
| 145 | expect(client.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | ||
| 146 | expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" }); | ||
| 147 | |||
| 148 | // Snapshot still reflects stream data, not the refresh response | ||
| 149 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 150 | targetKey: TARGET_KEY, | ||
| 151 | data: BASE_STATUS, | ||
| 152 | error: null, | ||
| 153 | cause: null, | ||
| 154 | isPending: false, | ||
| 155 | }); | ||
| 156 | |||
| 157 | release(); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("keeps subscriptions isolated by environment when cwds match", () => { | ||
| 161 | const local = createMockClient(); | ||
| 162 | const remote = createMockClient(); | ||
| 163 | const manager = createVcsStatusManager({ | ||
| 164 | getRegistry: () => atomRegistry, | ||
| 165 | getClient: () => null, | ||
| 166 | }); | ||
| 167 | |||
| 168 | const releaseLocal = manager.watch(TARGET, local.client); | ||
| 169 | const releaseRemote = manager.watch(OTHER_ENV_TARGET, remote.client); | ||
| 170 | |||
| 171 | local.emit(BASE_STATUS); | ||
| 172 | remote.emit({ ...BASE_STATUS, refName: "remote-branch" }); | ||
| 173 | |||
| 174 | expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); | ||
| 175 | expect(manager.getSnapshot(OTHER_ENV_TARGET).data?.refName).toBe("remote-branch"); | ||
| 176 | |||
| 177 | releaseLocal(); | ||
| 178 | releaseRemote(); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("rejects status data from a previous cwd during target transitions", () => { | ||
| 182 | const staleState = { | ||
| 183 | targetKey: TARGET_KEY, | ||
| 184 | data: BASE_STATUS, | ||
| 185 | error: null, | ||
| 186 | cause: null, | ||
| 187 | isPending: false, | ||
| 188 | }; | ||
| 189 | |||
| 190 | expect(getVcsStatusDataForTarget(staleState, FRESH_TARGET)).toBeNull(); | ||
| 191 | expect(getVcsStatusDataForTarget(staleState, TARGET)).toBe(BASE_STATUS); | ||
| 192 | }); | ||
| 193 | |||
| 194 | it("returns null from refresh when no client is available", async () => { | ||
| 195 | const manager = createVcsStatusManager({ | ||
| 196 | getRegistry: () => atomRegistry, | ||
| 197 | getClient: () => null, | ||
| 198 | }); | ||
| 199 | |||
| 200 | await expect(manager.refresh(TARGET)).resolves.toBeNull(); | ||
| 201 | }); | ||
| 202 | |||
| 203 | it("returns empty state for null targets", () => { | ||
| 204 | const manager = createVcsStatusManager({ | ||
| 205 | getRegistry: () => atomRegistry, | ||
| 206 | getClient: () => null, | ||
| 207 | }); | ||
| 208 | |||
| 209 | expect(manager.getSnapshot({ environmentId: null, cwd: null })).toEqual(EMPTY); | ||
| 210 | }); | ||
| 211 | }); | ||
| 212 | |||
| 213 | describe("with subscribeClientChanges (reconnection)", () => { | ||
| 214 | it("waits for a delayed client registration", () => { | ||
| 215 | const connectionListeners = new Set<() => void>(); | ||
| 216 | const clients = new Map<string, ReturnType<typeof createMockClient>>(); | ||
| 217 | |||
| 218 | const manager = createVcsStatusManager({ | ||
| 219 | getRegistry: () => atomRegistry, | ||
| 220 | getClient: (envId) => clients.get(envId)?.client ?? null, | ||
| 221 | getClientIdentity: (envId) => (clients.has(envId) ? envId : null), | ||
| 222 | subscribeClientChanges: (listener) => { | ||
| 223 | connectionListeners.add(listener); | ||
| 224 | return () => connectionListeners.delete(listener); | ||
| 225 | }, | ||
| 226 | }); | ||
| 227 | |||
| 228 | const release = manager.watch(TARGET); | ||
| 229 | expect(manager.getSnapshot(TARGET)).toEqual(PENDING); | ||
| 230 | |||
| 231 | // Register the client | ||
| 232 | const mock = createMockClient(); | ||
| 233 | clients.set("env-local", mock); | ||
| 234 | for (const listener of connectionListeners) listener(); | ||
| 235 | |||
| 236 | mock.emit(BASE_STATUS); | ||
| 237 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 238 | targetKey: TARGET_KEY, | ||
| 239 | data: BASE_STATUS, | ||
| 240 | error: null, | ||
| 241 | cause: null, | ||
| 242 | isPending: false, | ||
| 243 | }); | ||
| 244 | |||
| 245 | release(); | ||
| 246 | }); | ||
| 247 | |||
| 248 | it("resubscribes after client is removed and re-registered", () => { | ||
| 249 | const connectionListeners = new Set<() => void>(); | ||
| 250 | const clients = new Map<string, ReturnType<typeof createMockClient>>(); | ||
| 251 | |||
| 252 | const manager = createVcsStatusManager({ | ||
| 253 | getRegistry: () => atomRegistry, | ||
| 254 | getClient: (envId) => clients.get(envId)?.client ?? null, | ||
| 255 | getClientIdentity: (envId) => | ||
| 256 | clients.get(envId) ? `identity:${envId}:${clients.size}` : null, | ||
| 257 | subscribeClientChanges: (listener) => { | ||
| 258 | connectionListeners.add(listener); | ||
| 259 | return () => connectionListeners.delete(listener); | ||
| 260 | }, | ||
| 261 | }); | ||
| 262 | |||
| 263 | // Register first client and watch | ||
| 264 | const first = createMockClient(); | ||
| 265 | clients.set("env-local", first); | ||
| 266 | const release = manager.watch(TARGET); | ||
| 267 | |||
| 268 | first.emit(BASE_STATUS); | ||
| 269 | expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); | ||
| 270 | |||
| 271 | // Remove client | ||
| 272 | clients.delete("env-local"); | ||
| 273 | for (const listener of connectionListeners) listener(); | ||
| 274 | |||
| 275 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 276 | targetKey: TARGET_KEY, | ||
| 277 | data: BASE_STATUS, | ||
| 278 | error: null, | ||
| 279 | cause: null, | ||
| 280 | isPending: true, | ||
| 281 | }); | ||
| 282 | |||
| 283 | // Register new client (different identity) | ||
| 284 | const second = createMockClient(); | ||
| 285 | clients.set("env-local", second); | ||
| 286 | for (const listener of connectionListeners) listener(); | ||
| 287 | |||
| 288 | second.emit({ ...BASE_STATUS, refName: "reconnected-branch" }); | ||
| 289 | expect(manager.getSnapshot(TARGET).data?.refName).toBe("reconnected-branch"); | ||
| 290 | |||
| 291 | release(); | ||
| 292 | }); | ||
| 293 | |||
| 294 | it("cleans up connection listener on unwatch", () => { | ||
| 295 | const connectionListeners = new Set<() => void>(); | ||
| 296 | const mock = createMockClient(); | ||
| 297 | |||
| 298 | const manager = createVcsStatusManager({ | ||
| 299 | getRegistry: () => atomRegistry, | ||
| 300 | getClient: () => mock.client, | ||
| 301 | getClientIdentity: () => "id", | ||
| 302 | subscribeClientChanges: (listener) => { | ||
| 303 | connectionListeners.add(listener); | ||
| 304 | return () => connectionListeners.delete(listener); | ||
| 305 | }, | ||
| 306 | }); | ||
| 307 | |||
| 308 | const release = manager.watch(TARGET); | ||
| 309 | expect(connectionListeners.size).toBe(1); | ||
| 310 | |||
| 311 | release(); | ||
| 312 | expect(connectionListeners.size).toBe(0); | ||
| 313 | expect(mock.listeners.size).toBe(0); | ||
| 314 | }); | ||
| 315 | }); | ||
| 316 | |||
| 317 | describe("with getClient config (one-shot)", () => { | ||
| 318 | it("resolves client from config and subscribes", () => { | ||
| 319 | const mock = createMockClient(); | ||
| 320 | const manager = createVcsStatusManager({ | ||
| 321 | getRegistry: () => atomRegistry, | ||
| 322 | getClient: (envId) => (envId === "env-local" ? mock.client : null), | ||
| 323 | }); | ||
| 324 | |||
| 325 | const release = manager.watch(TARGET); | ||
| 326 | expect(mock.client.onStatus).toHaveBeenCalledOnce(); | ||
| 327 | |||
| 328 | mock.emit(BASE_STATUS); | ||
| 329 | expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); | ||
| 330 | |||
| 331 | release(); | ||
| 332 | expect(mock.listeners.size).toBe(0); | ||
| 333 | }); | ||
| 334 | |||
| 335 | it("returns noop when client is not available", () => { | ||
| 336 | const manager = createVcsStatusManager({ | ||
| 337 | getRegistry: () => atomRegistry, | ||
| 338 | getClient: () => null, | ||
| 339 | }); | ||
| 340 | |||
| 341 | const release = manager.watch(TARGET); | ||
| 342 | expect(manager.getSnapshot(TARGET)).toEqual(PENDING); | ||
| 343 | release(); // should not throw | ||
| 344 | }); | ||
| 345 | }); | ||
| 346 | |||
| 347 | describe("reset", () => { | ||
| 348 | it("tears down all active subscriptions", () => { | ||
| 349 | const mock = createMockClient(); | ||
| 350 | const manager = createVcsStatusManager({ | ||
| 351 | getRegistry: () => atomRegistry, | ||
| 352 | getClient: () => mock.client, | ||
| 353 | }); | ||
| 354 | |||
| 355 | manager.watch(TARGET); | ||
| 356 | manager.watch(FRESH_TARGET); | ||
| 357 | expect(mock.listeners.size).toBe(2); | ||
| 358 | |||
| 359 | manager.reset(); | ||
| 360 | expect(mock.listeners.size).toBe(0); | ||
| 361 | }); | ||
| 362 | }); | ||
| 363 | }); | ||
packages/client-runtime/src/vcsStatusState.ts deleted-306| ... | @@ -1,306 +0,0 @@ | ||
| 1 | import type { EnvironmentId, GitManagerServiceError, VcsStatusResult } from "@t3tools/contracts"; | ||
| 2 | import type * as Cause from "effect/Cause"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 5 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 6 | |||
| 7 | /* ─── Types ─────────────────────────────────────────────────────────── */ | ||
| 8 | |||
| 9 | export interface VcsStatusState { | ||
| 10 | readonly targetKey: string | null; | ||
| 11 | readonly data: VcsStatusResult | null; | ||
| 12 | readonly error: GitManagerServiceError | null; | ||
| 13 | readonly cause: Cause.Cause<GitManagerServiceError> | null; | ||
| 14 | readonly isPending: boolean; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface VcsStatusTarget { | ||
| 18 | readonly environmentId: EnvironmentId | null; | ||
| 19 | readonly cwd: string | null; | ||
| 20 | } | ||
| 21 | |||
| 22 | export type VcsStatusClient = Pick<WsRpcClient["vcs"], "onStatus" | "refreshStatus">; | ||
| 23 | |||
| 24 | interface WatchedEntry { | ||
| 25 | refCount: number; | ||
| 26 | teardown: () => void; | ||
| 27 | } | ||
| 28 | |||
| 29 | /* ─── Constants ─────────────────────────────────────────────────────── */ | ||
| 30 | |||
| 31 | const NOOP: () => void = () => undefined; | ||
| 32 | |||
| 33 | export const EMPTY_VCS_STATUS_STATE = Object.freeze<VcsStatusState>({ | ||
| 34 | targetKey: null, | ||
| 35 | data: null, | ||
| 36 | error: null, | ||
| 37 | cause: null, | ||
| 38 | isPending: false, | ||
| 39 | }); | ||
| 40 | |||
| 41 | function initialVcsStatusState(targetKey: string): VcsStatusState { | ||
| 42 | return { | ||
| 43 | targetKey, | ||
| 44 | data: null, | ||
| 45 | error: null, | ||
| 46 | cause: null, | ||
| 47 | isPending: true, | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 51 | /* ─── Atoms ─────────────────────────────────────────────────────────── */ | ||
| 52 | |||
| 53 | const knownVcsStatusKeys = new Set<string>(); | ||
| 54 | |||
| 55 | export const vcsStatusStateAtom = Atom.family((key: string) => { | ||
| 56 | knownVcsStatusKeys.add(key); | ||
| 57 | return Atom.make(initialVcsStatusState(key)).pipe( | ||
| 58 | Atom.keepAlive, | ||
| 59 | Atom.withLabel(`vcs-status:${key}`), | ||
| 60 | ); | ||
| 61 | }); | ||
| 62 | |||
| 63 | export const EMPTY_VCS_STATUS_ATOM = Atom.make(EMPTY_VCS_STATUS_STATE).pipe( | ||
| 64 | Atom.keepAlive, | ||
| 65 | Atom.withLabel("vcs-status:null"), | ||
| 66 | ); | ||
| 67 | |||
| 68 | /* ─── Helpers ───────────────────────────────────────────────────────── */ | ||
| 69 | |||
| 70 | export function getVcsStatusTargetKey(target: VcsStatusTarget): string | null { | ||
| 71 | if (target.environmentId === null || target.cwd === null) { | ||
| 72 | return null; | ||
| 73 | } | ||
| 74 | return `${target.environmentId}:${target.cwd}`; | ||
| 75 | } | ||
| 76 | |||
| 77 | export function getVcsStatusDataForTarget( | ||
| 78 | state: VcsStatusState, | ||
| 79 | target: VcsStatusTarget, | ||
| 80 | ): VcsStatusResult | null { | ||
| 81 | const targetKey = getVcsStatusTargetKey(target); | ||
| 82 | return targetKey !== null && state.targetKey === targetKey ? state.data : null; | ||
| 83 | } | ||
| 84 | |||
| 85 | /* ─── Subscription manager ──────────────────────────────────────────── */ | ||
| 86 | |||
| 87 | export interface VcsStatusManagerConfig { | ||
| 88 | /** | ||
| 89 | * Get the atom registry to read/write VCS status atoms. | ||
| 90 | */ | ||
| 91 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 92 | /** Resolve a VCS client for an environment. */ | ||
| 93 | readonly getClient: (environmentId: EnvironmentId) => VcsStatusClient | null; | ||
| 94 | /** | ||
| 95 | * Optional: get a stable identity for the current client. | ||
| 96 | * Used to detect reconnections — when the identity changes the | ||
| 97 | * manager tears down the old `onStatus` stream and subscribes anew. | ||
| 98 | */ | ||
| 99 | readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; | ||
| 100 | /** | ||
| 101 | * Optional: subscribe to environment-connection changes. | ||
| 102 | * When provided the manager reacts to client appear / disappear / | ||
| 103 | * reconnect events instead of doing a one-shot resolution. | ||
| 104 | */ | ||
| 105 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 106 | } | ||
| 107 | |||
| 108 | const VCS_STATUS_REFRESH_DEBOUNCE_MS = 1_000; | ||
| 109 | const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); | ||
| 110 | |||
| 111 | export function createVcsStatusManager(config: VcsStatusManagerConfig) { | ||
| 112 | const watched = new Map<string, WatchedEntry>(); | ||
| 113 | const refreshInFlight = new Map<string, Promise<VcsStatusResult>>(); | ||
| 114 | const lastRefreshAt = new Map<string, number>(); | ||
| 115 | |||
| 116 | /* ── Atom helpers ───────────────────────────────────────────────── */ | ||
| 117 | |||
| 118 | function markPending(targetKey: string): void { | ||
| 119 | const atom = vcsStatusStateAtom(targetKey); | ||
| 120 | const current = config.getRegistry().get(atom); | ||
| 121 | const next: VcsStatusState = | ||
| 122 | current.data === null | ||
| 123 | ? initialVcsStatusState(targetKey) | ||
| 124 | : { ...current, error: null, cause: null, isPending: true }; | ||
| 125 | if ( | ||
| 126 | current.data === next.data && | ||
| 127 | current.error === next.error && | ||
| 128 | current.cause === next.cause && | ||
| 129 | current.isPending === next.isPending | ||
| 130 | ) { | ||
| 131 | return; | ||
| 132 | } | ||
| 133 | config.getRegistry().set(atom, next); | ||
| 134 | } | ||
| 135 | |||
| 136 | function setData(targetKey: string, status: VcsStatusResult): void { | ||
| 137 | config.getRegistry().set(vcsStatusStateAtom(targetKey), { | ||
| 138 | targetKey, | ||
| 139 | data: status, | ||
| 140 | error: null, | ||
| 141 | cause: null, | ||
| 142 | isPending: false, | ||
| 143 | }); | ||
| 144 | } | ||
| 145 | |||
| 146 | /* ── Core subscription ──────────────────────────────────────────── */ | ||
| 147 | |||
| 148 | function subscribeStream(targetKey: string, cwd: string, client: VcsStatusClient): () => void { | ||
| 149 | markPending(targetKey); | ||
| 150 | return client.onStatus({ cwd }, (status) => setData(targetKey, status), { | ||
| 151 | onResubscribe: () => markPending(targetKey), | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | |||
| 155 | /* ── Dynamic subscription (handles reconnection) ────────────────── */ | ||
| 156 | |||
| 157 | function createDynamicSubscription(targetKey: string, target: VcsStatusTarget): () => void { | ||
| 158 | const environmentId = target.environmentId!; | ||
| 159 | const cwd = target.cwd!; | ||
| 160 | let currentIdentity: string | null = null; | ||
| 161 | let currentUnsub = NOOP; | ||
| 162 | |||
| 163 | const sync = () => { | ||
| 164 | const client = config.getClient(environmentId); | ||
| 165 | const identity = client ? (config.getClientIdentity?.(environmentId) ?? environmentId) : null; | ||
| 166 | |||
| 167 | if (!client || identity === null) { | ||
| 168 | if (currentIdentity !== null) { | ||
| 169 | currentUnsub(); | ||
| 170 | currentUnsub = NOOP; | ||
| 171 | currentIdentity = null; | ||
| 172 | } | ||
| 173 | markPending(targetKey); | ||
| 174 | return; | ||
| 175 | } | ||
| 176 | |||
| 177 | if (currentIdentity === identity) return; | ||
| 178 | |||
| 179 | currentUnsub(); | ||
| 180 | currentIdentity = identity; | ||
| 181 | currentUnsub = subscribeStream(targetKey, cwd, client); | ||
| 182 | }; | ||
| 183 | |||
| 184 | const unsubChanges = config.subscribeClientChanges!(sync); | ||
| 185 | sync(); | ||
| 186 | |||
| 187 | return () => { | ||
| 188 | unsubChanges(); | ||
| 189 | currentUnsub(); | ||
| 190 | }; | ||
| 191 | } | ||
| 192 | |||
| 193 | /* ── Public API ─────────────────────────────────────────────────── */ | ||
| 194 | |||
| 195 | /** | ||
| 196 | * Begin watching VCS status for `target`. | ||
| 197 | * | ||
| 198 | * Multiple watchers sharing the same `environmentId:cwd` key share | ||
| 199 | * one `onStatus` WS subscription (ref-counted). | ||
| 200 | * | ||
| 201 | * @param target The environment + cwd to watch. | ||
| 202 | * @param client Optional pre-resolved client — skips `getClient` | ||
| 203 | * lookup and reconnection handling. Useful in tests. | ||
| 204 | * @returns An unwatch function. | ||
| 205 | */ | ||
| 206 | function watch(target: VcsStatusTarget, client?: VcsStatusClient): () => void { | ||
| 207 | const targetKey = getVcsStatusTargetKey(target); | ||
| 208 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 209 | return NOOP; | ||
| 210 | } | ||
| 211 | |||
| 212 | const existing = watched.get(targetKey); | ||
| 213 | if (existing) { | ||
| 214 | existing.refCount += 1; | ||
| 215 | return () => unwatch(targetKey); | ||
| 216 | } | ||
| 217 | |||
| 218 | let teardown: () => void; | ||
| 219 | |||
| 220 | if (client) { | ||
| 221 | // Explicit client — direct subscription, no reconnection handling. | ||
| 222 | teardown = subscribeStream(targetKey, target.cwd, client); | ||
| 223 | } else if (config.subscribeClientChanges) { | ||
| 224 | // Dynamic client — subscribe to connection changes for reconnection. | ||
| 225 | teardown = createDynamicSubscription(targetKey, target); | ||
| 226 | } else { | ||
| 227 | // One-shot client resolution. | ||
| 228 | const resolved = config.getClient(target.environmentId); | ||
| 229 | if (!resolved) return NOOP; | ||
| 230 | teardown = subscribeStream(targetKey, target.cwd, resolved); | ||
| 231 | } | ||
| 232 | |||
| 233 | watched.set(targetKey, { refCount: 1, teardown }); | ||
| 234 | return () => unwatch(targetKey); | ||
| 235 | } | ||
| 236 | |||
| 237 | function unwatch(targetKey: string): void { | ||
| 238 | const entry = watched.get(targetKey); | ||
| 239 | if (!entry) return; | ||
| 240 | |||
| 241 | entry.refCount -= 1; | ||
| 242 | if (entry.refCount > 0) return; | ||
| 243 | |||
| 244 | entry.teardown(); | ||
| 245 | watched.delete(targetKey); | ||
| 246 | } | ||
| 247 | |||
| 248 | /** | ||
| 249 | * Trigger a one-shot `refreshStatus` RPC for a target. | ||
| 250 | * Debounced (1 s) and deduplicated (in-flight). | ||
| 251 | * The server-side refresh pushes a new event on the existing | ||
| 252 | * `onStatus` stream, so the subscription picks it up automatically. | ||
| 253 | */ | ||
| 254 | function refresh( | ||
| 255 | target: VcsStatusTarget, | ||
| 256 | client?: VcsStatusClient, | ||
| 257 | ): Promise<VcsStatusResult | null> { | ||
| 258 | const targetKey = getVcsStatusTargetKey(target); | ||
| 259 | if (targetKey === null || target.cwd === null) { | ||
| 260 | return Promise.resolve(null); | ||
| 261 | } | ||
| 262 | |||
| 263 | const resolved = | ||
| 264 | client ?? (target.environmentId ? config.getClient(target.environmentId) : null); | ||
| 265 | if (!resolved) { | ||
| 266 | return Promise.resolve(getSnapshot(target).data); | ||
| 267 | } | ||
| 268 | |||
| 269 | const existing = refreshInFlight.get(targetKey); | ||
| 270 | if (existing) return existing; | ||
| 271 | |||
| 272 | const requestedAt = nowMs(); | ||
| 273 | const last = lastRefreshAt.get(targetKey) ?? 0; | ||
| 274 | if (requestedAt - last < VCS_STATUS_REFRESH_DEBOUNCE_MS) { | ||
| 275 | return Promise.resolve(getSnapshot(target).data); | ||
| 276 | } | ||
| 277 | |||
| 278 | lastRefreshAt.set(targetKey, requestedAt); | ||
| 279 | const promise = resolved | ||
| 280 | .refreshStatus({ cwd: target.cwd }) | ||
| 281 | .finally(() => refreshInFlight.delete(targetKey)); | ||
| 282 | refreshInFlight.set(targetKey, promise); | ||
| 283 | return promise; | ||
| 284 | } | ||
| 285 | |||
| 286 | function getSnapshot(target: VcsStatusTarget): VcsStatusState { | ||
| 287 | const targetKey = getVcsStatusTargetKey(target); | ||
| 288 | if (targetKey === null) return EMPTY_VCS_STATUS_STATE; | ||
| 289 | return config.getRegistry().get(vcsStatusStateAtom(targetKey)); | ||
| 290 | } | ||
| 291 | |||
| 292 | function reset(): void { | ||
| 293 | for (const entry of watched.values()) { | ||
| 294 | entry.teardown(); | ||
| 295 | } | ||
| 296 | watched.clear(); | ||
| 297 | refreshInFlight.clear(); | ||
| 298 | lastRefreshAt.clear(); | ||
| 299 | for (const key of knownVcsStatusKeys) { | ||
| 300 | config.getRegistry().set(vcsStatusStateAtom(key), initialVcsStatusState(key)); | ||
| 301 | } | ||
| 302 | knownVcsStatusKeys.clear(); | ||
| 303 | } | ||
| 304 | |||
| 305 | return { watch, refresh, getSnapshot, reset }; | ||
| 306 | } | ||
packages/client-runtime/src/wsRpcClient.test.ts deleted-186| ... | @@ -1,186 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | VcsStatusLocalResult, | ||
| 3 | VcsStatusRemoteResult, | ||
| 4 | VcsStatusStreamEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { ORCHESTRATION_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 8 | |||
| 9 | vi.mock("./wsTransport.ts", () => ({ | ||
| 10 | WsTransport: class WsTransport { | ||
| 11 | dispose = vi.fn(async () => undefined); | ||
| 12 | reconnect = vi.fn(async () => undefined); | ||
| 13 | request = vi.fn(); | ||
| 14 | requestStream = vi.fn(); | ||
| 15 | subscribe = vi.fn(() => () => undefined); | ||
| 16 | }, | ||
| 17 | })); | ||
| 18 | |||
| 19 | import { createWsRpcClient } from "./wsRpcClient.ts"; | ||
| 20 | import type { WsTransport } from "./wsTransport.ts"; | ||
| 21 | |||
| 22 | const baseLocalStatus: VcsStatusLocalResult = { | ||
| 23 | isRepo: true, | ||
| 24 | hasPrimaryRemote: true, | ||
| 25 | isDefaultRef: false, | ||
| 26 | refName: "feature/demo", | ||
| 27 | hasWorkingTreeChanges: false, | ||
| 28 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 29 | }; | ||
| 30 | |||
| 31 | const baseRemoteStatus: VcsStatusRemoteResult = { | ||
| 32 | hasUpstream: true, | ||
| 33 | aheadCount: 0, | ||
| 34 | behindCount: 0, | ||
| 35 | pr: null, | ||
| 36 | }; | ||
| 37 | |||
| 38 | describe("createWsRpcClient", () => { | ||
| 39 | it("runs beforeReconnect before awaiting transport.reconnect", async () => { | ||
| 40 | const order: string[] = []; | ||
| 41 | const transport = { | ||
| 42 | dispose: vi.fn(async () => undefined), | ||
| 43 | reconnect: vi.fn(async () => { | ||
| 44 | order.push("reconnect"); | ||
| 45 | }), | ||
| 46 | isHeartbeatFresh: vi.fn(() => true), | ||
| 47 | request: vi.fn(), | ||
| 48 | requestStream: vi.fn(), | ||
| 49 | subscribe: vi.fn(() => () => undefined), | ||
| 50 | } satisfies Pick< | ||
| 51 | WsTransport, | ||
| 52 | "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" | ||
| 53 | >; | ||
| 54 | |||
| 55 | const client = createWsRpcClient(transport as unknown as WsTransport, { | ||
| 56 | beforeReconnect: () => { | ||
| 57 | order.push("beforeReconnect"); | ||
| 58 | }, | ||
| 59 | }); | ||
| 60 | |||
| 61 | await client.reconnect(); | ||
| 62 | expect(order).toEqual(["beforeReconnect", "reconnect"]); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("delegates heartbeat freshness to the transport", () => { | ||
| 66 | const isHeartbeatFresh = vi.fn(() => true); | ||
| 67 | const transport = { | ||
| 68 | dispose: vi.fn(async () => undefined), | ||
| 69 | reconnect: vi.fn(async () => undefined), | ||
| 70 | isHeartbeatFresh, | ||
| 71 | request: vi.fn(), | ||
| 72 | requestStream: vi.fn(), | ||
| 73 | subscribe: vi.fn(() => () => undefined), | ||
| 74 | } satisfies Pick< | ||
| 75 | WsTransport, | ||
| 76 | "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" | ||
| 77 | >; | ||
| 78 | |||
| 79 | const client = createWsRpcClient(transport as unknown as WsTransport); | ||
| 80 | |||
| 81 | expect(client.isHeartbeatFresh()).toBe(true); | ||
| 82 | expect(isHeartbeatFresh).toHaveBeenCalledOnce(); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("reduces vcs status stream events into flat status snapshots", () => { | ||
| 86 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { | ||
| 87 | for (const event of [ | ||
| 88 | { | ||
| 89 | _tag: "snapshot", | ||
| 90 | local: baseLocalStatus, | ||
| 91 | remote: null, | ||
| 92 | }, | ||
| 93 | { | ||
| 94 | _tag: "remoteUpdated", | ||
| 95 | remote: baseRemoteStatus, | ||
| 96 | }, | ||
| 97 | { | ||
| 98 | _tag: "localUpdated", | ||
| 99 | local: { | ||
| 100 | ...baseLocalStatus, | ||
| 101 | hasWorkingTreeChanges: true, | ||
| 102 | }, | ||
| 103 | }, | ||
| 104 | ] satisfies VcsStatusStreamEvent[]) { | ||
| 105 | listener(event as TValue); | ||
| 106 | } | ||
| 107 | return () => undefined; | ||
| 108 | }); | ||
| 109 | |||
| 110 | const transport = { | ||
| 111 | dispose: vi.fn(async () => undefined), | ||
| 112 | reconnect: vi.fn(async () => undefined), | ||
| 113 | isHeartbeatFresh: vi.fn(() => true), | ||
| 114 | request: vi.fn(), | ||
| 115 | requestStream: vi.fn(), | ||
| 116 | subscribe, | ||
| 117 | } satisfies Pick< | ||
| 118 | WsTransport, | ||
| 119 | "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" | ||
| 120 | >; | ||
| 121 | |||
| 122 | const client = createWsRpcClient(transport as unknown as WsTransport); | ||
| 123 | const listener = vi.fn(); | ||
| 124 | |||
| 125 | client.vcs.onStatus({ cwd: "/repo" }, listener); | ||
| 126 | |||
| 127 | expect(listener.mock.calls).toEqual([ | ||
| 128 | [ | ||
| 129 | { | ||
| 130 | ...baseLocalStatus, | ||
| 131 | hasUpstream: false, | ||
| 132 | aheadCount: 0, | ||
| 133 | behindCount: 0, | ||
| 134 | aheadOfDefaultCount: 0, | ||
| 135 | pr: null, | ||
| 136 | }, | ||
| 137 | ], | ||
| 138 | [ | ||
| 139 | { | ||
| 140 | ...baseLocalStatus, | ||
| 141 | ...baseRemoteStatus, | ||
| 142 | }, | ||
| 143 | ], | ||
| 144 | [ | ||
| 145 | { | ||
| 146 | ...baseLocalStatus, | ||
| 147 | ...baseRemoteStatus, | ||
| 148 | hasWorkingTreeChanges: true, | ||
| 149 | }, | ||
| 150 | ], | ||
| 151 | ]); | ||
| 152 | }); | ||
| 153 | |||
| 154 | it("tags stream subscriptions for targeted resubscribe handling", () => { | ||
| 155 | const subscribe = vi.fn(() => () => undefined); | ||
| 156 | const transport = { | ||
| 157 | dispose: vi.fn(async () => undefined), | ||
| 158 | reconnect: vi.fn(async () => undefined), | ||
| 159 | isHeartbeatFresh: vi.fn(() => true), | ||
| 160 | request: vi.fn(), | ||
| 161 | requestStream: vi.fn(), | ||
| 162 | subscribe, | ||
| 163 | } satisfies Pick< | ||
| 164 | WsTransport, | ||
| 165 | "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" | ||
| 166 | >; | ||
| 167 | |||
| 168 | const client = createWsRpcClient(transport as unknown as WsTransport); | ||
| 169 | const listener = vi.fn(); | ||
| 170 | |||
| 171 | client.terminal.onMetadata(listener); | ||
| 172 | client.vcs.onStatus({ cwd: "/repo" }, listener); | ||
| 173 | client.server.subscribeConfig(listener); | ||
| 174 | client.orchestration.subscribeThread({ threadId: ThreadId.make("thread-1") }, listener); | ||
| 175 | |||
| 176 | const subscribeCalls = subscribe.mock.calls as unknown as Array< | ||
| 177 | readonly [unknown, unknown, { readonly tag?: string }?] | ||
| 178 | >; | ||
| 179 | expect(subscribeCalls.map((call) => call[2]?.tag)).toEqual([ | ||
| 180 | WS_METHODS.subscribeTerminalMetadata, | ||
| 181 | WS_METHODS.subscribeVcsStatus, | ||
| 182 | WS_METHODS.subscribeServerConfig, | ||
| 183 | ORCHESTRATION_WS_METHODS.subscribeThread, | ||
| 184 | ]); | ||
| 185 | }); | ||
| 186 | }); | ||
packages/client-runtime/src/wsRpcClient.ts deleted-440| ... | @@ -1,440 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type GitActionProgressEvent, | ||
| 3 | type GitRunStackedActionInput, | ||
| 4 | type GitRunStackedActionResult, | ||
| 5 | type LocalApi, | ||
| 6 | ORCHESTRATION_WS_METHODS, | ||
| 7 | type RelayClientInstallProgressEvent, | ||
| 8 | type RelayClientStatus, | ||
| 9 | type ServerSettingsPatch, | ||
| 10 | type VcsStatusResult, | ||
| 11 | type VcsStatusStreamEvent, | ||
| 12 | WS_METHODS, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; | ||
| 15 | import type * as Effect from "effect/Effect"; | ||
| 16 | import type * as Stream from "effect/Stream"; | ||
| 17 | |||
| 18 | import { type WsRpcProtocolClient } from "./wsRpcProtocol.ts"; | ||
| 19 | import { WsTransport } from "./wsTransport.ts"; | ||
| 20 | |||
| 21 | type RpcTag = keyof WsRpcProtocolClient & string; | ||
| 22 | type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag]; | ||
| 23 | type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0]; | ||
| 24 | |||
| 25 | interface StreamSubscriptionOptions { | ||
| 26 | readonly onResubscribe?: () => void; | ||
| 27 | } | ||
| 28 | |||
| 29 | function subscriptionOptions( | ||
| 30 | options: StreamSubscriptionOptions | undefined, | ||
| 31 | tag: string, | ||
| 32 | ): StreamSubscriptionOptions & { readonly tag: string } { | ||
| 33 | return { | ||
| 34 | ...options, | ||
| 35 | tag, | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | |||
| 39 | type RpcUnaryMethod<TTag extends RpcTag> = | ||
| 40 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | ||
| 41 | ? (input: RpcInput<TTag>) => Promise<TSuccess> | ||
| 42 | : never; | ||
| 43 | |||
| 44 | type RpcUnaryNoArgMethod<TTag extends RpcTag> = | ||
| 45 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | ||
| 46 | ? () => Promise<TSuccess> | ||
| 47 | : never; | ||
| 48 | |||
| 49 | type RpcStreamMethod<TTag extends RpcTag> = | ||
| 50 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any> | ||
| 51 | ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void | ||
| 52 | : never; | ||
| 53 | |||
| 54 | type RpcInputStreamMethod<TTag extends RpcTag> = | ||
| 55 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any> | ||
| 56 | ? ( | ||
| 57 | input: RpcInput<TTag>, | ||
| 58 | listener: (event: TEvent) => void, | ||
| 59 | options?: StreamSubscriptionOptions, | ||
| 60 | ) => () => void | ||
| 61 | : never; | ||
| 62 | |||
| 63 | interface GitRunStackedActionOptions { | ||
| 64 | readonly onProgress?: (event: GitActionProgressEvent) => void; | ||
| 65 | } | ||
| 66 | |||
| 67 | export interface WsRpcClient { | ||
| 68 | readonly dispose: () => Promise<void>; | ||
| 69 | readonly reconnect: () => Promise<void>; | ||
| 70 | readonly isHeartbeatFresh: () => boolean; | ||
| 71 | readonly terminal: { | ||
| 72 | readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>; | ||
| 73 | readonly attach: RpcInputStreamMethod<typeof WS_METHODS.terminalAttach>; | ||
| 74 | readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>; | ||
| 75 | readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>; | ||
| 76 | readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>; | ||
| 77 | readonly restart: RpcUnaryMethod<typeof WS_METHODS.terminalRestart>; | ||
| 78 | readonly close: RpcUnaryMethod<typeof WS_METHODS.terminalClose>; | ||
| 79 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>; | ||
| 80 | readonly onMetadata: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalMetadata>; | ||
| 81 | }; | ||
| 82 | readonly preview: { | ||
| 83 | readonly open: RpcUnaryMethod<typeof WS_METHODS.previewOpen>; | ||
| 84 | readonly navigate: RpcUnaryMethod<typeof WS_METHODS.previewNavigate>; | ||
| 85 | readonly refresh: RpcUnaryMethod<typeof WS_METHODS.previewRefresh>; | ||
| 86 | readonly close: RpcUnaryMethod<typeof WS_METHODS.previewClose>; | ||
| 87 | readonly list: RpcUnaryMethod<typeof WS_METHODS.previewList>; | ||
| 88 | readonly reportStatus: RpcUnaryMethod<typeof WS_METHODS.previewReportStatus>; | ||
| 89 | readonly automation: { | ||
| 90 | readonly connect: RpcInputStreamMethod<typeof WS_METHODS.previewAutomationConnect>; | ||
| 91 | readonly respond: RpcUnaryMethod<typeof WS_METHODS.previewAutomationRespond>; | ||
| 92 | readonly reportOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationReportOwner>; | ||
| 93 | readonly clearOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationClearOwner>; | ||
| 94 | }; | ||
| 95 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribePreviewEvents>; | ||
| 96 | readonly subscribePorts: RpcStreamMethod<typeof WS_METHODS.subscribeDiscoveredLocalServers>; | ||
| 97 | }; | ||
| 98 | readonly projects: { | ||
| 99 | readonly listEntries: RpcUnaryMethod<typeof WS_METHODS.projectsListEntries>; | ||
| 100 | readonly readFile: RpcUnaryMethod<typeof WS_METHODS.projectsReadFile>; | ||
| 101 | readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>; | ||
| 102 | readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>; | ||
| 103 | }; | ||
| 104 | readonly filesystem: { | ||
| 105 | readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>; | ||
| 106 | }; | ||
| 107 | readonly assets: { | ||
| 108 | readonly createUrl: RpcUnaryMethod<typeof WS_METHODS.assetsCreateUrl>; | ||
| 109 | }; | ||
| 110 | readonly sourceControl: { | ||
| 111 | readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>; | ||
| 112 | readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>; | ||
| 113 | readonly publishRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlPublishRepository>; | ||
| 114 | }; | ||
| 115 | readonly shell: { | ||
| 116 | readonly openInEditor: (input: { | ||
| 117 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; | ||
| 118 | readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1]; | ||
| 119 | }) => ReturnType<LocalApi["shell"]["openInEditor"]>; | ||
| 120 | }; | ||
| 121 | readonly vcs: { | ||
| 122 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.vcsPull>; | ||
| 123 | readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.vcsRefreshStatus>; | ||
| 124 | readonly onStatus: ( | ||
| 125 | input: RpcInput<typeof WS_METHODS.subscribeVcsStatus>, | ||
| 126 | listener: (status: VcsStatusResult) => void, | ||
| 127 | options?: StreamSubscriptionOptions, | ||
| 128 | ) => () => void; | ||
| 129 | readonly listRefs: RpcUnaryMethod<typeof WS_METHODS.vcsListRefs>; | ||
| 130 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsCreateWorktree>; | ||
| 131 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsRemoveWorktree>; | ||
| 132 | readonly createRef: RpcUnaryMethod<typeof WS_METHODS.vcsCreateRef>; | ||
| 133 | readonly switchRef: RpcUnaryMethod<typeof WS_METHODS.vcsSwitchRef>; | ||
| 134 | readonly init: RpcUnaryMethod<typeof WS_METHODS.vcsInit>; | ||
| 135 | }; | ||
| 136 | readonly git: { | ||
| 137 | readonly runStackedAction: ( | ||
| 138 | input: GitRunStackedActionInput, | ||
| 139 | options?: GitRunStackedActionOptions, | ||
| 140 | ) => Promise<GitRunStackedActionResult>; | ||
| 141 | readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>; | ||
| 142 | readonly preparePullRequestThread: RpcUnaryMethod< | ||
| 143 | typeof WS_METHODS.gitPreparePullRequestThread | ||
| 144 | >; | ||
| 145 | }; | ||
| 146 | readonly review: { | ||
| 147 | readonly getDiffPreview: RpcUnaryMethod<typeof WS_METHODS.reviewGetDiffPreview>; | ||
| 148 | }; | ||
| 149 | readonly server: { | ||
| 150 | readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>; | ||
| 151 | readonly refreshProviders: ( | ||
| 152 | input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>, | ||
| 153 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>; | ||
| 154 | readonly discoverSourceControl: RpcUnaryNoArgMethod< | ||
| 155 | typeof WS_METHODS.serverDiscoverSourceControl | ||
| 156 | >; | ||
| 157 | readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>; | ||
| 158 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; | ||
| 159 | readonly removeKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverRemoveKeybinding>; | ||
| 160 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; | ||
| 161 | readonly updateSettings: ( | ||
| 162 | patch: ServerSettingsPatch, | ||
| 163 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>; | ||
| 164 | readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>; | ||
| 165 | readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>; | ||
| 166 | readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>; | ||
| 167 | readonly getTraceDiagnostics: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetTraceDiagnostics>; | ||
| 168 | readonly getProcessDiagnostics: RpcUnaryNoArgMethod< | ||
| 169 | typeof WS_METHODS.serverGetProcessDiagnostics | ||
| 170 | >; | ||
| 171 | readonly getProcessResourceHistory: RpcUnaryMethod< | ||
| 172 | typeof WS_METHODS.serverGetProcessResourceHistory | ||
| 173 | >; | ||
| 174 | readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>; | ||
| 175 | }; | ||
| 176 | readonly cloud: { | ||
| 177 | readonly getRelayClientStatus: RpcUnaryNoArgMethod<typeof WS_METHODS.cloudGetRelayClientStatus>; | ||
| 178 | readonly installRelayClient: ( | ||
| 179 | onProgress?: (event: RelayClientInstallProgressEvent) => void, | ||
| 180 | ) => Promise<RelayClientStatus>; | ||
| 181 | }; | ||
| 182 | readonly orchestration: { | ||
| 183 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; | ||
| 184 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; | ||
| 185 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; | ||
| 186 | readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod< | ||
| 187 | typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot | ||
| 188 | >; | ||
| 189 | readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>; | ||
| 190 | readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>; | ||
| 191 | }; | ||
| 192 | } | ||
| 193 | |||
| 194 | export interface CreateWsRpcClientOptions { | ||
| 195 | /** Runs immediately before `transport.reconnect()` (e.g. reset reconnect UI/backoff state). */ | ||
| 196 | readonly beforeReconnect?: () => void; | ||
| 197 | } | ||
| 198 | |||
| 199 | export function createWsRpcClient( | ||
| 200 | transport: WsTransport, | ||
| 201 | options?: CreateWsRpcClientOptions, | ||
| 202 | ): WsRpcClient { | ||
| 203 | return { | ||
| 204 | dispose: () => transport.dispose(), | ||
| 205 | isHeartbeatFresh: () => transport.isHeartbeatFresh(), | ||
| 206 | reconnect: async () => { | ||
| 207 | options?.beforeReconnect?.(); | ||
| 208 | await transport.reconnect(); | ||
| 209 | }, | ||
| 210 | terminal: { | ||
| 211 | open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), | ||
| 212 | attach: (input, listener, options) => | ||
| 213 | transport.subscribe( | ||
| 214 | (client) => client[WS_METHODS.terminalAttach](input), | ||
| 215 | listener, | ||
| 216 | subscriptionOptions(options, WS_METHODS.terminalAttach), | ||
| 217 | ), | ||
| 218 | write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), | ||
| 219 | resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), | ||
| 220 | clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), | ||
| 221 | restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), | ||
| 222 | close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), | ||
| 223 | onEvent: (listener, options) => | ||
| 224 | transport.subscribe( | ||
| 225 | (client) => client[WS_METHODS.subscribeTerminalEvents]({}), | ||
| 226 | listener, | ||
| 227 | subscriptionOptions(options, WS_METHODS.subscribeTerminalEvents), | ||
| 228 | ), | ||
| 229 | onMetadata: (listener, options) => | ||
| 230 | transport.subscribe( | ||
| 231 | (client) => client[WS_METHODS.subscribeTerminalMetadata]({}), | ||
| 232 | listener, | ||
| 233 | subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata), | ||
| 234 | ), | ||
| 235 | }, | ||
| 236 | preview: { | ||
| 237 | open: (input) => transport.request((client) => client[WS_METHODS.previewOpen](input)), | ||
| 238 | navigate: (input) => transport.request((client) => client[WS_METHODS.previewNavigate](input)), | ||
| 239 | refresh: (input) => transport.request((client) => client[WS_METHODS.previewRefresh](input)), | ||
| 240 | close: (input) => transport.request((client) => client[WS_METHODS.previewClose](input)), | ||
| 241 | list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), | ||
| 242 | reportStatus: (input) => | ||
| 243 | transport.request((client) => client[WS_METHODS.previewReportStatus](input)), | ||
| 244 | automation: { | ||
| 245 | connect: (input, listener, options) => | ||
| 246 | transport.subscribe( | ||
| 247 | (client) => client[WS_METHODS.previewAutomationConnect](input), | ||
| 248 | listener, | ||
| 249 | subscriptionOptions(options, WS_METHODS.previewAutomationConnect), | ||
| 250 | ), | ||
| 251 | respond: (input) => | ||
| 252 | transport.request((client) => client[WS_METHODS.previewAutomationRespond](input)), | ||
| 253 | reportOwner: (input) => | ||
| 254 | transport.request((client) => client[WS_METHODS.previewAutomationReportOwner](input)), | ||
| 255 | clearOwner: (input) => | ||
| 256 | transport.request((client) => client[WS_METHODS.previewAutomationClearOwner](input)), | ||
| 257 | }, | ||
| 258 | onEvent: (listener, options) => | ||
| 259 | transport.subscribe( | ||
| 260 | (client) => client[WS_METHODS.subscribePreviewEvents]({}), | ||
| 261 | listener, | ||
| 262 | options, | ||
| 263 | ), | ||
| 264 | subscribePorts: (listener, options) => | ||
| 265 | transport.subscribe( | ||
| 266 | (client) => client[WS_METHODS.subscribeDiscoveredLocalServers]({}), | ||
| 267 | listener, | ||
| 268 | options, | ||
| 269 | ), | ||
| 270 | }, | ||
| 271 | projects: { | ||
| 272 | listEntries: (input) => | ||
| 273 | transport.request((client) => client[WS_METHODS.projectsListEntries](input)), | ||
| 274 | readFile: (input) => | ||
| 275 | transport.request((client) => client[WS_METHODS.projectsReadFile](input)), | ||
| 276 | searchEntries: (input) => | ||
| 277 | transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), | ||
| 278 | writeFile: (input) => | ||
| 279 | transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), | ||
| 280 | }, | ||
| 281 | filesystem: { | ||
| 282 | browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), | ||
| 283 | }, | ||
| 284 | assets: { | ||
| 285 | createUrl: (input) => | ||
| 286 | transport.request((client) => client[WS_METHODS.assetsCreateUrl](input)), | ||
| 287 | }, | ||
| 288 | sourceControl: { | ||
| 289 | lookupRepository: (input) => | ||
| 290 | transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), | ||
| 291 | cloneRepository: (input) => | ||
| 292 | transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)), | ||
| 293 | publishRepository: (input) => | ||
| 294 | transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)), | ||
| 295 | }, | ||
| 296 | shell: { | ||
| 297 | openInEditor: (input) => | ||
| 298 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), | ||
| 299 | }, | ||
| 300 | vcs: { | ||
| 301 | pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), | ||
| 302 | refreshStatus: (input) => | ||
| 303 | transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), | ||
| 304 | onStatus: (input, listener, options) => { | ||
| 305 | let current: VcsStatusResult | null = null; | ||
| 306 | return transport.subscribe( | ||
| 307 | (client) => client[WS_METHODS.subscribeVcsStatus](input), | ||
| 308 | (event: VcsStatusStreamEvent) => { | ||
| 309 | current = applyGitStatusStreamEvent(current, event); | ||
| 310 | listener(current); | ||
| 311 | }, | ||
| 312 | subscriptionOptions(options, WS_METHODS.subscribeVcsStatus), | ||
| 313 | ); | ||
| 314 | }, | ||
| 315 | listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), | ||
| 316 | createWorktree: (input) => | ||
| 317 | transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), | ||
| 318 | removeWorktree: (input) => | ||
| 319 | transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), | ||
| 320 | createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), | ||
| 321 | switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), | ||
| 322 | init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), | ||
| 323 | }, | ||
| 324 | git: { | ||
| 325 | runStackedAction: async (input, options) => { | ||
| 326 | let result: GitRunStackedActionResult | null = null; | ||
| 327 | |||
| 328 | await transport.requestStream( | ||
| 329 | (client) => client[WS_METHODS.gitRunStackedAction](input), | ||
| 330 | (event) => { | ||
| 331 | options?.onProgress?.(event); | ||
| 332 | if (event.kind === "action_finished") { | ||
| 333 | result = event.result; | ||
| 334 | } | ||
| 335 | }, | ||
| 336 | ); | ||
| 337 | |||
| 338 | if (result) { | ||
| 339 | return result; | ||
| 340 | } | ||
| 341 | |||
| 342 | throw new Error("Git action stream completed without a final result."); | ||
| 343 | }, | ||
| 344 | resolvePullRequest: (input) => | ||
| 345 | transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), | ||
| 346 | preparePullRequestThread: (input) => | ||
| 347 | transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), | ||
| 348 | }, | ||
| 349 | review: { | ||
| 350 | getDiffPreview: (input) => | ||
| 351 | transport.request((client) => client[WS_METHODS.reviewGetDiffPreview](input)), | ||
| 352 | }, | ||
| 353 | server: { | ||
| 354 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), | ||
| 355 | refreshProviders: (input) => | ||
| 356 | transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), | ||
| 357 | discoverSourceControl: () => | ||
| 358 | transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), | ||
| 359 | updateProvider: (input) => | ||
| 360 | transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), | ||
| 361 | upsertKeybinding: (input) => | ||
| 362 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), | ||
| 363 | removeKeybinding: (input) => | ||
| 364 | transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)), | ||
| 365 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | ||
| 366 | updateSettings: (patch) => | ||
| 367 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), | ||
| 368 | subscribeConfig: (listener, options) => | ||
| 369 | transport.subscribe( | ||
| 370 | (client) => client[WS_METHODS.subscribeServerConfig]({}), | ||
| 371 | listener, | ||
| 372 | subscriptionOptions(options, WS_METHODS.subscribeServerConfig), | ||
| 373 | ), | ||
| 374 | subscribeLifecycle: (listener, options) => | ||
| 375 | transport.subscribe( | ||
| 376 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 377 | listener, | ||
| 378 | subscriptionOptions(options, WS_METHODS.subscribeServerLifecycle), | ||
| 379 | ), | ||
| 380 | subscribeAuthAccess: (listener, options) => | ||
| 381 | transport.subscribe( | ||
| 382 | (client) => client[WS_METHODS.subscribeAuthAccess]({}), | ||
| 383 | listener, | ||
| 384 | subscriptionOptions(options, WS_METHODS.subscribeAuthAccess), | ||
| 385 | ), | ||
| 386 | getTraceDiagnostics: () => | ||
| 387 | transport.request((client) => client[WS_METHODS.serverGetTraceDiagnostics]({})), | ||
| 388 | getProcessDiagnostics: () => | ||
| 389 | transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})), | ||
| 390 | getProcessResourceHistory: (input) => | ||
| 391 | transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)), | ||
| 392 | signalProcess: (input) => | ||
| 393 | transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), | ||
| 394 | }, | ||
| 395 | cloud: { | ||
| 396 | getRelayClientStatus: () => | ||
| 397 | transport.request((client) => client[WS_METHODS.cloudGetRelayClientStatus]({})), | ||
| 398 | installRelayClient: async (onProgress) => { | ||
| 399 | let installed: RelayClientStatus | null = null; | ||
| 400 | await transport.requestStream( | ||
| 401 | (client) => client[WS_METHODS.cloudInstallRelayClient]({}), | ||
| 402 | (event) => { | ||
| 403 | onProgress?.(event); | ||
| 404 | if (event.type === "complete") { | ||
| 405 | installed = event.status; | ||
| 406 | } | ||
| 407 | }, | ||
| 408 | ); | ||
| 409 | if (installed) { | ||
| 410 | return installed; | ||
| 411 | } | ||
| 412 | throw new Error("Relay client install stream completed without a final status."); | ||
| 413 | }, | ||
| 414 | }, | ||
| 415 | orchestration: { | ||
| 416 | dispatchCommand: (input) => | ||
| 417 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), | ||
| 418 | getTurnDiff: (input) => | ||
| 419 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), | ||
| 420 | getFullThreadDiff: (input) => | ||
| 421 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), | ||
| 422 | getArchivedShellSnapshot: () => | ||
| 423 | transport.request((client) => | ||
| 424 | client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}), | ||
| 425 | ), | ||
| 426 | subscribeShell: (listener, options) => | ||
| 427 | transport.subscribe( | ||
| 428 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), | ||
| 429 | listener, | ||
| 430 | subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeShell), | ||
| 431 | ), | ||
| 432 | subscribeThread: (input, listener, options) => | ||
| 433 | transport.subscribe( | ||
| 434 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), | ||
| 435 | listener, | ||
| 436 | subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread), | ||
| 437 | ), | ||
| 438 | }, | ||
| 439 | }; | ||
| 440 | } | ||
packages/client-runtime/src/wsRpcProtocol.ts deleted-319| ... | @@ -1,319 +0,0 @@ | ||
| 1 | import { WsRpcGroup } from "@t3tools/contracts"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Schedule from "effect/Schedule"; | ||
| 6 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | ||
| 7 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | DEFAULT_RECONNECT_BACKOFF, | ||
| 11 | getReconnectDelayMs, | ||
| 12 | type ReconnectBackoffConfig, | ||
| 13 | } from "./reconnectBackoff.ts"; | ||
| 14 | |||
| 15 | export interface WsProtocolLifecycleHandlers { | ||
| 16 | readonly getConnectionLabel?: () => string | null; | ||
| 17 | readonly getVersionMismatchHint?: () => string | null; | ||
| 18 | readonly isCloseIntentional?: () => boolean; | ||
| 19 | readonly isActive?: () => boolean; | ||
| 20 | readonly onAttempt?: (socketUrl: string) => void; | ||
| 21 | readonly onOpen?: () => void; | ||
| 22 | readonly onHeartbeatPing?: () => void; | ||
| 23 | readonly onHeartbeatPong?: () => void; | ||
| 24 | readonly onHeartbeatTimeout?: () => void; | ||
| 25 | readonly onRequestStart?: (info: { | ||
| 26 | readonly id: string; | ||
| 27 | readonly tag: string; | ||
| 28 | readonly stream: boolean; | ||
| 29 | }) => void; | ||
| 30 | readonly onRequestChunk?: (info: { | ||
| 31 | readonly id: string; | ||
| 32 | readonly tag: string; | ||
| 33 | readonly chunkCount: number; | ||
| 34 | }) => void; | ||
| 35 | readonly onRequestExit?: (info: { | ||
| 36 | readonly id: string; | ||
| 37 | readonly tag: string; | ||
| 38 | readonly stream: boolean; | ||
| 39 | }) => void; | ||
| 40 | readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void; | ||
| 41 | readonly onError?: (message: string) => void; | ||
| 42 | readonly onClose?: ( | ||
| 43 | details: { readonly code: number; readonly reason: string }, | ||
| 44 | context: { readonly intentional: boolean }, | ||
| 45 | ) => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface WsRpcProtocolRequestTelemetry { | ||
| 49 | readonly onRequestSent?: (requestId: string, tag: string) => void; | ||
| 50 | readonly onRequestAcknowledged?: (requestId: string) => void; | ||
| 51 | readonly onClearTrackedRequests?: () => void; | ||
| 52 | } | ||
| 53 | |||
| 54 | export interface WsRpcProtocolOptions { | ||
| 55 | /** Backoff configuration for reconnect retries. */ | ||
| 56 | readonly backoff?: ReconnectBackoffConfig; | ||
| 57 | /** | ||
| 58 | * Invoked before user {@link WsProtocolLifecycleHandlers} for each socket lifecycle event. | ||
| 59 | * Use for additive telemetry (connection state, clearing request trackers on disconnect). | ||
| 60 | */ | ||
| 61 | readonly telemetryLifecycle?: WsProtocolLifecycleHandlers; | ||
| 62 | /** Optional hooks around outbound requests and inbound RPC responses (latency tracking, etc.). */ | ||
| 63 | readonly requestTelemetry?: WsRpcProtocolRequestTelemetry; | ||
| 64 | } | ||
| 65 | |||
| 66 | export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); | ||
| 67 | type RpcClientFactory = typeof makeWsRpcProtocolClient; | ||
| 68 | export type WsRpcProtocolClient = | ||
| 69 | RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never; | ||
| 70 | export type WsRpcProtocolSocketUrlProvider = string | (() => Promise<string>); | ||
| 71 | |||
| 72 | function formatSocketErrorMessage(error: unknown): string { | ||
| 73 | if (error instanceof Error && error.message.trim().length > 0) { | ||
| 74 | return error.message; | ||
| 75 | } | ||
| 76 | |||
| 77 | return String(error); | ||
| 78 | } | ||
| 79 | |||
| 80 | function resolveWsRpcSocketUrl(rawUrl: string): string { | ||
| 81 | const resolved = new URL(rawUrl); | ||
| 82 | if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") { | ||
| 83 | throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); | ||
| 84 | } | ||
| 85 | |||
| 86 | resolved.pathname = "/ws"; | ||
| 87 | return resolved.toString(); | ||
| 88 | } | ||
| 89 | |||
| 90 | type ResolvedLifecycleHandlers = Required< | ||
| 91 | Pick< | ||
| 92 | WsProtocolLifecycleHandlers, | ||
| 93 | | "getConnectionLabel" | ||
| 94 | | "getVersionMismatchHint" | ||
| 95 | | "isCloseIntentional" | ||
| 96 | | "isActive" | ||
| 97 | | "onAttempt" | ||
| 98 | | "onOpen" | ||
| 99 | | "onHeartbeatPing" | ||
| 100 | | "onHeartbeatPong" | ||
| 101 | | "onHeartbeatTimeout" | ||
| 102 | | "onError" | ||
| 103 | | "onClose" | ||
| 104 | > | ||
| 105 | >; | ||
| 106 | |||
| 107 | function defaultLifecycleHandlers(): ResolvedLifecycleHandlers { | ||
| 108 | return { | ||
| 109 | onAttempt: () => undefined, | ||
| 110 | onOpen: () => undefined, | ||
| 111 | onHeartbeatPing: () => undefined, | ||
| 112 | onHeartbeatPong: () => undefined, | ||
| 113 | onHeartbeatTimeout: () => undefined, | ||
| 114 | onError: () => undefined, | ||
| 115 | onClose: () => undefined, | ||
| 116 | getConnectionLabel: () => null, | ||
| 117 | getVersionMismatchHint: () => null, | ||
| 118 | isCloseIntentional: () => false, | ||
| 119 | isActive: () => true, | ||
| 120 | }; | ||
| 121 | } | ||
| 122 | |||
| 123 | function resolveLifecycleHandlers( | ||
| 124 | handlers: WsProtocolLifecycleHandlers | undefined, | ||
| 125 | telemetryLifecycle: WsProtocolLifecycleHandlers | undefined, | ||
| 126 | ): ResolvedLifecycleHandlers { | ||
| 127 | const defaults = defaultLifecycleHandlers(); | ||
| 128 | const isActive = handlers?.isActive ?? telemetryLifecycle?.isActive ?? defaults.isActive; | ||
| 129 | const isCloseIntentional = | ||
| 130 | handlers?.isCloseIntentional ?? | ||
| 131 | telemetryLifecycle?.isCloseIntentional ?? | ||
| 132 | defaults.isCloseIntentional; | ||
| 133 | |||
| 134 | return { | ||
| 135 | getConnectionLabel: () => | ||
| 136 | handlers?.getConnectionLabel?.() ?? telemetryLifecycle?.getConnectionLabel?.() ?? null, | ||
| 137 | getVersionMismatchHint: () => | ||
| 138 | handlers?.getVersionMismatchHint?.() ?? | ||
| 139 | telemetryLifecycle?.getVersionMismatchHint?.() ?? | ||
| 140 | null, | ||
| 141 | isActive, | ||
| 142 | isCloseIntentional, | ||
| 143 | onAttempt: (socketUrl) => { | ||
| 144 | if (!isActive()) { | ||
| 145 | return; | ||
| 146 | } | ||
| 147 | telemetryLifecycle?.onAttempt?.(socketUrl); | ||
| 148 | handlers?.onAttempt?.(socketUrl); | ||
| 149 | }, | ||
| 150 | onOpen: () => { | ||
| 151 | if (!isActive()) { | ||
| 152 | return; | ||
| 153 | } | ||
| 154 | telemetryLifecycle?.onOpen?.(); | ||
| 155 | handlers?.onOpen?.(); | ||
| 156 | }, | ||
| 157 | onHeartbeatPing: () => { | ||
| 158 | if (!isActive()) { | ||
| 159 | return; | ||
| 160 | } | ||
| 161 | telemetryLifecycle?.onHeartbeatPing?.(); | ||
| 162 | handlers?.onHeartbeatPing?.(); | ||
| 163 | }, | ||
| 164 | onHeartbeatPong: () => { | ||
| 165 | if (!isActive()) { | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | telemetryLifecycle?.onHeartbeatPong?.(); | ||
| 169 | handlers?.onHeartbeatPong?.(); | ||
| 170 | }, | ||
| 171 | onHeartbeatTimeout: () => { | ||
| 172 | if (!isActive()) { | ||
| 173 | return; | ||
| 174 | } | ||
| 175 | telemetryLifecycle?.onHeartbeatTimeout?.(); | ||
| 176 | handlers?.onHeartbeatTimeout?.(); | ||
| 177 | }, | ||
| 178 | onError: (message) => { | ||
| 179 | if (!isActive()) { | ||
| 180 | return; | ||
| 181 | } | ||
| 182 | telemetryLifecycle?.onError?.(message); | ||
| 183 | handlers?.onError?.(message); | ||
| 184 | }, | ||
| 185 | onClose: (details, context) => { | ||
| 186 | if (!isActive()) { | ||
| 187 | return; | ||
| 188 | } | ||
| 189 | telemetryLifecycle?.onClose?.(details, context); | ||
| 190 | handlers?.onClose?.(details, context); | ||
| 191 | }, | ||
| 192 | }; | ||
| 193 | } | ||
| 194 | |||
| 195 | export function createWsRpcProtocolLayer( | ||
| 196 | url: WsRpcProtocolSocketUrlProvider, | ||
| 197 | handlers?: WsProtocolLifecycleHandlers, | ||
| 198 | options?: WsRpcProtocolOptions, | ||
| 199 | ) { | ||
| 200 | const lifecycle = resolveLifecycleHandlers(handlers, options?.telemetryLifecycle); | ||
| 201 | const backoff = options?.backoff ?? DEFAULT_RECONNECT_BACKOFF; | ||
| 202 | const requestTelemetry = options?.requestTelemetry; | ||
| 203 | const resolvedUrl = | ||
| 204 | typeof url === "function" | ||
| 205 | ? Effect.promise(() => url()).pipe( | ||
| 206 | Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)), | ||
| 207 | Effect.tapError((error) => | ||
| 208 | Effect.sync(() => { | ||
| 209 | lifecycle.onError(formatSocketErrorMessage(error)); | ||
| 210 | }), | ||
| 211 | ), | ||
| 212 | Effect.orDie, | ||
| 213 | ) | ||
| 214 | : resolveWsRpcSocketUrl(url); | ||
| 215 | |||
| 216 | const trackingWebSocketConstructorLayer = Layer.succeed( | ||
| 217 | Socket.WebSocketConstructor, | ||
| 218 | (socketUrl, protocols) => { | ||
| 219 | lifecycle.onAttempt(socketUrl); | ||
| 220 | const socket = new globalThis.WebSocket(socketUrl, protocols); | ||
| 221 | |||
| 222 | socket.addEventListener( | ||
| 223 | "open", | ||
| 224 | () => { | ||
| 225 | lifecycle.onOpen(); | ||
| 226 | }, | ||
| 227 | { once: true }, | ||
| 228 | ); | ||
| 229 | socket.addEventListener( | ||
| 230 | "error", | ||
| 231 | () => { | ||
| 232 | lifecycle.onError("Unable to connect to the T3 server WebSocket."); | ||
| 233 | }, | ||
| 234 | { once: true }, | ||
| 235 | ); | ||
| 236 | socket.addEventListener("message", (event) => { | ||
| 237 | try { | ||
| 238 | const message = JSON.parse(String(event.data)) as { readonly _tag?: string }; | ||
| 239 | if (message._tag === "Pong") { | ||
| 240 | lifecycle.onHeartbeatPong(); | ||
| 241 | } | ||
| 242 | } catch { | ||
| 243 | // Ignore malformed messages here; the Effect RPC parser still owns protocol errors. | ||
| 244 | } | ||
| 245 | }); | ||
| 246 | socket.addEventListener( | ||
| 247 | "close", | ||
| 248 | (event) => { | ||
| 249 | lifecycle.onClose( | ||
| 250 | { | ||
| 251 | code: event.code, | ||
| 252 | reason: event.reason, | ||
| 253 | }, | ||
| 254 | { | ||
| 255 | intentional: lifecycle.isCloseIntentional(), | ||
| 256 | }, | ||
| 257 | ); | ||
| 258 | }, | ||
| 259 | { once: true }, | ||
| 260 | ); | ||
| 261 | |||
| 262 | return socket; | ||
| 263 | }, | ||
| 264 | ); | ||
| 265 | const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe( | ||
| 266 | Layer.provide(trackingWebSocketConstructorLayer), | ||
| 267 | ); | ||
| 268 | |||
| 269 | const baseSchedule = | ||
| 270 | backoff.maxRetries === null ? Schedule.forever : Schedule.recurs(backoff.maxRetries); | ||
| 271 | const retryPolicy = Schedule.addDelay(baseSchedule, (retryCount) => | ||
| 272 | Effect.succeed(Duration.millis(getReconnectDelayMs(retryCount, backoff) ?? 0)), | ||
| 273 | ); | ||
| 274 | const protocolLayer = Layer.effect( | ||
| 275 | RpcClient.Protocol, | ||
| 276 | Effect.map( | ||
| 277 | RpcClient.makeProtocolSocket({ | ||
| 278 | retryPolicy, | ||
| 279 | retryTransientErrors: true, | ||
| 280 | }), | ||
| 281 | (protocol) => ({ | ||
| 282 | ...protocol, | ||
| 283 | run: (clientId, writeResponse) => | ||
| 284 | protocol.run(clientId, (response) => { | ||
| 285 | if (response._tag === "Chunk" || response._tag === "Exit") { | ||
| 286 | requestTelemetry?.onRequestAcknowledged?.(response.requestId); | ||
| 287 | } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") { | ||
| 288 | requestTelemetry?.onClearTrackedRequests?.(); | ||
| 289 | } | ||
| 290 | return writeResponse(response); | ||
| 291 | }), | ||
| 292 | send: (clientId, request, transferables) => { | ||
| 293 | if (request._tag === "Request") { | ||
| 294 | requestTelemetry?.onRequestSent?.(request.id, request.tag); | ||
| 295 | if (lifecycle.isActive()) { | ||
| 296 | handlers?.onRequestStart?.({ | ||
| 297 | id: request.id, | ||
| 298 | tag: request.tag, | ||
| 299 | stream: false, | ||
| 300 | }); | ||
| 301 | } | ||
| 302 | } | ||
| 303 | return protocol.send(clientId, request, transferables); | ||
| 304 | }, | ||
| 305 | }), | ||
| 306 | ), | ||
| 307 | ); | ||
| 308 | const connectionHooksLayer = Layer.succeed( | ||
| 309 | RpcClient.ConnectionHooks, | ||
| 310 | RpcClient.ConnectionHooks.of({ | ||
| 311 | onConnect: Effect.void, | ||
| 312 | onDisconnect: Effect.void, | ||
| 313 | }), | ||
| 314 | ); | ||
| 315 | |||
| 316 | return protocolLayer.pipe( | ||
| 317 | Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)), | ||
| 318 | ); | ||
| 319 | } | ||
packages/client-runtime/src/wsTransport.test.ts deleted-959| ... | @@ -1,959 +0,0 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Stream from "effect/Stream"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { WsTransport } from "./wsTransport.ts"; | ||
| 8 | |||
| 9 | type WsEventType = "open" | "message" | "close" | "error"; | ||
| 10 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | ||
| 11 | type WsListener = (event?: WsEvent) => void; | ||
| 12 | |||
| 13 | const sockets: MockWebSocket[] = []; | ||
| 14 | |||
| 15 | class MockWebSocket { | ||
| 16 | static readonly CONNECTING = 0; | ||
| 17 | static readonly OPEN = 1; | ||
| 18 | static readonly CLOSING = 2; | ||
| 19 | static readonly CLOSED = 3; | ||
| 20 | |||
| 21 | readyState = MockWebSocket.CONNECTING; | ||
| 22 | readonly sent: string[] = []; | ||
| 23 | readonly url: string; | ||
| 24 | private readonly listeners = new Map<WsEventType, Set<WsListener>>(); | ||
| 25 | |||
| 26 | constructor(url: string) { | ||
| 27 | this.url = url; | ||
| 28 | sockets.push(this); | ||
| 29 | } | ||
| 30 | |||
| 31 | addEventListener(type: WsEventType, listener: WsListener) { | ||
| 32 | const listeners = this.listeners.get(type) ?? new Set<WsListener>(); | ||
| 33 | listeners.add(listener); | ||
| 34 | this.listeners.set(type, listeners); | ||
| 35 | } | ||
| 36 | |||
| 37 | removeEventListener(type: WsEventType, listener: WsListener) { | ||
| 38 | this.listeners.get(type)?.delete(listener); | ||
| 39 | } | ||
| 40 | |||
| 41 | send(data: string) { | ||
| 42 | this.sent.push(data); | ||
| 43 | } | ||
| 44 | |||
| 45 | close(code = 1000, reason = "") { | ||
| 46 | this.readyState = MockWebSocket.CLOSED; | ||
| 47 | this.emit("close", { code, reason, type: "close" }); | ||
| 48 | } | ||
| 49 | |||
| 50 | open() { | ||
| 51 | this.readyState = MockWebSocket.OPEN; | ||
| 52 | this.emit("open", { type: "open" }); | ||
| 53 | } | ||
| 54 | |||
| 55 | serverMessage(data: unknown) { | ||
| 56 | this.emit("message", { data, type: "message" }); | ||
| 57 | } | ||
| 58 | |||
| 59 | error() { | ||
| 60 | this.emit("error", { type: "error" }); | ||
| 61 | } | ||
| 62 | |||
| 63 | private emit(type: WsEventType, event?: WsEvent) { | ||
| 64 | const listeners = this.listeners.get(type); | ||
| 65 | if (!listeners) return; | ||
| 66 | for (const listener of listeners) { | ||
| 67 | listener(event); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | const originalWebSocket = globalThis.WebSocket; | ||
| 73 | const originalFetch = globalThis.fetch; | ||
| 74 | const transports: WsTransport[] = []; | ||
| 75 | |||
| 76 | function getSocket(): MockWebSocket { | ||
| 77 | const socket = sockets.at(-1); | ||
| 78 | if (!socket) { | ||
| 79 | throw new Error("Expected a websocket instance"); | ||
| 80 | } | ||
| 81 | return socket; | ||
| 82 | } | ||
| 83 | |||
| 84 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | ||
| 85 | const startedAt = performance.now(); | ||
| 86 | for (;;) { | ||
| 87 | try { | ||
| 88 | assertion(); | ||
| 89 | return; | ||
| 90 | } catch (error) { | ||
| 91 | if (performance.now() - startedAt >= timeoutMs) { | ||
| 92 | throw error; | ||
| 93 | } | ||
| 94 | await Effect.runPromise(Effect.sleep(Duration.millis(10))); | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | function createTransport(...args: ConstructorParameters<typeof WsTransport>): WsTransport { | ||
| 100 | const transport = new WsTransport(...args); | ||
| 101 | transports.push(transport); | ||
| 102 | return transport; | ||
| 103 | } | ||
| 104 | |||
| 105 | beforeEach(() => { | ||
| 106 | vi.useRealTimers(); | ||
| 107 | sockets.length = 0; | ||
| 108 | transports.length = 0; | ||
| 109 | |||
| 110 | Object.defineProperty(globalThis, "window", { | ||
| 111 | configurable: true, | ||
| 112 | value: { | ||
| 113 | location: { | ||
| 114 | origin: "http://localhost:3020", | ||
| 115 | hostname: "localhost", | ||
| 116 | port: "3020", | ||
| 117 | protocol: "http:", | ||
| 118 | }, | ||
| 119 | desktopBridge: undefined, | ||
| 120 | }, | ||
| 121 | }); | ||
| 122 | Object.defineProperty(globalThis, "navigator", { | ||
| 123 | configurable: true, | ||
| 124 | value: { onLine: true }, | ||
| 125 | }); | ||
| 126 | |||
| 127 | globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; | ||
| 128 | }); | ||
| 129 | |||
| 130 | afterEach(async () => { | ||
| 131 | await Promise.allSettled(transports.map((transport) => transport.dispose())); | ||
| 132 | transports.length = 0; | ||
| 133 | globalThis.WebSocket = originalWebSocket; | ||
| 134 | globalThis.fetch = originalFetch; | ||
| 135 | vi.restoreAllMocks(); | ||
| 136 | }); | ||
| 137 | |||
| 138 | describe("WsTransport", () => { | ||
| 139 | it("normalizes root websocket urls to /ws and preserves query params", async () => { | ||
| 140 | const transport = createTransport("ws://localhost:3020/?token=secret-token"); | ||
| 141 | |||
| 142 | await waitFor(() => { | ||
| 143 | expect(sockets).toHaveLength(1); | ||
| 144 | }); | ||
| 145 | |||
| 146 | expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); | ||
| 147 | await transport.dispose(); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("uses an explicit secure websocket base url", async () => { | ||
| 151 | const transport = createTransport("wss://app.example.com"); | ||
| 152 | |||
| 153 | await waitFor(() => { | ||
| 154 | expect(sockets).toHaveLength(1); | ||
| 155 | }); | ||
| 156 | |||
| 157 | expect(getSocket().url).toBe("wss://app.example.com/ws"); | ||
| 158 | await transport.dispose(); | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("uses an explicit insecure websocket base url for remote backends", async () => { | ||
| 162 | const transport = createTransport("ws://192.168.1.44:3773"); | ||
| 163 | |||
| 164 | await waitFor(() => { | ||
| 165 | expect(sockets).toHaveLength(1); | ||
| 166 | }); | ||
| 167 | |||
| 168 | expect(getSocket().url).toBe("ws://192.168.1.44:3773/ws"); | ||
| 169 | await transport.dispose(); | ||
| 170 | }); | ||
| 171 | |||
| 172 | it("supports async websocket url providers", async () => { | ||
| 173 | const transport = createTransport(async () => "wss://remote.example.com/?wsTicket=dynamic"); | ||
| 174 | |||
| 175 | await waitFor(() => { | ||
| 176 | expect(sockets).toHaveLength(1); | ||
| 177 | }); | ||
| 178 | |||
| 179 | expect(getSocket().url).toBe("wss://remote.example.com/ws?wsTicket=dynamic"); | ||
| 180 | await transport.dispose(); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("invokes optional lifecycle handlers when the socket opens and closes", async () => { | ||
| 184 | const onOpen = vi.fn(); | ||
| 185 | const onClose = vi.fn(); | ||
| 186 | const transport = createTransport("ws://localhost:3020", { | ||
| 187 | onOpen, | ||
| 188 | onClose, | ||
| 189 | }); | ||
| 190 | |||
| 191 | await waitFor(() => { | ||
| 192 | expect(sockets).toHaveLength(1); | ||
| 193 | }); | ||
| 194 | |||
| 195 | const socket = getSocket(); | ||
| 196 | socket.open(); | ||
| 197 | |||
| 198 | await waitFor(() => { | ||
| 199 | expect(onOpen).toHaveBeenCalledOnce(); | ||
| 200 | }); | ||
| 201 | |||
| 202 | socket.close(1012, "service restart"); | ||
| 203 | |||
| 204 | await waitFor(() => { | ||
| 205 | expect(onClose).toHaveBeenCalledWith( | ||
| 206 | { | ||
| 207 | code: 1012, | ||
| 208 | reason: "service restart", | ||
| 209 | }, | ||
| 210 | { | ||
| 211 | intentional: false, | ||
| 212 | }, | ||
| 213 | ); | ||
| 214 | }); | ||
| 215 | |||
| 216 | await transport.dispose(); | ||
| 217 | }); | ||
| 218 | |||
| 219 | it("tracks heartbeat freshness from websocket pongs", async () => { | ||
| 220 | const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1_000); | ||
| 221 | const onHeartbeatPong = vi.fn(); | ||
| 222 | const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); | ||
| 223 | |||
| 224 | await waitFor(() => { | ||
| 225 | expect(sockets).toHaveLength(1); | ||
| 226 | }); | ||
| 227 | |||
| 228 | expect(transport.isHeartbeatFresh()).toBe(false); | ||
| 229 | |||
| 230 | const socket = getSocket(); | ||
| 231 | socket.open(); | ||
| 232 | socket.serverMessage(JSON.stringify({ _tag: "Pong" })); | ||
| 233 | |||
| 234 | await waitFor(() => { | ||
| 235 | expect(onHeartbeatPong).toHaveBeenCalledOnce(); | ||
| 236 | }); | ||
| 237 | |||
| 238 | expect(transport.isHeartbeatFresh()).toBe(true); | ||
| 239 | expect(transport.isHeartbeatFresh(500)).toBe(true); | ||
| 240 | |||
| 241 | nowSpy.mockReturnValue(1_501); | ||
| 242 | expect(transport.isHeartbeatFresh(500)).toBe(false); | ||
| 243 | |||
| 244 | await transport.dispose(); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it("clears heartbeat freshness when reconnecting", async () => { | ||
| 248 | vi.spyOn(performance, "now").mockReturnValue(1_000); | ||
| 249 | const onHeartbeatPong = vi.fn(); | ||
| 250 | const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); | ||
| 251 | |||
| 252 | await waitFor(() => { | ||
| 253 | expect(sockets).toHaveLength(1); | ||
| 254 | }); | ||
| 255 | |||
| 256 | const firstSocket = getSocket(); | ||
| 257 | firstSocket.open(); | ||
| 258 | firstSocket.serverMessage(JSON.stringify({ _tag: "Pong" })); | ||
| 259 | |||
| 260 | await waitFor(() => { | ||
| 261 | expect(onHeartbeatPong).toHaveBeenCalledOnce(); | ||
| 262 | }); | ||
| 263 | expect(transport.isHeartbeatFresh()).toBe(true); | ||
| 264 | |||
| 265 | await transport.reconnect(); | ||
| 266 | |||
| 267 | expect(transport.isHeartbeatFresh()).toBe(false); | ||
| 268 | |||
| 269 | await transport.dispose(); | ||
| 270 | }); | ||
| 271 | |||
| 272 | it("does not report an intentional dispose as a close", async () => { | ||
| 273 | const onClose = vi.fn(); | ||
| 274 | const transport = createTransport("ws://localhost:3020", { onClose }); | ||
| 275 | |||
| 276 | await waitFor(() => { | ||
| 277 | expect(sockets).toHaveLength(1); | ||
| 278 | }); | ||
| 279 | |||
| 280 | getSocket().open(); | ||
| 281 | await transport.dispose(); | ||
| 282 | |||
| 283 | expect(onClose).not.toHaveBeenCalled(); | ||
| 284 | }); | ||
| 285 | |||
| 286 | it("ignores stale socket lifecycle events after reconnect starts a new session", async () => { | ||
| 287 | const onClose = vi.fn(); | ||
| 288 | const transport = createTransport("ws://localhost:3020", { onClose }); | ||
| 289 | |||
| 290 | await waitFor(() => { | ||
| 291 | expect(sockets).toHaveLength(1); | ||
| 292 | }); | ||
| 293 | |||
| 294 | const firstSocket = getSocket(); | ||
| 295 | firstSocket.open(); | ||
| 296 | |||
| 297 | await transport.reconnect(); | ||
| 298 | |||
| 299 | await waitFor(() => { | ||
| 300 | expect(sockets).toHaveLength(2); | ||
| 301 | }); | ||
| 302 | |||
| 303 | firstSocket.close(1006, "stale close"); | ||
| 304 | |||
| 305 | expect(onClose).not.toHaveBeenCalled(); | ||
| 306 | |||
| 307 | await transport.dispose(); | ||
| 308 | }); | ||
| 309 | |||
| 310 | it("reconnects the websocket session without disposing the transport", async () => { | ||
| 311 | const transport = createTransport("ws://localhost:3020"); | ||
| 312 | |||
| 313 | await waitFor(() => { | ||
| 314 | expect(sockets).toHaveLength(1); | ||
| 315 | }); | ||
| 316 | |||
| 317 | const firstSocket = getSocket(); | ||
| 318 | firstSocket.open(); | ||
| 319 | |||
| 320 | await transport.reconnect(); | ||
| 321 | |||
| 322 | await waitFor(() => { | ||
| 323 | expect(sockets).toHaveLength(2); | ||
| 324 | }); | ||
| 325 | |||
| 326 | const secondSocket = getSocket(); | ||
| 327 | expect(secondSocket).not.toBe(firstSocket); | ||
| 328 | expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); | ||
| 329 | |||
| 330 | const requestPromise = transport.request((client) => | ||
| 331 | client[WS_METHODS.serverUpsertKeybinding]({ | ||
| 332 | command: "terminal.toggle", | ||
| 333 | key: "ctrl+k", | ||
| 334 | }), | ||
| 335 | ); | ||
| 336 | |||
| 337 | secondSocket.open(); | ||
| 338 | |||
| 339 | await waitFor(() => { | ||
| 340 | expect(secondSocket.sent).toHaveLength(1); | ||
| 341 | }); | ||
| 342 | |||
| 343 | const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string }; | ||
| 344 | secondSocket.serverMessage( | ||
| 345 | JSON.stringify({ | ||
| 346 | _tag: "Exit", | ||
| 347 | requestId: requestMessage.id, | ||
| 348 | exit: { | ||
| 349 | _tag: "Success", | ||
| 350 | value: { | ||
| 351 | keybindings: [], | ||
| 352 | issues: [], | ||
| 353 | }, | ||
| 354 | }, | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | await expect(requestPromise).resolves.toEqual({ | ||
| 359 | keybindings: [], | ||
| 360 | issues: [], | ||
| 361 | }); | ||
| 362 | |||
| 363 | await transport.dispose(); | ||
| 364 | }); | ||
| 365 | |||
| 366 | it("sends unary RPC requests and resolves successful exits", async () => { | ||
| 367 | const transport = createTransport("ws://localhost:3020"); | ||
| 368 | |||
| 369 | const requestPromise = transport.request((client) => | ||
| 370 | client[WS_METHODS.serverUpsertKeybinding]({ | ||
| 371 | command: "terminal.toggle", | ||
| 372 | key: "ctrl+k", | ||
| 373 | }), | ||
| 374 | ); | ||
| 375 | |||
| 376 | await waitFor(() => { | ||
| 377 | expect(sockets).toHaveLength(1); | ||
| 378 | }); | ||
| 379 | |||
| 380 | const socket = getSocket(); | ||
| 381 | socket.open(); | ||
| 382 | |||
| 383 | await waitFor(() => { | ||
| 384 | expect(socket.sent).toHaveLength(1); | ||
| 385 | }); | ||
| 386 | |||
| 387 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | ||
| 388 | _tag: string; | ||
| 389 | id: string; | ||
| 390 | payload: unknown; | ||
| 391 | tag: string; | ||
| 392 | }; | ||
| 393 | expect(requestMessage).toMatchObject({ | ||
| 394 | _tag: "Request", | ||
| 395 | tag: WS_METHODS.serverUpsertKeybinding, | ||
| 396 | payload: { | ||
| 397 | command: "terminal.toggle", | ||
| 398 | key: "ctrl+k", | ||
| 399 | }, | ||
| 400 | }); | ||
| 401 | |||
| 402 | socket.serverMessage( | ||
| 403 | JSON.stringify({ | ||
| 404 | _tag: "Exit", | ||
| 405 | requestId: requestMessage.id, | ||
| 406 | exit: { | ||
| 407 | _tag: "Success", | ||
| 408 | value: { | ||
| 409 | keybindings: [], | ||
| 410 | issues: [], | ||
| 411 | }, | ||
| 412 | }, | ||
| 413 | }), | ||
| 414 | ); | ||
| 415 | |||
| 416 | await expect(requestPromise).resolves.toEqual({ | ||
| 417 | keybindings: [], | ||
| 418 | issues: [], | ||
| 419 | }); | ||
| 420 | |||
| 421 | await transport.dispose(); | ||
| 422 | }); | ||
| 423 | |||
| 424 | it("delivers stream chunks to subscribers", async () => { | ||
| 425 | const transport = createTransport("ws://localhost:3020"); | ||
| 426 | const listener = vi.fn(); | ||
| 427 | |||
| 428 | const unsubscribe = transport.subscribe( | ||
| 429 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 430 | listener, | ||
| 431 | ); | ||
| 432 | await waitFor(() => { | ||
| 433 | expect(sockets).toHaveLength(1); | ||
| 434 | }); | ||
| 435 | |||
| 436 | const socket = getSocket(); | ||
| 437 | socket.open(); | ||
| 438 | |||
| 439 | await waitFor(() => { | ||
| 440 | expect(socket.sent).toHaveLength(1); | ||
| 441 | }); | ||
| 442 | |||
| 443 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | ||
| 444 | expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); | ||
| 445 | |||
| 446 | const welcomeEvent = { | ||
| 447 | version: 1, | ||
| 448 | sequence: 1, | ||
| 449 | type: "welcome", | ||
| 450 | payload: { | ||
| 451 | environment: { | ||
| 452 | environmentId: "environment-local", | ||
| 453 | label: "Local environment", | ||
| 454 | platform: { os: "darwin", arch: "arm64" }, | ||
| 455 | serverVersion: "0.0.0-test", | ||
| 456 | capabilities: { repositoryIdentity: true }, | ||
| 457 | }, | ||
| 458 | cwd: "/tmp/workspace", | ||
| 459 | projectName: "workspace", | ||
| 460 | }, | ||
| 461 | }; | ||
| 462 | |||
| 463 | socket.serverMessage( | ||
| 464 | JSON.stringify({ | ||
| 465 | _tag: "Chunk", | ||
| 466 | requestId: requestMessage.id, | ||
| 467 | values: [welcomeEvent], | ||
| 468 | }), | ||
| 469 | ); | ||
| 470 | |||
| 471 | await waitFor(() => { | ||
| 472 | expect(listener).toHaveBeenCalledWith(welcomeEvent); | ||
| 473 | }); | ||
| 474 | |||
| 475 | unsubscribe(); | ||
| 476 | await transport.dispose(); | ||
| 477 | }); | ||
| 478 | |||
| 479 | it("re-subscribes stream listeners after the stream exits", async () => { | ||
| 480 | const transport = createTransport("ws://localhost:3020"); | ||
| 481 | const listener = vi.fn(); | ||
| 482 | const onResubscribe = vi.fn(); | ||
| 483 | |||
| 484 | const unsubscribe = transport.subscribe( | ||
| 485 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 486 | listener, | ||
| 487 | { onResubscribe }, | ||
| 488 | ); | ||
| 489 | await waitFor(() => { | ||
| 490 | expect(sockets).toHaveLength(1); | ||
| 491 | }); | ||
| 492 | |||
| 493 | const socket = getSocket(); | ||
| 494 | socket.open(); | ||
| 495 | |||
| 496 | await waitFor(() => { | ||
| 497 | expect(socket.sent).toHaveLength(1); | ||
| 498 | }); | ||
| 499 | |||
| 500 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 501 | socket.serverMessage( | ||
| 502 | JSON.stringify({ | ||
| 503 | _tag: "Chunk", | ||
| 504 | requestId: firstRequest.id, | ||
| 505 | values: [ | ||
| 506 | { | ||
| 507 | version: 1, | ||
| 508 | sequence: 1, | ||
| 509 | type: "welcome", | ||
| 510 | payload: { | ||
| 511 | environment: { | ||
| 512 | environmentId: "environment-local", | ||
| 513 | label: "Local environment", | ||
| 514 | platform: { os: "darwin", arch: "arm64" }, | ||
| 515 | serverVersion: "0.0.0-test", | ||
| 516 | capabilities: { repositoryIdentity: true }, | ||
| 517 | }, | ||
| 518 | cwd: "/tmp/one", | ||
| 519 | projectName: "one", | ||
| 520 | }, | ||
| 521 | }, | ||
| 522 | ], | ||
| 523 | }), | ||
| 524 | ); | ||
| 525 | socket.serverMessage( | ||
| 526 | JSON.stringify({ | ||
| 527 | _tag: "Exit", | ||
| 528 | requestId: firstRequest.id, | ||
| 529 | exit: { | ||
| 530 | _tag: "Success", | ||
| 531 | value: null, | ||
| 532 | }, | ||
| 533 | }), | ||
| 534 | ); | ||
| 535 | |||
| 536 | await waitFor(() => { | ||
| 537 | const nextRequest = socket.sent | ||
| 538 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | ||
| 539 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | ||
| 540 | expect(nextRequest).toBeDefined(); | ||
| 541 | }); | ||
| 542 | expect(onResubscribe).toHaveBeenCalledOnce(); | ||
| 543 | |||
| 544 | const secondRequest = socket.sent | ||
| 545 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) | ||
| 546 | .find( | ||
| 547 | (message): message is { _tag: "Request"; id: string; tag: string } => | ||
| 548 | message._tag === "Request" && message.id !== firstRequest.id, | ||
| 549 | ); | ||
| 550 | if (!secondRequest) { | ||
| 551 | throw new Error("Expected a resubscribe request"); | ||
| 552 | } | ||
| 553 | expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); | ||
| 554 | expect(secondRequest.id).not.toBe(firstRequest.id); | ||
| 555 | |||
| 556 | const secondEvent = { | ||
| 557 | version: 1, | ||
| 558 | sequence: 2, | ||
| 559 | type: "welcome", | ||
| 560 | payload: { | ||
| 561 | environment: { | ||
| 562 | environmentId: "environment-local", | ||
| 563 | label: "Local environment", | ||
| 564 | platform: { os: "darwin", arch: "arm64" }, | ||
| 565 | serverVersion: "0.0.0-test", | ||
| 566 | capabilities: { repositoryIdentity: true }, | ||
| 567 | }, | ||
| 568 | cwd: "/tmp/two", | ||
| 569 | projectName: "two", | ||
| 570 | }, | ||
| 571 | }; | ||
| 572 | socket.serverMessage( | ||
| 573 | JSON.stringify({ | ||
| 574 | _tag: "Chunk", | ||
| 575 | requestId: secondRequest.id, | ||
| 576 | values: [secondEvent], | ||
| 577 | }), | ||
| 578 | ); | ||
| 579 | |||
| 580 | await waitFor(() => { | ||
| 581 | expect(listener).toHaveBeenLastCalledWith(secondEvent); | ||
| 582 | }); | ||
| 583 | |||
| 584 | unsubscribe(); | ||
| 585 | await transport.dispose(); | ||
| 586 | }); | ||
| 587 | |||
| 588 | it("re-subscribes live stream listeners after an explicit transport reconnect", async () => { | ||
| 589 | const transport = createTransport("ws://localhost:3020"); | ||
| 590 | const listener = vi.fn(); | ||
| 591 | const onResubscribe = vi.fn(); | ||
| 592 | |||
| 593 | const unsubscribe = transport.subscribe( | ||
| 594 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 595 | listener, | ||
| 596 | { onResubscribe }, | ||
| 597 | ); | ||
| 598 | |||
| 599 | await waitFor(() => { | ||
| 600 | expect(sockets).toHaveLength(1); | ||
| 601 | }); | ||
| 602 | |||
| 603 | const firstSocket = getSocket(); | ||
| 604 | firstSocket.open(); | ||
| 605 | |||
| 606 | await waitFor(() => { | ||
| 607 | expect(firstSocket.sent).toHaveLength(1); | ||
| 608 | }); | ||
| 609 | |||
| 610 | const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; | ||
| 611 | const firstEvent = { | ||
| 612 | version: 1, | ||
| 613 | sequence: 1, | ||
| 614 | type: "welcome", | ||
| 615 | payload: { | ||
| 616 | environment: { | ||
| 617 | environmentId: "environment-local", | ||
| 618 | label: "Local environment", | ||
| 619 | platform: { os: "darwin", arch: "arm64" }, | ||
| 620 | serverVersion: "0.0.0-test", | ||
| 621 | capabilities: { repositoryIdentity: true }, | ||
| 622 | }, | ||
| 623 | cwd: "/tmp/one", | ||
| 624 | projectName: "one", | ||
| 625 | }, | ||
| 626 | }; | ||
| 627 | |||
| 628 | firstSocket.serverMessage( | ||
| 629 | JSON.stringify({ | ||
| 630 | _tag: "Chunk", | ||
| 631 | requestId: firstRequest.id, | ||
| 632 | values: [firstEvent], | ||
| 633 | }), | ||
| 634 | ); | ||
| 635 | |||
| 636 | await waitFor(() => { | ||
| 637 | expect(listener).toHaveBeenLastCalledWith(firstEvent); | ||
| 638 | }); | ||
| 639 | |||
| 640 | await transport.reconnect(); | ||
| 641 | |||
| 642 | await waitFor(() => { | ||
| 643 | expect(sockets).toHaveLength(2); | ||
| 644 | }); | ||
| 645 | |||
| 646 | const secondSocket = getSocket(); | ||
| 647 | expect(secondSocket).not.toBe(firstSocket); | ||
| 648 | expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); | ||
| 649 | |||
| 650 | secondSocket.open(); | ||
| 651 | |||
| 652 | await waitFor(() => { | ||
| 653 | expect(secondSocket.sent).toHaveLength(1); | ||
| 654 | }); | ||
| 655 | |||
| 656 | const secondRequest = JSON.parse(secondSocket.sent[0] ?? "{}") as { | ||
| 657 | id: string; | ||
| 658 | tag: string; | ||
| 659 | }; | ||
| 660 | expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); | ||
| 661 | expect(secondRequest.id).not.toBe(firstRequest.id); | ||
| 662 | expect(onResubscribe).toHaveBeenCalledOnce(); | ||
| 663 | |||
| 664 | const secondEvent = { | ||
| 665 | version: 1, | ||
| 666 | sequence: 2, | ||
| 667 | type: "welcome", | ||
| 668 | payload: { | ||
| 669 | environment: { | ||
| 670 | environmentId: "environment-local", | ||
| 671 | label: "Local environment", | ||
| 672 | platform: { os: "darwin", arch: "arm64" }, | ||
| 673 | serverVersion: "0.0.0-test", | ||
| 674 | capabilities: { repositoryIdentity: true }, | ||
| 675 | }, | ||
| 676 | cwd: "/tmp/two", | ||
| 677 | projectName: "two", | ||
| 678 | }, | ||
| 679 | }; | ||
| 680 | |||
| 681 | secondSocket.serverMessage( | ||
| 682 | JSON.stringify({ | ||
| 683 | _tag: "Chunk", | ||
| 684 | requestId: secondRequest.id, | ||
| 685 | values: [secondEvent], | ||
| 686 | }), | ||
| 687 | ); | ||
| 688 | |||
| 689 | await waitFor(() => { | ||
| 690 | expect(listener).toHaveBeenLastCalledWith(secondEvent); | ||
| 691 | }); | ||
| 692 | |||
| 693 | unsubscribe(); | ||
| 694 | await transport.dispose(); | ||
| 695 | }); | ||
| 696 | |||
| 697 | it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { | ||
| 698 | const transport = createTransport("ws://localhost:3020"); | ||
| 699 | const listener = vi.fn(); | ||
| 700 | const onResubscribe = vi.fn(); | ||
| 701 | |||
| 702 | const unsubscribe = transport.subscribe( | ||
| 703 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 704 | listener, | ||
| 705 | { onResubscribe }, | ||
| 706 | ); | ||
| 707 | await waitFor(() => { | ||
| 708 | expect(sockets).toHaveLength(1); | ||
| 709 | }); | ||
| 710 | |||
| 711 | const socket = getSocket(); | ||
| 712 | socket.open(); | ||
| 713 | |||
| 714 | await waitFor(() => { | ||
| 715 | expect(socket.sent).toHaveLength(1); | ||
| 716 | }); | ||
| 717 | |||
| 718 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 719 | socket.serverMessage( | ||
| 720 | JSON.stringify({ | ||
| 721 | _tag: "Exit", | ||
| 722 | requestId: firstRequest.id, | ||
| 723 | exit: { | ||
| 724 | _tag: "Success", | ||
| 725 | value: null, | ||
| 726 | }, | ||
| 727 | }), | ||
| 728 | ); | ||
| 729 | |||
| 730 | await waitFor(() => { | ||
| 731 | const nextRequest = socket.sent | ||
| 732 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | ||
| 733 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | ||
| 734 | expect(nextRequest).toBeDefined(); | ||
| 735 | }); | ||
| 736 | expect(onResubscribe).not.toHaveBeenCalled(); | ||
| 737 | expect(listener).not.toHaveBeenCalled(); | ||
| 738 | |||
| 739 | unsubscribe(); | ||
| 740 | await transport.dispose(); | ||
| 741 | }); | ||
| 742 | |||
| 743 | it("does not retry stream subscriptions after application-level failures", async () => { | ||
| 744 | const warnSpy = vi.fn(); | ||
| 745 | const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); | ||
| 746 | let attempts = 0; | ||
| 747 | |||
| 748 | const unsubscribe = transport.subscribe( | ||
| 749 | () => | ||
| 750 | Stream.suspend(() => { | ||
| 751 | attempts += 1; | ||
| 752 | return Stream.fail(new Error("Git command failed in GitCore.statusDetails")); | ||
| 753 | }), | ||
| 754 | vi.fn(), | ||
| 755 | { retryDelay: 10 }, | ||
| 756 | ); | ||
| 757 | |||
| 758 | await waitFor(() => { | ||
| 759 | expect(sockets).toHaveLength(1); | ||
| 760 | }); | ||
| 761 | |||
| 762 | getSocket().open(); | ||
| 763 | |||
| 764 | await waitFor(() => { | ||
| 765 | expect(attempts).toBe(1); | ||
| 766 | }); | ||
| 767 | await Effect.runPromise(Effect.sleep(Duration.millis(50))); | ||
| 768 | |||
| 769 | expect(attempts).toBe(1); | ||
| 770 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription failed", { | ||
| 771 | error: "Git command failed in GitCore.statusDetails", | ||
| 772 | }); | ||
| 773 | expect(warnSpy).not.toHaveBeenCalledWith( | ||
| 774 | "WebSocket RPC subscription disconnected", | ||
| 775 | expect.anything(), | ||
| 776 | ); | ||
| 777 | |||
| 778 | unsubscribe(); | ||
| 779 | await transport.dispose(); | ||
| 780 | }); | ||
| 781 | |||
| 782 | it("keeps retrying stream subscriptions after transport failures", async () => { | ||
| 783 | const warnSpy = vi.fn(); | ||
| 784 | const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); | ||
| 785 | let attempts = 0; | ||
| 786 | |||
| 787 | const unsubscribe = transport.subscribe( | ||
| 788 | () => | ||
| 789 | Stream.suspend(() => { | ||
| 790 | attempts += 1; | ||
| 791 | return Stream.fail(new Error("Socket is not connected")); | ||
| 792 | }), | ||
| 793 | vi.fn(), | ||
| 794 | { retryDelay: 10 }, | ||
| 795 | ); | ||
| 796 | |||
| 797 | await waitFor(() => { | ||
| 798 | expect(sockets).toHaveLength(1); | ||
| 799 | }); | ||
| 800 | |||
| 801 | getSocket().open(); | ||
| 802 | |||
| 803 | await waitFor(() => { | ||
| 804 | expect(attempts).toBeGreaterThanOrEqual(2); | ||
| 805 | }); | ||
| 806 | |||
| 807 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { | ||
| 808 | error: "Socket is not connected", | ||
| 809 | }); | ||
| 810 | |||
| 811 | unsubscribe(); | ||
| 812 | await transport.dispose(); | ||
| 813 | }); | ||
| 814 | |||
| 815 | it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { | ||
| 816 | const warnSpy = vi.fn(); | ||
| 817 | const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); | ||
| 818 | |||
| 819 | const unsubscribeA = transport.subscribe( | ||
| 820 | () => Stream.fail(new Error("SocketCloseError: 1006")), | ||
| 821 | vi.fn(), | ||
| 822 | { retryDelay: 10 }, | ||
| 823 | ); | ||
| 824 | const unsubscribeB = transport.subscribe( | ||
| 825 | () => Stream.fail(new Error("SocketCloseError: 1006")), | ||
| 826 | vi.fn(), | ||
| 827 | { retryDelay: 10 }, | ||
| 828 | ); | ||
| 829 | |||
| 830 | await waitFor(() => { | ||
| 831 | expect(sockets).toHaveLength(1); | ||
| 832 | }); | ||
| 833 | |||
| 834 | getSocket().open(); | ||
| 835 | |||
| 836 | await waitFor(() => { | ||
| 837 | expect(warnSpy).toHaveBeenCalledTimes(1); | ||
| 838 | }); | ||
| 839 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { | ||
| 840 | error: "SocketCloseError: 1006", | ||
| 841 | }); | ||
| 842 | |||
| 843 | unsubscribeA(); | ||
| 844 | unsubscribeB(); | ||
| 845 | await transport.dispose(); | ||
| 846 | }); | ||
| 847 | |||
| 848 | it("streams finite request events without re-subscribing", async () => { | ||
| 849 | const transport = createTransport("ws://localhost:3020"); | ||
| 850 | const listener = vi.fn(); | ||
| 851 | |||
| 852 | await waitFor(() => { | ||
| 853 | expect(sockets).toHaveLength(1); | ||
| 854 | }); | ||
| 855 | const socket = getSocket(); | ||
| 856 | socket.open(); | ||
| 857 | |||
| 858 | const requestPromise = transport.requestStream( | ||
| 859 | (client) => | ||
| 860 | client[WS_METHODS.gitRunStackedAction]({ | ||
| 861 | actionId: "action-1", | ||
| 862 | cwd: "/repo", | ||
| 863 | action: "commit", | ||
| 864 | }), | ||
| 865 | listener, | ||
| 866 | ); | ||
| 867 | |||
| 868 | await waitFor(() => { | ||
| 869 | expect(socket.sent).toHaveLength(1); | ||
| 870 | }); | ||
| 871 | |||
| 872 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 873 | const progressEvent = { | ||
| 874 | actionId: "action-1", | ||
| 875 | cwd: "/repo", | ||
| 876 | action: "commit", | ||
| 877 | kind: "phase_started", | ||
| 878 | phase: "commit", | ||
| 879 | label: "Committing...", | ||
| 880 | } as const; | ||
| 881 | |||
| 882 | socket.serverMessage( | ||
| 883 | JSON.stringify({ | ||
| 884 | _tag: "Chunk", | ||
| 885 | requestId: requestMessage.id, | ||
| 886 | values: [progressEvent], | ||
| 887 | }), | ||
| 888 | ); | ||
| 889 | socket.serverMessage( | ||
| 890 | JSON.stringify({ | ||
| 891 | _tag: "Exit", | ||
| 892 | requestId: requestMessage.id, | ||
| 893 | exit: { | ||
| 894 | _tag: "Success", | ||
| 895 | value: null, | ||
| 896 | }, | ||
| 897 | }), | ||
| 898 | ); | ||
| 899 | |||
| 900 | await expect(requestPromise).resolves.toBeUndefined(); | ||
| 901 | expect(listener).toHaveBeenCalledWith(progressEvent); | ||
| 902 | expect( | ||
| 903 | socket.sent.filter((message) => { | ||
| 904 | const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; | ||
| 905 | return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; | ||
| 906 | }), | ||
| 907 | ).toHaveLength(1); | ||
| 908 | await transport.dispose(); | ||
| 909 | }); | ||
| 910 | |||
| 911 | it("closes the client scope on the transport runtime before disposing the runtime", async () => { | ||
| 912 | const callOrder: string[] = []; | ||
| 913 | let resolveClose!: () => void; | ||
| 914 | const closePromise = new Promise<void>((resolve) => { | ||
| 915 | resolveClose = resolve; | ||
| 916 | }); | ||
| 917 | |||
| 918 | const runtime = { | ||
| 919 | runPromise: vi.fn(async () => { | ||
| 920 | callOrder.push("close:start"); | ||
| 921 | await closePromise; | ||
| 922 | callOrder.push("close:done"); | ||
| 923 | return undefined; | ||
| 924 | }), | ||
| 925 | dispose: vi.fn(async () => { | ||
| 926 | callOrder.push("runtime:dispose"); | ||
| 927 | }), | ||
| 928 | }; | ||
| 929 | const transport = { | ||
| 930 | disposed: false, | ||
| 931 | session: { | ||
| 932 | clientScope: {} as never, | ||
| 933 | runtime, | ||
| 934 | }, | ||
| 935 | closeSession: ( | ||
| 936 | WsTransport.prototype as unknown as { | ||
| 937 | closeSession: (session: { | ||
| 938 | clientScope: unknown; | ||
| 939 | runtime: { dispose: () => Promise<void>; runPromise: () => Promise<void> }; | ||
| 940 | }) => Promise<void>; | ||
| 941 | } | ||
| 942 | ).closeSession, | ||
| 943 | } as unknown as WsTransport; | ||
| 944 | |||
| 945 | void WsTransport.prototype.dispose.call(transport); | ||
| 946 | |||
| 947 | expect(runtime.runPromise).toHaveBeenCalledTimes(1); | ||
| 948 | expect(runtime.dispose).not.toHaveBeenCalled(); | ||
| 949 | expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); | ||
| 950 | |||
| 951 | resolveClose(); | ||
| 952 | |||
| 953 | await waitFor(() => { | ||
| 954 | expect(runtime.dispose).toHaveBeenCalledTimes(1); | ||
| 955 | }); | ||
| 956 | |||
| 957 | expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); | ||
| 958 | }); | ||
| 959 | }); | ||
packages/client-runtime/src/wsTransport.ts deleted-377| ... | @@ -1,377 +0,0 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as ManagedRuntime from "effect/ManagedRuntime"; | ||
| 7 | import * as Scope from "effect/Scope"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import { RpcClient } from "effect/unstable/rpc"; | ||
| 10 | |||
| 11 | import { isTransportConnectionErrorMessage } from "./transportError.ts"; | ||
| 12 | import { | ||
| 13 | createWsRpcProtocolLayer, | ||
| 14 | makeWsRpcProtocolClient, | ||
| 15 | type WsProtocolLifecycleHandlers, | ||
| 16 | type WsRpcProtocolClient, | ||
| 17 | type WsRpcProtocolSocketUrlProvider, | ||
| 18 | } from "./wsRpcProtocol.ts"; | ||
| 19 | |||
| 20 | export interface WsTransportOptions { | ||
| 21 | /** | ||
| 22 | * Merged into the transport `ManagedRuntime` alongside the RPC protocol layer | ||
| 23 | * (for example a `Tracer` layer for OTLP). | ||
| 24 | */ | ||
| 25 | readonly tracingLayer?: Layer.Layer<never, never, never>; | ||
| 26 | /** | ||
| 27 | * Override protocol construction (defaults to {@link createWsRpcProtocolLayer}). | ||
| 28 | * The web app supplies its instrumented layer factory. | ||
| 29 | */ | ||
| 30 | readonly createProtocolLayer?: ( | ||
| 31 | url: WsRpcProtocolSocketUrlProvider, | ||
| 32 | lifecycleHandlers?: WsProtocolLifecycleHandlers, | ||
| 33 | ) => Layer.Layer<RpcClient.Protocol, never, never>; | ||
| 34 | readonly logWarning?: (message: string, metadata: { readonly error: string }) => void; | ||
| 35 | /** | ||
| 36 | * Invoked at the start of {@link WsTransport.reconnect} before the session is replaced. | ||
| 37 | */ | ||
| 38 | readonly onBeforeReconnect?: () => void; | ||
| 39 | } | ||
| 40 | |||
| 41 | interface SubscribeOptions { | ||
| 42 | readonly retryDelay?: Duration.Input; | ||
| 43 | readonly onResubscribe?: () => void; | ||
| 44 | readonly tag?: string; | ||
| 45 | } | ||
| 46 | |||
| 47 | const DEFAULT_SUBSCRIPTION_RETRY_DELAY = Duration.millis(250); | ||
| 48 | const NOOP: () => void = () => undefined; | ||
| 49 | |||
| 50 | interface TransportSession { | ||
| 51 | readonly clientPromise: Promise<WsRpcProtocolClient>; | ||
| 52 | readonly clientScope: Scope.Closeable; | ||
| 53 | readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; | ||
| 54 | } | ||
| 55 | |||
| 56 | function formatErrorMessage(error: unknown): string { | ||
| 57 | if (error instanceof Error && error.message.trim().length > 0) { | ||
| 58 | return error.message; | ||
| 59 | } | ||
| 60 | |||
| 61 | return String(error); | ||
| 62 | } | ||
| 63 | |||
| 64 | export class WsTransport { | ||
| 65 | private readonly url: WsRpcProtocolSocketUrlProvider; | ||
| 66 | private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; | ||
| 67 | private readonly options: WsTransportOptions | undefined; | ||
| 68 | private disposed = false; | ||
| 69 | private hasReportedTransportDisconnect = false; | ||
| 70 | private intentionalCloseDepth = 0; | ||
| 71 | private nextSessionId = 0; | ||
| 72 | private activeSessionId = 0; | ||
| 73 | private lastHeartbeatPongAt: number | null = null; | ||
| 74 | private readonly streamRequestStartListeners = new Set< | ||
| 75 | (info: { readonly tag: string }) => void | ||
| 76 | >(); | ||
| 77 | private reconnectChain: Promise<void> = Promise.resolve(); | ||
| 78 | private session: TransportSession; | ||
| 79 | |||
| 80 | constructor( | ||
| 81 | url: WsRpcProtocolSocketUrlProvider, | ||
| 82 | lifecycleHandlers?: WsProtocolLifecycleHandlers, | ||
| 83 | options?: WsTransportOptions, | ||
| 84 | ) { | ||
| 85 | this.url = url; | ||
| 86 | this.lifecycleHandlers = lifecycleHandlers; | ||
| 87 | this.options = options; | ||
| 88 | this.session = this.createSession(); | ||
| 89 | } | ||
| 90 | |||
| 91 | async request<TSuccess>( | ||
| 92 | execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>, | ||
| 93 | ): Promise<TSuccess> { | ||
| 94 | if (this.disposed) { | ||
| 95 | throw new Error("Transport disposed"); | ||
| 96 | } | ||
| 97 | |||
| 98 | const session = this.session; | ||
| 99 | const client = await session.clientPromise; | ||
| 100 | return await session.runtime.runPromise(Effect.suspend(() => execute(client))); | ||
| 101 | } | ||
| 102 | |||
| 103 | async requestStream<TValue>( | ||
| 104 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | ||
| 105 | listener: (value: TValue) => void, | ||
| 106 | ): Promise<void> { | ||
| 107 | if (this.disposed) { | ||
| 108 | throw new Error("Transport disposed"); | ||
| 109 | } | ||
| 110 | |||
| 111 | const session = this.session; | ||
| 112 | const client = await session.clientPromise; | ||
| 113 | await session.runtime.runPromise( | ||
| 114 | Stream.runForEach(connect(client), (value) => | ||
| 115 | Effect.sync(() => { | ||
| 116 | try { | ||
| 117 | listener(value); | ||
| 118 | } catch { | ||
| 119 | // Ignore listener errors so the stream can finish cleanly. | ||
| 120 | } | ||
| 121 | }), | ||
| 122 | ), | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | |||
| 126 | subscribe<TValue>( | ||
| 127 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | ||
| 128 | listener: (value: TValue) => void, | ||
| 129 | options?: SubscribeOptions, | ||
| 130 | ): () => void { | ||
| 131 | if (this.disposed) { | ||
| 132 | return NOOP; | ||
| 133 | } | ||
| 134 | |||
| 135 | let active = true; | ||
| 136 | let hasReceivedValue = false; | ||
| 137 | const retryDelayMs = Duration.toMillis( | ||
| 138 | Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY), | ||
| 139 | ); | ||
| 140 | let cancelCurrentStream: () => void = NOOP; | ||
| 141 | const onStreamRequestStart = (info: { readonly tag: string }) => { | ||
| 142 | if ( | ||
| 143 | !hasReceivedValue || | ||
| 144 | !active || | ||
| 145 | (options?.tag !== undefined && info.tag !== options.tag) | ||
| 146 | ) { | ||
| 147 | return; | ||
| 148 | } | ||
| 149 | |||
| 150 | try { | ||
| 151 | options?.onResubscribe?.(); | ||
| 152 | } catch { | ||
| 153 | // Ignore reconnect hook failures so the stream can recover. | ||
| 154 | } | ||
| 155 | }; | ||
| 156 | this.streamRequestStartListeners.add(onStreamRequestStart); | ||
| 157 | |||
| 158 | void (async () => { | ||
| 159 | for (;;) { | ||
| 160 | if (!active || this.disposed) { | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | |||
| 164 | const session = this.session; | ||
| 165 | try { | ||
| 166 | if (hasReceivedValue) { | ||
| 167 | try { | ||
| 168 | options?.onResubscribe?.(); | ||
| 169 | } catch { | ||
| 170 | // Ignore reconnect hook failures so the stream can recover. | ||
| 171 | } | ||
| 172 | } | ||
| 173 | const runningStream = this.runStreamOnSession( | ||
| 174 | session, | ||
| 175 | connect, | ||
| 176 | listener, | ||
| 177 | () => active, | ||
| 178 | () => { | ||
| 179 | this.hasReportedTransportDisconnect = false; | ||
| 180 | hasReceivedValue = true; | ||
| 181 | }, | ||
| 182 | ); | ||
| 183 | cancelCurrentStream = runningStream.cancel; | ||
| 184 | await runningStream.completed; | ||
| 185 | cancelCurrentStream = NOOP; | ||
| 186 | } catch (error) { | ||
| 187 | cancelCurrentStream = NOOP; | ||
| 188 | if (!active || this.disposed) { | ||
| 189 | return; | ||
| 190 | } | ||
| 191 | |||
| 192 | // Skip retry if the session has already been replaced by a reconnect. | ||
| 193 | if (session !== this.session) { | ||
| 194 | continue; | ||
| 195 | } | ||
| 196 | |||
| 197 | const formattedError = formatErrorMessage(error); | ||
| 198 | if (!isTransportConnectionErrorMessage(formattedError)) { | ||
| 199 | this.logWarning("WebSocket RPC subscription failed", { error: formattedError }); | ||
| 200 | return; | ||
| 201 | } | ||
| 202 | |||
| 203 | if (!this.hasReportedTransportDisconnect) { | ||
| 204 | this.logWarning("WebSocket RPC subscription disconnected", { | ||
| 205 | error: formattedError, | ||
| 206 | }); | ||
| 207 | } | ||
| 208 | this.hasReportedTransportDisconnect = true; | ||
| 209 | await sleep(retryDelayMs); | ||
| 210 | } | ||
| 211 | } | ||
| 212 | })(); | ||
| 213 | |||
| 214 | return () => { | ||
| 215 | active = false; | ||
| 216 | this.streamRequestStartListeners.delete(onStreamRequestStart); | ||
| 217 | cancelCurrentStream(); | ||
| 218 | }; | ||
| 219 | } | ||
| 220 | |||
| 221 | async reconnect() { | ||
| 222 | if (this.disposed) { | ||
| 223 | throw new Error("Transport disposed"); | ||
| 224 | } | ||
| 225 | |||
| 226 | const reconnectOperation = this.reconnectChain.then(async () => { | ||
| 227 | if (this.disposed) { | ||
| 228 | throw new Error("Transport disposed"); | ||
| 229 | } | ||
| 230 | |||
| 231 | try { | ||
| 232 | this.options?.onBeforeReconnect?.(); | ||
| 233 | } catch { | ||
| 234 | // Ignore hook failures so reconnect can proceed. | ||
| 235 | } | ||
| 236 | |||
| 237 | this.lastHeartbeatPongAt = null; | ||
| 238 | const previousSession = this.session; | ||
| 239 | this.session = this.createSession(); | ||
| 240 | await this.closeSession(previousSession); | ||
| 241 | }); | ||
| 242 | |||
| 243 | this.reconnectChain = reconnectOperation.catch(() => undefined); | ||
| 244 | await reconnectOperation; | ||
| 245 | } | ||
| 246 | |||
| 247 | isHeartbeatFresh(maxAgeMs = 15_000): boolean { | ||
| 248 | return ( | ||
| 249 | this.lastHeartbeatPongAt !== null && performance.now() - this.lastHeartbeatPongAt <= maxAgeMs | ||
| 250 | ); | ||
| 251 | } | ||
| 252 | |||
| 253 | async dispose() { | ||
| 254 | if (this.disposed) { | ||
| 255 | return; | ||
| 256 | } | ||
| 257 | |||
| 258 | this.disposed = true; | ||
| 259 | await this.closeSession(this.session); | ||
| 260 | } | ||
| 261 | |||
| 262 | private closeSession(session: TransportSession) { | ||
| 263 | this.intentionalCloseDepth += 1; | ||
| 264 | return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { | ||
| 265 | this.intentionalCloseDepth = Math.max(0, this.intentionalCloseDepth - 1); | ||
| 266 | session.runtime.dispose(); | ||
| 267 | }); | ||
| 268 | } | ||
| 269 | |||
| 270 | private createSession(): TransportSession { | ||
| 271 | const protocolFactory = this.options?.createProtocolLayer ?? createWsRpcProtocolLayer; | ||
| 272 | const sessionId = this.nextSessionId + 1; | ||
| 273 | this.nextSessionId = sessionId; | ||
| 274 | this.activeSessionId = sessionId; | ||
| 275 | const lifecycleHandlers = this.lifecycleHandlers; | ||
| 276 | const protocolLayer = protocolFactory(this.url, { | ||
| 277 | ...lifecycleHandlers, | ||
| 278 | isActive: () => | ||
| 279 | !this.disposed && | ||
| 280 | this.activeSessionId === sessionId && | ||
| 281 | (lifecycleHandlers?.isActive?.() ?? true), | ||
| 282 | isCloseIntentional: () => | ||
| 283 | this.disposed || | ||
| 284 | this.intentionalCloseDepth > 0 || | ||
| 285 | lifecycleHandlers?.isCloseIntentional?.() === true, | ||
| 286 | onHeartbeatPong: () => { | ||
| 287 | this.lastHeartbeatPongAt = performance.now(); | ||
| 288 | lifecycleHandlers?.onHeartbeatPong?.(); | ||
| 289 | }, | ||
| 290 | onRequestStart: (info) => { | ||
| 291 | lifecycleHandlers?.onRequestStart?.(info); | ||
| 292 | if (!info.stream) { | ||
| 293 | return; | ||
| 294 | } | ||
| 295 | for (const listener of this.streamRequestStartListeners) { | ||
| 296 | listener({ tag: info.tag }); | ||
| 297 | } | ||
| 298 | }, | ||
| 299 | }); | ||
| 300 | const rootLayer = this.options?.tracingLayer | ||
| 301 | ? Layer.mergeAll(protocolLayer, this.options.tracingLayer) | ||
| 302 | : protocolLayer; | ||
| 303 | const runtime = ManagedRuntime.make(rootLayer); | ||
| 304 | const clientScope = runtime.runSync(Scope.make()); | ||
| 305 | return { | ||
| 306 | runtime, | ||
| 307 | clientScope, | ||
| 308 | clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)), | ||
| 309 | }; | ||
| 310 | } | ||
| 311 | |||
| 312 | private logWarning(message: string, metadata: { readonly error: string }) { | ||
| 313 | const logWarning = this.options?.logWarning; | ||
| 314 | if (logWarning) { | ||
| 315 | logWarning(message, metadata); | ||
| 316 | } else { | ||
| 317 | Effect.runSync(Effect.logWarning(message, metadata)); | ||
| 318 | } | ||
| 319 | } | ||
| 320 | |||
| 321 | private runStreamOnSession<TValue>( | ||
| 322 | session: TransportSession, | ||
| 323 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | ||
| 324 | listener: (value: TValue) => void, | ||
| 325 | isActive: () => boolean, | ||
| 326 | markValueReceived: () => void, | ||
| 327 | ): { | ||
| 328 | readonly cancel: () => void; | ||
| 329 | readonly completed: Promise<void>; | ||
| 330 | } { | ||
| 331 | let resolveCompleted!: () => void; | ||
| 332 | let rejectCompleted!: (error: unknown) => void; | ||
| 333 | const completed = new Promise<void>((resolve, reject) => { | ||
| 334 | resolveCompleted = resolve; | ||
| 335 | rejectCompleted = reject; | ||
| 336 | }); | ||
| 337 | const cancel = session.runtime.runCallback( | ||
| 338 | Effect.promise(() => session.clientPromise).pipe( | ||
| 339 | Effect.flatMap((client) => | ||
| 340 | Stream.runForEach(connect(client), (value) => | ||
| 341 | Effect.sync(() => { | ||
| 342 | if (!isActive()) { | ||
| 343 | return; | ||
| 344 | } | ||
| 345 | |||
| 346 | markValueReceived(); | ||
| 347 | try { | ||
| 348 | listener(value); | ||
| 349 | } catch { | ||
| 350 | // Ignore listener errors so the stream stays live. | ||
| 351 | } | ||
| 352 | }), | ||
| 353 | ), | ||
| 354 | ), | ||
| 355 | ), | ||
| 356 | { | ||
| 357 | onExit: (exit) => { | ||
| 358 | if (Exit.isSuccess(exit)) { | ||
| 359 | resolveCompleted(); | ||
| 360 | return; | ||
| 361 | } | ||
| 362 | |||
| 363 | rejectCompleted(Cause.squash(exit.cause)); | ||
| 364 | }, | ||
| 365 | }, | ||
| 366 | ); | ||
| 367 | |||
| 368 | return { | ||
| 369 | cancel, | ||
| 370 | completed, | ||
| 371 | }; | ||
| 372 | } | ||
| 373 | } | ||
| 374 | |||
| 375 | function sleep(ms: number): Promise<void> { | ||
| 376 | return Effect.runPromise(Effect.sleep(Duration.millis(ms))); | ||
| 377 | } | ||
packages/client-runtime/tsconfig.json-1| ... | @@ -1,5 +1,4 @@ | ... | @@ -1,5 +1,4 @@ |
| 1 | { | 1 | { |
| 2 | "extends": "../../tsconfig.base.json", | 2 | "extends": "../../tsconfig.base.json", |
| 3 | "compilerOptions": {}, | ||
| 4 | "include": ["src"] | 3 | "include": ["src"] |
| 5 | } | 4 | } |
packages/contracts/src/ipc.ts+3-14| ... | @@ -899,13 +899,9 @@ export interface DesktopBridge { | ... | @@ -899,13 +899,9 @@ export interface DesktopBridge { |
| 899 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; | 899 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
| 900 | getClientSettings: () => Promise<ClientSettings | null>; | 900 | getClientSettings: () => Promise<ClientSettings | null>; |
| 901 | setClientSettings: (settings: ClientSettings) => Promise<void>; | 901 | setClientSettings: (settings: ClientSettings) => Promise<void>; |
| 902 | getSavedEnvironmentRegistry: () => Promise<readonly PersistedSavedEnvironmentRecord[]>; | 902 | getConnectionCatalog?: () => Promise<string | null>; |
| 903 | setSavedEnvironmentRegistry: ( | 903 | setConnectionCatalog?: (catalog: string) => Promise<boolean>; |
| 904 | records: readonly PersistedSavedEnvironmentRecord[], | 904 | clearConnectionCatalog?: () => Promise<void>; |
| 905 | ) => Promise<void>; | ||
| 906 | getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>; | ||
| 907 | setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>; | ||
| 908 | removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>; | ||
| 909 | discoverSshHosts: () => Promise<readonly DesktopDiscoveredSshHost[]>; | 905 | discoverSshHosts: () => Promise<readonly DesktopDiscoveredSshHost[]>; |
| 910 | ensureSshEnvironment: ( | 906 | ensureSshEnvironment: ( |
| 911 | target: DesktopSshEnvironmentTarget, | 907 | target: DesktopSshEnvironmentTarget, |
| ... | @@ -1049,13 +1045,6 @@ export interface LocalApi { | ... | @@ -1049,13 +1045,6 @@ export interface LocalApi { |
| 1049 | persistence: { | 1045 | persistence: { |
| 1050 | getClientSettings: () => Promise<ClientSettings | null>; | 1046 | getClientSettings: () => Promise<ClientSettings | null>; |
| 1051 | setClientSettings: (settings: ClientSettings) => Promise<void>; | 1047 | setClientSettings: (settings: ClientSettings) => Promise<void>; |
| 1052 | getSavedEnvironmentRegistry: () => Promise<readonly PersistedSavedEnvironmentRecord[]>; | ||
| 1053 | setSavedEnvironmentRegistry: ( | ||
| 1054 | records: readonly PersistedSavedEnvironmentRecord[], | ||
| 1055 | ) => Promise<void>; | ||
| 1056 | getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>; | ||
| 1057 | setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>; | ||
| 1058 | removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>; | ||
| 1059 | }; | 1048 | }; |
| 1060 | server: { | 1049 | server: { |
| 1061 | getConfig: () => Promise<ServerConfig>; | 1050 | getConfig: () => Promise<ServerConfig>; |
packages/contracts/src/relay.ts+1| ... | @@ -711,6 +711,7 @@ export const RelayEnvironmentStatusResponse = Schema.Struct({ | ... | @@ -711,6 +711,7 @@ export const RelayEnvironmentStatusResponse = Schema.Struct({ |
| 711 | checkedAt: TrimmedNonEmptyString, | 711 | checkedAt: TrimmedNonEmptyString, |
| 712 | descriptor: Schema.optional(ExecutionEnvironmentDescriptor), | 712 | descriptor: Schema.optional(ExecutionEnvironmentDescriptor), |
| 713 | error: Schema.optional(TrimmedNonEmptyString), | 713 | error: Schema.optional(TrimmedNonEmptyString), |
| 714 | traceId: Schema.optional(TrimmedNonEmptyString), | ||
| 714 | }); | 715 | }); |
| 715 | export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type; | 716 | export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type; |
| 716 | 717 |
packages/shared/package.json+4-4| ... | @@ -155,6 +155,10 @@ | ... | @@ -155,6 +155,10 @@ |
| 155 | "types": "./src/relayClient.ts", | 155 | "types": "./src/relayClient.ts", |
| 156 | "import": "./src/relayClient.ts" | 156 | "import": "./src/relayClient.ts" |
| 157 | }, | 157 | }, |
| 158 | "./relayTracing": { | ||
| 159 | "types": "./src/relayTracing.ts", | ||
| 160 | "import": "./src/relayTracing.ts" | ||
| 161 | }, | ||
| 158 | "./preview": { | 162 | "./preview": { |
| 159 | "types": "./src/preview.ts", | 163 | "types": "./src/preview.ts", |
| 160 | "import": "./src/preview.ts" | 164 | "import": "./src/preview.ts" |
| ... | @@ -162,10 +166,6 @@ | ... | @@ -162,10 +166,6 @@ |
| 162 | "./hostProcess": { | 166 | "./hostProcess": { |
| 163 | "types": "./src/hostProcess.ts", | 167 | "types": "./src/hostProcess.ts", |
| 164 | "import": "./src/hostProcess.ts" | 168 | "import": "./src/hostProcess.ts" |
| 165 | }, | ||
| 166 | "./relayTracing": { | ||
| 167 | "types": "./src/relayTracing.ts", | ||
| 168 | "import": "./src/relayTracing.ts" | ||
| 169 | } | 169 | } |
| 170 | }, | 170 | }, |
| 171 | "scripts": { | 171 | "scripts": { |
packages/shared/src/Net.test.ts+2-2| ... | @@ -81,9 +81,9 @@ it.layer(NetService.layer)("NetService", (it) => { | ... | @@ -81,9 +81,9 @@ it.layer(NetService.layer)("NetService", (it) => { |
| 81 | }), | 81 | }), |
| 82 | ); | 82 | ); |
| 83 | 83 | ||
| 84 | it.effect("findAvailablePort falls back when preferred is occupied", () => | 84 | it.effect("findAvailablePort falls back when a wildcard listener occupies IPv4", () => |
| 85 | Effect.acquireUseRelease( | 85 | Effect.acquireUseRelease( |
| 86 | openServer(), | 86 | openServer("0.0.0.0"), |
| 87 | (server) => | 87 | (server) => |
| 88 | Effect.gen(function* () { | 88 | Effect.gen(function* () { |
| 89 | const net = yield* NetService.NetService; | 89 | const net = yield* NetService.NetService; |
packages/shared/src/Net.ts+54-41| ... | @@ -28,40 +28,6 @@ const closeServer = (server: NodeNet.Server) => { | ... | @@ -28,40 +28,6 @@ const closeServer = (server: NodeNet.Server) => { |
| 28 | } | 28 | } |
| 29 | }; | 29 | }; |
| 30 | 30 | ||
| 31 | const tryReservePort = (port: number): Effect.Effect<number, NetError> => | ||
| 32 | Effect.callback<number, NetError>((resume) => { | ||
| 33 | const server = NodeNet.createServer(); | ||
| 34 | let settled = false; | ||
| 35 | |||
| 36 | const settle = (effect: Effect.Effect<number, NetError>) => { | ||
| 37 | if (settled) return; | ||
| 38 | settled = true; | ||
| 39 | resume(effect); | ||
| 40 | }; | ||
| 41 | |||
| 42 | server.unref(); | ||
| 43 | |||
| 44 | server.once("error", (cause) => { | ||
| 45 | settle(Effect.fail(new NetError({ message: "Could not find an available port.", cause }))); | ||
| 46 | }); | ||
| 47 | |||
| 48 | server.listen(port, () => { | ||
| 49 | const address = server.address(); | ||
| 50 | const resolved = typeof address === "object" && address !== null ? address.port : 0; | ||
| 51 | server.close(() => { | ||
| 52 | if (resolved > 0) { | ||
| 53 | settle(Effect.succeed(resolved)); | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | settle(Effect.fail(new NetError({ message: "Could not find an available port." }))); | ||
| 57 | }); | ||
| 58 | }); | ||
| 59 | |||
| 60 | return Effect.sync(() => { | ||
| 61 | closeServer(server); | ||
| 62 | }); | ||
| 63 | }); | ||
| 64 | |||
| 65 | export interface NetServiceShape { | 31 | export interface NetServiceShape { |
| 66 | /** | 32 | /** |
| 67 | * Returns true when a TCP server can bind to {host, port}. | 33 | * Returns true when a TCP server can bind to {host, port}. |
| ... | @@ -131,6 +97,53 @@ export const make = () => { | ... | @@ -131,6 +97,53 @@ export const make = () => { |
| 131 | }); | 97 | }); |
| 132 | }); | 98 | }); |
| 133 | 99 | ||
| 100 | const hasListenerOnHost = (port: number, host: string): Effect.Effect<boolean> => | ||
| 101 | Effect.callback<boolean>((resume) => { | ||
| 102 | const socket = NodeNet.createConnection({ host, port }); | ||
| 103 | let settled = false; | ||
| 104 | |||
| 105 | const settle = (value: boolean) => { | ||
| 106 | if (settled) return; | ||
| 107 | settled = true; | ||
| 108 | socket.destroy(); | ||
| 109 | resume(Effect.succeed(value)); | ||
| 110 | }; | ||
| 111 | |||
| 112 | socket.unref(); | ||
| 113 | socket.setTimeout(250); | ||
| 114 | socket.once("connect", () => { | ||
| 115 | settle(true); | ||
| 116 | }); | ||
| 117 | socket.once("error", () => { | ||
| 118 | settle(false); | ||
| 119 | }); | ||
| 120 | socket.once("timeout", () => { | ||
| 121 | settle(false); | ||
| 122 | }); | ||
| 123 | |||
| 124 | return Effect.sync(() => { | ||
| 125 | socket.destroy(); | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | |||
| 129 | const isPortAvailableOnLoopback = (port: number): Effect.Effect<boolean> => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const hasListener = yield* Effect.zipWith( | ||
| 132 | hasListenerOnHost(port, "127.0.0.1"), | ||
| 133 | hasListenerOnHost(port, "::1"), | ||
| 134 | (ipv4, ipv6) => ipv4 || ipv6, | ||
| 135 | ); | ||
| 136 | if (hasListener) { | ||
| 137 | return false; | ||
| 138 | } | ||
| 139 | |||
| 140 | return yield* Effect.zipWith( | ||
| 141 | canListenOnHost(port, "127.0.0.1"), | ||
| 142 | canListenOnHost(port, "::1"), | ||
| 143 | (ipv4, ipv6) => ipv4 && ipv6, | ||
| 144 | ); | ||
| 145 | }); | ||
| 146 | |||
| 134 | /** | 147 | /** |
| 135 | * Reserve an ephemeral loopback port and release it immediately. | 148 | * Reserve an ephemeral loopback port and release it immediately. |
| 136 | * Returns the reserved port number. | 149 | * Returns the reserved port number. |
| ... | @@ -169,15 +182,15 @@ export const make = () => { | ... | @@ -169,15 +182,15 @@ export const make = () => { |
| 169 | 182 | ||
| 170 | return { | 183 | return { |
| 171 | canListenOnHost, | 184 | canListenOnHost, |
| 172 | isPortAvailableOnLoopback: (port) => | 185 | isPortAvailableOnLoopback, |
| 173 | Effect.zipWith( | ||
| 174 | canListenOnHost(port, "127.0.0.1"), | ||
| 175 | canListenOnHost(port, "::1"), | ||
| 176 | (ipv4, ipv6) => ipv4 && ipv6, | ||
| 177 | ), | ||
| 178 | reserveLoopbackPort, | 186 | reserveLoopbackPort, |
| 179 | findAvailablePort: (preferred) => | 187 | findAvailablePort: (preferred) => |
| 180 | Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), | 188 | Effect.gen(function* () { |
| 189 | if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) { | ||
| 190 | return preferred; | ||
| 191 | } | ||
| 192 | return yield* reserveLoopbackPort(); | ||
| 193 | }), | ||
| 181 | } satisfies NetServiceShape; | 194 | } satisfies NetServiceShape; |
| 182 | }; | 195 | }; |
| 183 | 196 |
packages/shared/src/relayJwt.ts+2-1| ... | @@ -49,6 +49,7 @@ export function verifyRelayJwt(input: { | ... | @@ -49,6 +49,7 @@ export function verifyRelayJwt(input: { |
| 49 | readonly issuer: string; | 49 | readonly issuer: string; |
| 50 | readonly audience: string; | 50 | readonly audience: string; |
| 51 | readonly nowEpochSeconds: number; | 51 | readonly nowEpochSeconds: number; |
| 52 | readonly maxTokenAge?: string | number; | ||
| 52 | }): Effect.Effect<JWTPayload, RelayJwtError> { | 53 | }): Effect.Effect<JWTPayload, RelayJwtError> { |
| 53 | return Effect.tryPromise({ | 54 | return Effect.tryPromise({ |
| 54 | try: async () => { | 55 | try: async () => { |
| ... | @@ -58,7 +59,7 @@ export function verifyRelayJwt(input: { | ... | @@ -58,7 +59,7 @@ export function verifyRelayJwt(input: { |
| 58 | typ: input.typ, | 59 | typ: input.typ, |
| 59 | issuer: input.issuer, | 60 | issuer: input.issuer, |
| 60 | audience: input.audience, | 61 | audience: input.audience, |
| 61 | maxTokenAge: "5 minutes", | 62 | maxTokenAge: input.maxTokenAge ?? "5 minutes", |
| 62 | clockTolerance: 60, | 63 | clockTolerance: 60, |
| 63 | currentDate: DateTime.toDate(DateTime.makeUnsafe(input.nowEpochSeconds * 1_000)), | 64 | currentDate: DateTime.toDate(DateTime.makeUnsafe(input.nowEpochSeconds * 1_000)), |
| 64 | }); | 65 | }); |
packages/shared/src/relayTracing.ts+70-3| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import * as Cause from "effect/Cause"; | ||
| 1 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | ||
| 3 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 4 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 5 | import * as Tracer from "effect/Tracer"; | 7 | import * as Tracer from "effect/Tracer"; |
| ... | @@ -39,6 +41,70 @@ export const withRelayClientTracing = <A, E, R>( | ... | @@ -39,6 +41,70 @@ export const withRelayClientTracing = <A, E, R>( |
| 39 | ), | 41 | ), |
| 40 | ); | 42 | ); |
| 41 | 43 | ||
| 44 | function traceSafeError(value: unknown): Error { | ||
| 45 | const message = | ||
| 46 | value instanceof Error | ||
| 47 | ? value.message | ||
| 48 | : typeof value === "object" && | ||
| 49 | value !== null && | ||
| 50 | "message" in value && | ||
| 51 | typeof value.message === "string" | ||
| 52 | ? value.message | ||
| 53 | : String(value); | ||
| 54 | const error = new Error(message); | ||
| 55 | if (value instanceof Error) { | ||
| 56 | error.name = value.name; | ||
| 57 | if (value.stack !== undefined) { | ||
| 58 | error.stack = value.stack; | ||
| 59 | } | ||
| 60 | } else if ( | ||
| 61 | typeof value === "object" && | ||
| 62 | value !== null && | ||
| 63 | "name" in value && | ||
| 64 | typeof value.name === "string" | ||
| 65 | ) { | ||
| 66 | error.name = value.name; | ||
| 67 | } | ||
| 68 | return error; | ||
| 69 | } | ||
| 70 | |||
| 71 | function traceSafeExit(exit: Exit.Exit<unknown, unknown>): Exit.Exit<unknown, unknown> { | ||
| 72 | if (Exit.isSuccess(exit)) { | ||
| 73 | return exit; | ||
| 74 | } | ||
| 75 | return Exit.failCause( | ||
| 76 | Cause.fromReasons( | ||
| 77 | exit.cause.reasons.map((reason) => { | ||
| 78 | if (Cause.isFailReason(reason)) { | ||
| 79 | return Cause.makeFailReason(traceSafeError(reason.error)); | ||
| 80 | } | ||
| 81 | if (Cause.isDieReason(reason)) { | ||
| 82 | return Cause.makeDieReason(traceSafeError(reason.defect)); | ||
| 83 | } | ||
| 84 | return reason; | ||
| 85 | }), | ||
| 86 | ), | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | function nonInterferingTracer(delegate: Tracer.Tracer): Tracer.Tracer { | ||
| 91 | return Tracer.make({ | ||
| 92 | span(options) { | ||
| 93 | const span = delegate.span(options); | ||
| 94 | const end = span.end.bind(span); | ||
| 95 | span.end = (endTime, exit) => { | ||
| 96 | try { | ||
| 97 | end(endTime, traceSafeExit(exit)); | ||
| 98 | } catch { | ||
| 99 | // Telemetry is best-effort and must never change application behavior. | ||
| 100 | } | ||
| 101 | }; | ||
| 102 | return span; | ||
| 103 | }, | ||
| 104 | ...(delegate.context ? { context: delegate.context } : {}), | ||
| 105 | }); | ||
| 106 | } | ||
| 107 | |||
| 42 | export function makeRelayClientTracingLayer( | 108 | export function makeRelayClientTracingLayer( |
| 43 | config: RelayClientTracingConfig | null, | 109 | config: RelayClientTracingConfig | null, |
| 44 | resource: RelayClientTracingResource, | 110 | resource: RelayClientTracingResource, |
| ... | @@ -64,7 +130,8 @@ export function makeRelayClientTracingLayer( | ... | @@ -64,7 +130,8 @@ export function makeRelayClientTracingLayer( |
| 64 | }, | 130 | }, |
| 65 | }).pipe(Layer.provide(OtlpSerialization.layerJson)); | 131 | }).pipe(Layer.provide(OtlpSerialization.layerJson)); |
| 66 | 132 | ||
| 67 | return Layer.effect(RelayClientTracer, Tracer.Tracer.pipe(Effect.map(Option.some))).pipe( | 133 | return Layer.effect( |
| 68 | Layer.provide(tracerLayer), | 134 | RelayClientTracer, |
| 69 | ); | 135 | Tracer.Tracer.pipe(Effect.map(nonInterferingTracer), Effect.map(Option.some)), |
| 136 | ).pipe(Layer.provide(tracerLayer)); | ||
| 70 | } | 137 | } |
pnpm-lock.yaml+14-251| ... | @@ -290,6 +290,9 @@ importers: | ... | @@ -290,6 +290,9 @@ importers: |
| 290 | expo-linking: | 290 | expo-linking: |
| 291 | specifier: ~56.0.12 | 291 | specifier: ~56.0.12 |
| 292 | version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 292 | version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 293 | expo-network: | ||
| 294 | specifier: ~56.0.5 | ||
| 295 | version: 56.0.5(expo@56.0.8)(react@19.2.3) | ||
| 293 | expo-notifications: | 296 | expo-notifications: |
| 294 | specifier: ~56.0.14 | 297 | specifier: ~56.0.14 |
| 295 | version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | 298 | version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) |
| ... | @@ -514,9 +517,6 @@ importers: | ... | @@ -514,9 +517,6 @@ importers: |
| 514 | '@tanstack/react-pacer': | 517 | '@tanstack/react-pacer': |
| 515 | specifier: ^0.19.4 | 518 | specifier: ^0.19.4 |
| 516 | version: 0.19.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 519 | version: 0.19.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 517 | '@tanstack/react-query': | ||
| 518 | specifier: ^5.90.0 | ||
| 519 | version: 5.100.14(react@19.2.6) | ||
| 520 | '@tanstack/react-router': | 520 | '@tanstack/react-router': |
| 521 | specifier: ^1.160.2 | 521 | specifier: ^1.160.2 |
| 522 | version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 522 | version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| ... | @@ -605,9 +605,6 @@ importers: | ... | @@ -605,9 +605,6 @@ importers: |
| 605 | msw: | 605 | msw: |
| 606 | specifier: 2.12.11 | 606 | specifier: 2.12.11 |
| 607 | version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) | 607 | version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) |
| 608 | playwright: | ||
| 609 | specifier: ^1.58.2 | ||
| 610 | version: 1.60.0 | ||
| 611 | tailwindcss: | 608 | tailwindcss: |
| 612 | specifier: ^4.0.0 | 609 | specifier: ^4.0.0 |
| 613 | version: 4.3.0 | 610 | version: 4.3.0 |
| ... | @@ -617,9 +614,6 @@ importers: | ... | @@ -617,9 +614,6 @@ importers: |
| 617 | vite-plus: | 614 | vite-plus: |
| 618 | specifier: 'catalog:' | 615 | specifier: 'catalog:' |
| 619 | version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | 616 | version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| 620 | vitest-browser-react: | ||
| 621 | specifier: ^2.0.5 | ||
| 622 | version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) | ||
| 623 | 617 | ||
| 624 | infra/relay: | 618 | infra/relay: |
| 625 | dependencies: | 619 | dependencies: |
| ... | @@ -4406,11 +4400,6 @@ packages: | ... | @@ -4406,11 +4400,6 @@ packages: |
| 4406 | react: '>=16.8' | 4400 | react: '>=16.8' |
| 4407 | react-dom: '>=16.8' | 4401 | react-dom: '>=16.8' |
| 4408 | 4402 | ||
| 4409 | '@tanstack/react-query@5.100.14': | ||
| 4410 | resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==} | ||
| 4411 | peerDependencies: | ||
| 4412 | react: ^18 || ^19 | ||
| 4413 | |||
| 4414 | '@tanstack/react-router@1.170.10': | 4403 | '@tanstack/react-router@1.170.10': |
| 4415 | resolution: {integrity: sha512-gVmWYq0ucWr+OB97Nud0YhKa9NOipB7/QrWI7wRZJJWEL0qUS8WPqAs0vA1f3IBXZpXmf8xxzf/tl5cmo4tlmA==} | 4404 | resolution: {integrity: sha512-gVmWYq0ucWr+OB97Nud0YhKa9NOipB7/QrWI7wRZJJWEL0qUS8WPqAs0vA1f3IBXZpXmf8xxzf/tl5cmo4tlmA==} |
| 4416 | engines: {node: '>=20.19'} | 4405 | engines: {node: '>=20.19'} |
| ... | @@ -4679,35 +4668,6 @@ packages: | ... | @@ -4679,35 +4668,6 @@ packages: |
| 4679 | babel-plugin-react-compiler: | 4668 | babel-plugin-react-compiler: |
| 4680 | optional: true | 4669 | optional: true |
| 4681 | 4670 | ||
| 4682 | '@vitest/expect@4.1.8': | ||
| 4683 | resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} | ||
| 4684 | |||
| 4685 | '@vitest/mocker@4.1.8': | ||
| 4686 | resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} | ||
| 4687 | peerDependencies: | ||
| 4688 | msw: ^2.4.9 | ||
| 4689 | vite: ^6.0.0 || ^7.0.0 || ^8.0.0 | ||
| 4690 | peerDependenciesMeta: | ||
| 4691 | msw: | ||
| 4692 | optional: true | ||
| 4693 | vite: | ||
| 4694 | optional: true | ||
| 4695 | |||
| 4696 | '@vitest/pretty-format@4.1.8': | ||
| 4697 | resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} | ||
| 4698 | |||
| 4699 | '@vitest/runner@4.1.8': | ||
| 4700 | resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} | ||
| 4701 | |||
| 4702 | '@vitest/snapshot@4.1.8': | ||
| 4703 | resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} | ||
| 4704 | |||
| 4705 | '@vitest/spy@4.1.8': | ||
| 4706 | resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} | ||
| 4707 | |||
| 4708 | '@vitest/utils@4.1.8': | ||
| 4709 | resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} | ||
| 4710 | |||
| 4711 | '@voidzero-dev/vite-plus-core@0.1.24': | 4671 | '@voidzero-dev/vite-plus-core@0.1.24': |
| 4712 | resolution: {integrity: sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==} | 4672 | resolution: {integrity: sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==} |
| 4713 | engines: {node: ^20.19.0 || >=22.12.0} | 4673 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -5372,10 +5332,6 @@ packages: | ... | @@ -5372,10 +5332,6 @@ packages: |
| 5372 | ccount@2.0.1: | 5332 | ccount@2.0.1: |
| 5373 | resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} | 5333 | resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} |
| 5374 | 5334 | ||
| 5375 | chai@6.2.2: | ||
| 5376 | resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} | ||
| 5377 | engines: {node: '>=18'} | ||
| 5378 | |||
| 5379 | chalk@2.4.2: | 5335 | chalk@2.4.2: |
| 5380 | resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} | 5336 | resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} |
| 5381 | engines: {node: '>=4'} | 5337 | engines: {node: '>=4'} |
| ... | @@ -6109,9 +6065,6 @@ packages: | ... | @@ -6109,9 +6065,6 @@ packages: |
| 6109 | estree-walker@2.0.2: | 6065 | estree-walker@2.0.2: |
| 6110 | resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} | 6066 | resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} |
| 6111 | 6067 | ||
| 6112 | estree-walker@3.0.3: | ||
| 6113 | resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} | ||
| 6114 | |||
| 6115 | etag@1.8.1: | 6068 | etag@1.8.1: |
| 6116 | resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} | 6069 | resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} |
| 6117 | engines: {node: '>= 0.6'} | 6070 | engines: {node: '>= 0.6'} |
| ... | @@ -6131,10 +6084,6 @@ packages: | ... | @@ -6131,10 +6084,6 @@ packages: |
| 6131 | resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} | 6084 | resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} |
| 6132 | engines: {node: '>=18.0.0'} | 6085 | engines: {node: '>=18.0.0'} |
| 6133 | 6086 | ||
| 6134 | expect-type@1.3.0: | ||
| 6135 | resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} | ||
| 6136 | engines: {node: '>=12.0.0'} | ||
| 6137 | |||
| 6138 | expo-application@56.0.3: | 6087 | expo-application@56.0.3: |
| 6139 | resolution: {integrity: sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==} | 6088 | resolution: {integrity: sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==} |
| 6140 | peerDependencies: | 6089 | peerDependencies: |
| ... | @@ -6286,6 +6235,12 @@ packages: | ... | @@ -6286,6 +6235,12 @@ packages: |
| 6286 | peerDependencies: | 6235 | peerDependencies: |
| 6287 | react-native: '*' | 6236 | react-native: '*' |
| 6288 | 6237 | ||
| 6238 | expo-network@56.0.5: | ||
| 6239 | resolution: {integrity: sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==} | ||
| 6240 | peerDependencies: | ||
| 6241 | expo: '*' | ||
| 6242 | react: '*' | ||
| 6243 | |||
| 6289 | expo-notifications@56.0.15: | 6244 | expo-notifications@56.0.15: |
| 6290 | resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==} | 6245 | resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==} |
| 6291 | peerDependencies: | 6246 | peerDependencies: |
| ... | @@ -6586,11 +6541,6 @@ packages: | ... | @@ -6586,11 +6541,6 @@ packages: |
| 6586 | fs.realpath@1.0.0: | 6541 | fs.realpath@1.0.0: |
| 6587 | resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} | 6542 | resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} |
| 6588 | 6543 | ||
| 6589 | fsevents@2.3.2: | ||
| 6590 | resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} | ||
| 6591 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | ||
| 6592 | os: [darwin] | ||
| 6593 | |||
| 6594 | fsevents@2.3.3: | 6544 | fsevents@2.3.3: |
| 6595 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} | 6545 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} |
| 6596 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | 6546 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} |
| ... | @@ -7940,10 +7890,6 @@ packages: | ... | @@ -7940,10 +7890,6 @@ packages: |
| 7940 | obug@2.1.1: | 7890 | obug@2.1.1: |
| 7941 | resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} | 7891 | resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} |
| 7942 | 7892 | ||
| 7943 | obug@2.1.2: | ||
| 7944 | resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} | ||
| 7945 | engines: {node: '>=12.20.0'} | ||
| 7946 | |||
| 7947 | ofetch@1.5.1: | 7893 | ofetch@1.5.1: |
| 7948 | resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} | 7894 | resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} |
| 7949 | 7895 | ||
| ... | @@ -8197,11 +8143,6 @@ packages: | ... | @@ -8197,11 +8143,6 @@ packages: |
| 8197 | engines: {node: '>=18'} | 8143 | engines: {node: '>=18'} |
| 8198 | hasBin: true | 8144 | hasBin: true |
| 8199 | 8145 | ||
| 8200 | playwright@1.60.0: | ||
| 8201 | resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} | ||
| 8202 | engines: {node: '>=18'} | ||
| 8203 | hasBin: true | ||
| 8204 | |||
| 8205 | plist@3.1.0: | 8146 | plist@3.1.0: |
| 8206 | resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} | 8147 | resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} |
| 8207 | engines: {node: '>=10.4.0'} | 8148 | engines: {node: '>=10.4.0'} |
| ... | @@ -8916,9 +8857,6 @@ packages: | ... | @@ -8916,9 +8857,6 @@ packages: |
| 8916 | resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} | 8857 | resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} |
| 8917 | engines: {node: '>= 0.4'} | 8858 | engines: {node: '>= 0.4'} |
| 8918 | 8859 | ||
| 8919 | siginfo@2.0.0: | ||
| 8920 | resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} | ||
| 8921 | |||
| 8922 | signal-exit@3.0.7: | 8860 | signal-exit@3.0.7: |
| 8923 | resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} | 8861 | resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} |
| 8924 | 8862 | ||
| ... | @@ -9000,9 +8938,6 @@ packages: | ... | @@ -9000,9 +8938,6 @@ packages: |
| 9000 | resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} | 8938 | resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} |
| 9001 | engines: {node: '>=10'} | 8939 | engines: {node: '>=10'} |
| 9002 | 8940 | ||
| 9003 | stackback@0.0.2: | ||
| 9004 | resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} | ||
| 9005 | |||
| 9006 | stackframe@1.3.4: | 8941 | stackframe@1.3.4: |
| 9007 | resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} | 8942 | resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} |
| 9008 | 8943 | ||
| ... | @@ -9200,10 +9135,6 @@ packages: | ... | @@ -9200,10 +9135,6 @@ packages: |
| 9200 | resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} | 9135 | resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} |
| 9201 | engines: {node: ^20.0.0 || >=22.0.0} | 9136 | engines: {node: ^20.0.0 || >=22.0.0} |
| 9202 | 9137 | ||
| 9203 | tinyrainbow@3.1.0: | ||
| 9204 | resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} | ||
| 9205 | engines: {node: '>=14.0.0'} | ||
| 9206 | |||
| 9207 | tldts-core@7.4.2: | 9138 | tldts-core@7.4.2: |
| 9208 | resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} | 9139 | resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} |
| 9209 | 9140 | ||
| ... | @@ -9569,61 +9500,6 @@ packages: | ... | @@ -9569,61 +9500,6 @@ packages: |
| 9569 | vite: | 9500 | vite: |
| 9570 | optional: true | 9501 | optional: true |
| 9571 | 9502 | ||
| 9572 | vitest-browser-react@2.2.0: | ||
| 9573 | resolution: {integrity: sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==} | ||
| 9574 | peerDependencies: | ||
| 9575 | '@types/react': ^18.0.0 || ^19.0.0 | ||
| 9576 | '@types/react-dom': ^18.0.0 || ^19.0.0 | ||
| 9577 | react: ^18.0.0 || ^19.0.0 | ||
| 9578 | react-dom: ^18.0.0 || ^19.0.0 | ||
| 9579 | vitest: ^4.0.0 | ||
| 9580 | peerDependenciesMeta: | ||
| 9581 | '@types/react': | ||
| 9582 | optional: true | ||
| 9583 | '@types/react-dom': | ||
| 9584 | optional: true | ||
| 9585 | |||
| 9586 | vitest@4.1.8: | ||
| 9587 | resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} | ||
| 9588 | engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} | ||
| 9589 | hasBin: true | ||
| 9590 | peerDependencies: | ||
| 9591 | '@edge-runtime/vm': '*' | ||
| 9592 | '@opentelemetry/api': ^1.9.0 | ||
| 9593 | '@types/node': 24.12.4 | ||
| 9594 | '@vitest/browser-playwright': 4.1.8 | ||
| 9595 | '@vitest/browser-preview': 4.1.8 | ||
| 9596 | '@vitest/browser-webdriverio': 4.1.8 | ||
| 9597 | '@vitest/coverage-istanbul': 4.1.8 | ||
| 9598 | '@vitest/coverage-v8': 4.1.8 | ||
| 9599 | '@vitest/ui': 4.1.8 | ||
| 9600 | happy-dom: '*' | ||
| 9601 | jsdom: '*' | ||
| 9602 | vite: ^6.0.0 || ^7.0.0 || ^8.0.0 | ||
| 9603 | peerDependenciesMeta: | ||
| 9604 | '@edge-runtime/vm': | ||
| 9605 | optional: true | ||
| 9606 | '@opentelemetry/api': | ||
| 9607 | optional: true | ||
| 9608 | '@types/node': | ||
| 9609 | optional: true | ||
| 9610 | '@vitest/browser-playwright': | ||
| 9611 | optional: true | ||
| 9612 | '@vitest/browser-preview': | ||
| 9613 | optional: true | ||
| 9614 | '@vitest/browser-webdriverio': | ||
| 9615 | optional: true | ||
| 9616 | '@vitest/coverage-istanbul': | ||
| 9617 | optional: true | ||
| 9618 | '@vitest/coverage-v8': | ||
| 9619 | optional: true | ||
| 9620 | '@vitest/ui': | ||
| 9621 | optional: true | ||
| 9622 | happy-dom: | ||
| 9623 | optional: true | ||
| 9624 | jsdom: | ||
| 9625 | optional: true | ||
| 9626 | |||
| 9627 | vlq@1.0.1: | 9503 | vlq@1.0.1: |
| 9628 | resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} | 9504 | resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} |
| 9629 | 9505 | ||
| ... | @@ -9767,11 +9643,6 @@ packages: | ... | @@ -9767,11 +9643,6 @@ packages: |
| 9767 | engines: {node: ^20.17.0 || >=22.9.0} | 9643 | engines: {node: ^20.17.0 || >=22.9.0} |
| 9768 | hasBin: true | 9644 | hasBin: true |
| 9769 | 9645 | ||
| 9770 | why-is-node-running@2.3.0: | ||
| 9771 | resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} | ||
| 9772 | engines: {node: '>=8'} | ||
| 9773 | hasBin: true | ||
| 9774 | |||
| 9775 | widest-line@6.0.0: | 9646 | widest-line@6.0.0: |
| 9776 | resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} | 9647 | resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} |
| 9777 | engines: {node: '>=20'} | 9648 | engines: {node: '>=20'} |
| ... | @@ -13662,11 +13533,6 @@ snapshots: | ... | @@ -13662,11 +13533,6 @@ snapshots: |
| 13662 | react: 19.2.6 | 13533 | react: 19.2.6 |
| 13663 | react-dom: 19.2.6(react@19.2.6) | 13534 | react-dom: 19.2.6(react@19.2.6) |
| 13664 | 13535 | ||
| 13665 | '@tanstack/react-query@5.100.14(react@19.2.6)': | ||
| 13666 | dependencies: | ||
| 13667 | '@tanstack/query-core': 5.100.14 | ||
| 13668 | react: 19.2.6 | ||
| 13669 | |||
| 13670 | '@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 13536 | '@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 13671 | dependencies: | 13537 | dependencies: |
| 13672 | '@tanstack/history': 1.162.0 | 13538 | '@tanstack/history': 1.162.0 |
| ... | @@ -13975,48 +13841,6 @@ snapshots: | ... | @@ -13975,48 +13841,6 @@ snapshots: |
| 13975 | '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.0.3) | 13841 | '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.0.3) |
| 13976 | babel-plugin-react-compiler: 1.0.0 | 13842 | babel-plugin-react-compiler: 1.0.0 |
| 13977 | 13843 | ||
| 13978 | '@vitest/expect@4.1.8': | ||
| 13979 | dependencies: | ||
| 13980 | '@standard-schema/spec': 1.1.0 | ||
| 13981 | '@types/chai': 5.2.3 | ||
| 13982 | '@vitest/spy': 4.1.8 | ||
| 13983 | '@vitest/utils': 4.1.8 | ||
| 13984 | chai: 6.2.2 | ||
| 13985 | tinyrainbow: 3.1.0 | ||
| 13986 | |||
| 13987 | '@vitest/mocker@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))': | ||
| 13988 | dependencies: | ||
| 13989 | '@vitest/spy': 4.1.8 | ||
| 13990 | estree-walker: 3.0.3 | ||
| 13991 | magic-string: 0.30.21 | ||
| 13992 | optionalDependencies: | ||
| 13993 | msw: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) | ||
| 13994 | vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' | ||
| 13995 | |||
| 13996 | '@vitest/pretty-format@4.1.8': | ||
| 13997 | dependencies: | ||
| 13998 | tinyrainbow: 3.1.0 | ||
| 13999 | |||
| 14000 | '@vitest/runner@4.1.8': | ||
| 14001 | dependencies: | ||
| 14002 | '@vitest/utils': 4.1.8 | ||
| 14003 | pathe: 2.0.3 | ||
| 14004 | |||
| 14005 | '@vitest/snapshot@4.1.8': | ||
| 14006 | dependencies: | ||
| 14007 | '@vitest/pretty-format': 4.1.8 | ||
| 14008 | '@vitest/utils': 4.1.8 | ||
| 14009 | magic-string: 0.30.21 | ||
| 14010 | pathe: 2.0.3 | ||
| 14011 | |||
| 14012 | '@vitest/spy@4.1.8': {} | ||
| 14013 | |||
| 14014 | '@vitest/utils@4.1.8': | ||
| 14015 | dependencies: | ||
| 14016 | '@vitest/pretty-format': 4.1.8 | ||
| 14017 | convert-source-map: 2.0.0 | ||
| 14018 | tinyrainbow: 3.1.0 | ||
| 14019 | |||
| 14020 | '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': | 13844 | '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': |
| 14021 | dependencies: | 13845 | dependencies: |
| 14022 | '@oxc-project/runtime': 0.133.0 | 13846 | '@oxc-project/runtime': 0.133.0 |
| ... | @@ -14806,8 +14630,6 @@ snapshots: | ... | @@ -14806,8 +14630,6 @@ snapshots: |
| 14806 | 14630 | ||
| 14807 | ccount@2.0.1: {} | 14631 | ccount@2.0.1: {} |
| 14808 | 14632 | ||
| 14809 | chai@6.2.2: {} | ||
| 14810 | |||
| 14811 | chalk@2.4.2: | 14633 | chalk@2.4.2: |
| 14812 | dependencies: | 14634 | dependencies: |
| 14813 | ansi-styles: 3.2.1 | 14635 | ansi-styles: 3.2.1 |
| ... | @@ -15480,10 +15302,6 @@ snapshots: | ... | @@ -15480,10 +15302,6 @@ snapshots: |
| 15480 | 15302 | ||
| 15481 | estree-walker@2.0.2: {} | 15303 | estree-walker@2.0.2: {} |
| 15482 | 15304 | ||
| 15483 | estree-walker@3.0.3: | ||
| 15484 | dependencies: | ||
| 15485 | '@types/estree': 1.0.9 | ||
| 15486 | |||
| 15487 | etag@1.8.1: {} | 15305 | etag@1.8.1: {} |
| 15488 | 15306 | ||
| 15489 | event-target-shim@5.0.1: {} | 15307 | event-target-shim@5.0.1: {} |
| ... | @@ -15496,8 +15314,6 @@ snapshots: | ... | @@ -15496,8 +15314,6 @@ snapshots: |
| 15496 | dependencies: | 15314 | dependencies: |
| 15497 | eventsource-parser: 3.1.0 | 15315 | eventsource-parser: 3.1.0 |
| 15498 | 15316 | ||
| 15499 | expect-type@1.3.0: {} | ||
| 15500 | |||
| 15501 | expo-application@56.0.3(expo@56.0.8): | 15317 | expo-application@56.0.3(expo@56.0.8): |
| 15502 | dependencies: | 15318 | dependencies: |
| 15503 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) | 15319 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| ... | @@ -15669,6 +15485,11 @@ snapshots: | ... | @@ -15669,6 +15485,11 @@ snapshots: |
| 15669 | dependencies: | 15485 | dependencies: |
| 15670 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 15486 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 15671 | 15487 | ||
| 15488 | expo-network@56.0.5(expo@56.0.8)(react@19.2.3): | ||
| 15489 | dependencies: | ||
| 15490 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) | ||
| 15491 | react: 19.2.3 | ||
| 15492 | |||
| 15672 | expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): | 15493 | expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): |
| 15673 | dependencies: | 15494 | dependencies: |
| 15674 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | 15495 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| ... | @@ -16092,9 +15913,6 @@ snapshots: | ... | @@ -16092,9 +15913,6 @@ snapshots: |
| 16092 | 15913 | ||
| 16093 | fs.realpath@1.0.0: {} | 15914 | fs.realpath@1.0.0: {} |
| 16094 | 15915 | ||
| 16095 | fsevents@2.3.2: | ||
| 16096 | optional: true | ||
| 16097 | |||
| 16098 | fsevents@2.3.3: | 15916 | fsevents@2.3.3: |
| 16099 | optional: true | 15917 | optional: true |
| 16100 | 15918 | ||
| ... | @@ -17742,8 +17560,6 @@ snapshots: | ... | @@ -17742,8 +17560,6 @@ snapshots: |
| 17742 | 17560 | ||
| 17743 | obug@2.1.1: {} | 17561 | obug@2.1.1: {} |
| 17744 | 17562 | ||
| 17745 | obug@2.1.2: {} | ||
| 17746 | |||
| 17747 | ofetch@1.5.1: | 17563 | ofetch@1.5.1: |
| 17748 | dependencies: | 17564 | dependencies: |
| 17749 | destr: 2.0.5 | 17565 | destr: 2.0.5 |
| ... | @@ -18023,12 +17839,6 @@ snapshots: | ... | @@ -18023,12 +17839,6 @@ snapshots: |
| 18023 | 17839 | ||
| 18024 | playwright-core@1.60.0: {} | 17840 | playwright-core@1.60.0: {} |
| 18025 | 17841 | ||
| 18026 | playwright@1.60.0: | ||
| 18027 | dependencies: | ||
| 18028 | playwright-core: 1.60.0 | ||
| 18029 | optionalDependencies: | ||
| 18030 | fsevents: 2.3.2 | ||
| 18031 | |||
| 18032 | plist@3.1.0: | 17842 | plist@3.1.0: |
| 18033 | dependencies: | 17843 | dependencies: |
| 18034 | '@xmldom/xmldom': 0.8.13 | 17844 | '@xmldom/xmldom': 0.8.13 |
| ... | @@ -18966,8 +18776,6 @@ snapshots: | ... | @@ -18966,8 +18776,6 @@ snapshots: |
| 18966 | side-channel-map: 1.0.1 | 18776 | side-channel-map: 1.0.1 |
| 18967 | side-channel-weakmap: 1.0.2 | 18777 | side-channel-weakmap: 1.0.2 |
| 18968 | 18778 | ||
| 18969 | siginfo@2.0.0: {} | ||
| 18970 | |||
| 18971 | signal-exit@3.0.7: {} | 18779 | signal-exit@3.0.7: {} |
| 18972 | 18780 | ||
| 18973 | signal-exit@4.1.0: {} | 18781 | signal-exit@4.1.0: {} |
| ... | @@ -19039,8 +18847,6 @@ snapshots: | ... | @@ -19039,8 +18847,6 @@ snapshots: |
| 19039 | dependencies: | 18847 | dependencies: |
| 19040 | escape-string-regexp: 2.0.0 | 18848 | escape-string-regexp: 2.0.0 |
| 19041 | 18849 | ||
| 19042 | stackback@0.0.2: {} | ||
| 19043 | |||
| 19044 | stackframe@1.3.4: {} | 18850 | stackframe@1.3.4: {} |
| 19045 | 18851 | ||
| 19046 | stacktrace-parser@0.1.11: | 18852 | stacktrace-parser@0.1.11: |
| ... | @@ -19228,8 +19034,6 @@ snapshots: | ... | @@ -19228,8 +19034,6 @@ snapshots: |
| 19228 | 19034 | ||
| 19229 | tinypool@2.1.0: {} | 19035 | tinypool@2.1.0: {} |
| 19230 | 19036 | ||
| 19231 | tinyrainbow@3.1.0: {} | ||
| 19232 | |||
| 19233 | tldts-core@7.4.2: {} | 19037 | tldts-core@7.4.2: {} |
| 19234 | 19038 | ||
| 19235 | tldts@7.4.2: | 19039 | tldts@7.4.2: |
| ... | @@ -19571,42 +19375,6 @@ snapshots: | ... | @@ -19571,42 +19375,6 @@ snapshots: |
| 19571 | optionalDependencies: | 19375 | optionalDependencies: |
| 19572 | vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' | 19376 | vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' |
| 19573 | 19377 | ||
| 19574 | vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))): | ||
| 19575 | dependencies: | ||
| 19576 | react: 19.2.6 | ||
| 19577 | react-dom: 19.2.6(react@19.2.6) | ||
| 19578 | vitest: 4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) | ||
| 19579 | optionalDependencies: | ||
| 19580 | '@types/react': 19.2.16 | ||
| 19581 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | ||
| 19582 | |||
| 19583 | vitest@4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): | ||
| 19584 | dependencies: | ||
| 19585 | '@vitest/expect': 4.1.8 | ||
| 19586 | '@vitest/mocker': 4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) | ||
| 19587 | '@vitest/pretty-format': 4.1.8 | ||
| 19588 | '@vitest/runner': 4.1.8 | ||
| 19589 | '@vitest/snapshot': 4.1.8 | ||
| 19590 | '@vitest/spy': 4.1.8 | ||
| 19591 | '@vitest/utils': 4.1.8 | ||
| 19592 | es-module-lexer: 2.1.0 | ||
| 19593 | expect-type: 1.3.0 | ||
| 19594 | magic-string: 0.30.21 | ||
| 19595 | obug: 2.1.2 | ||
| 19596 | pathe: 2.0.3 | ||
| 19597 | picomatch: 4.0.4 | ||
| 19598 | std-env: 4.1.0 | ||
| 19599 | tinybench: 2.9.0 | ||
| 19600 | tinyexec: 1.2.4 | ||
| 19601 | tinyglobby: 0.2.17 | ||
| 19602 | tinyrainbow: 3.1.0 | ||
| 19603 | vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' | ||
| 19604 | why-is-node-running: 2.3.0 | ||
| 19605 | optionalDependencies: | ||
| 19606 | '@types/node': 24.12.4 | ||
| 19607 | transitivePeerDependencies: | ||
| 19608 | - msw | ||
| 19609 | |||
| 19610 | vlq@1.0.1: {} | 19378 | vlq@1.0.1: {} |
| 19611 | 19379 | ||
| 19612 | volar-service-css@0.0.70(@volar/language-service@2.4.28): | 19380 | volar-service-css@0.0.70(@volar/language-service@2.4.28): |
| ... | @@ -19746,11 +19514,6 @@ snapshots: | ... | @@ -19746,11 +19514,6 @@ snapshots: |
| 19746 | dependencies: | 19514 | dependencies: |
| 19747 | isexe: 4.0.0 | 19515 | isexe: 4.0.0 |
| 19748 | 19516 | ||
| 19749 | why-is-node-running@2.3.0: | ||
| 19750 | dependencies: | ||
| 19751 | siginfo: 2.0.0 | ||
| 19752 | stackback: 0.0.2 | ||
| 19753 | |||
| 19754 | widest-line@6.0.0: | 19517 | widest-line@6.0.0: |
| 19755 | dependencies: | 19518 | dependencies: |
| 19756 | string-width: 8.2.1 | 19519 | string-width: 8.2.1 |
vite.config.ts+16-1| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 1 | import "vite-plus/test/config"; | 1 | import "vite-plus/test/config"; |
| 2 | import { defineConfig } from "vite-plus"; | 2 | import { defineConfig } from "vite-plus"; |
| 3 | import { fileURLToPath } from "node:url"; | ||
| 3 | 4 | ||
| 4 | export default defineConfig({ | 5 | export default defineConfig({ |
| 5 | resolve: { | 6 | resolve: { |
| 6 | tsconfigPaths: true, | 7 | alias: { |
| 8 | "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), | ||
| 9 | }, | ||
| 7 | }, | 10 | }, |
| 8 | test: { | 11 | test: { |
| 9 | environment: "node", | 12 | environment: "node", |
| ... | @@ -93,6 +96,18 @@ export default defineConfig({ | ... | @@ -93,6 +96,18 @@ export default defineConfig({ |
| 93 | "typescript/require-array-sort-compare": "off", | 96 | "typescript/require-array-sort-compare": "off", |
| 94 | "typescript/restrict-template-expressions": "off", | 97 | "typescript/restrict-template-expressions": "off", |
| 95 | "typescript/unbound-method": "off", | 98 | "typescript/unbound-method": "off", |
| 99 | "eslint/no-restricted-imports": [ | ||
| 100 | "error", | ||
| 101 | { | ||
| 102 | paths: [ | ||
| 103 | { | ||
| 104 | name: "@t3tools/client-runtime", | ||
| 105 | message: | ||
| 106 | "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.", | ||
| 107 | }, | ||
| 108 | ], | ||
| 109 | }, | ||
| 110 | ], | ||
| 96 | "t3code/no-global-process-runtime": "error", | 111 | "t3code/no-global-process-runtime": "error", |
| 97 | "t3code/no-inline-schema-compile": "warn", | 112 | "t3code/no-inline-schema-compile": "warn", |
| 98 | "t3code/no-manual-effect-runtime-in-tests": "error", | 113 | "t3code/no-manual-effect-runtime-in-tests": "error", |